first commit
This commit is contained in:
4
src/App.vue
Normal file
4
src/App.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
<script setup lang="ts"></script>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
85
src/components/baInput/components/array.vue
Normal file
85
src/components/baInput/components/array.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="10" class="ba-array-key">{{ state.keyTitle }}</el-col>
|
||||
<el-col :span="10" class="ba-array-value">{{ state.valueTitle }}</el-col>
|
||||
</el-row>
|
||||
<el-row class="ba-array-item" v-for="(item, idx) in state.value" :gutter="10" :key="idx">
|
||||
<el-col :span="10">
|
||||
<el-input v-model="item.key"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-input v-model="item.value"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button @click="onDelArrayItem(idx)" size="small" icon="el-icon-Delete" circle />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="10" :offset="10">
|
||||
<el-button v-blur class="ba-add-array-item" @click="onAddArrayItem" icon="el-icon-Plus">{{ t('Add') }}</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type baInputArray = { key: string; value: string }
|
||||
interface Props {
|
||||
modelValue: baInputArray[]
|
||||
keyTitle?: string
|
||||
valueTitle?: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
keyTitle: '',
|
||||
valueTitle: '',
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
value: props.modelValue,
|
||||
keyTitle: props.keyTitle ? props.keyTitle : t('utils.ArrayKey'),
|
||||
valueTitle: props.valueTitle ? props.valueTitle : t('utils.ArrayValue'),
|
||||
})
|
||||
|
||||
const onAddArrayItem = () => {
|
||||
state.value.push({
|
||||
key: '',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
|
||||
const onDelArrayItem = (idx: number) => {
|
||||
state.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
state.value = newVal
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-array-key,
|
||||
.ba-array-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.ba-array-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ba-add-array-item {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
430
src/components/baInput/components/baUpload.vue
Normal file
430
src/components/baInput/components/baUpload.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div class="w100">
|
||||
<el-upload
|
||||
ref="upload"
|
||||
class="ba-upload"
|
||||
:class="type"
|
||||
v-model:file-list="state.fileList"
|
||||
:auto-upload="false"
|
||||
@change="onElChange"
|
||||
@remove="onElRemove"
|
||||
@preview="onElPreview"
|
||||
@exceed="onElExceed"
|
||||
v-bind="state.attr"
|
||||
:key="state.key"
|
||||
>
|
||||
<!-- 插槽支持,不加 if 时 el-upload 样式会错乱 -->
|
||||
<template v-if="slots.default" #default><slot name="default"></slot></template>
|
||||
<template v-else #default>
|
||||
<template v-if="type == 'image' || type == 'images'">
|
||||
<div v-if="!hideSelectFile" @click.stop="state.selectFile.show = true" class="ba-upload-select-image">
|
||||
{{ $t('utils.choice') }}
|
||||
</div>
|
||||
<Icon class="ba-upload-icon" name="el-icon-Plus" size="30" color="#c0c4cc" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button v-blur type="primary">
|
||||
<Icon name="el-icon-Plus" color="#ffffff" />
|
||||
<span>{{ $t('Upload') }}</span>
|
||||
</el-button>
|
||||
<el-button v-blur v-if="!hideSelectFile" @click.stop="state.selectFile.show = true" type="success">
|
||||
<Icon name="fa fa-th-list" size="14px" color="#ffffff" />
|
||||
<span class="ml-6">{{ $t('utils.choice') }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="slots.trigger" #trigger><slot name="trigger"></slot></template>
|
||||
<template v-if="slots.tip" #tip><slot name="tip"></slot></template>
|
||||
<template v-if="slots.file" #file><slot name="file"></slot></template>
|
||||
</el-upload>
|
||||
<el-dialog v-model="state.preview.show" class="ba-upload-preview">
|
||||
<div class="ba-upload-preview-scroll ba-scroll-style">
|
||||
<img :src="state.preview.url" class="ba-upload-preview-img" alt="" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
<SelectFile v-model="state.selectFile.show" v-bind="state.selectFile" @choice="onChoice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, useSlots, nextTick } from 'vue'
|
||||
import { genFileId } from 'element-plus'
|
||||
import type { UploadInstance, UploadUserFile, UploadProps, UploadRawFile, UploadFiles } from 'element-plus'
|
||||
import { stringToArray } from '@/components/baInput/helper'
|
||||
import { fullUrl, arrayFullUrl, getFileNameFromPath, getArrayKey } from '@/utils/common'
|
||||
import { fileUpload } from '@/api/common'
|
||||
import SelectFile from '@/components/baInput/components/selectFile.vue'
|
||||
import { uuid } from '@/utils/random'
|
||||
import { cloneDeep, isEmpty } from 'lodash-es'
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
|
||||
interface Props {
|
||||
type: 'image' | 'images' | 'file' | 'files'
|
||||
// 上传请求时的额外携带数据
|
||||
data?: anyObj
|
||||
modelValue: string | string[]
|
||||
// 返回绝对路径
|
||||
returnFullUrl?: boolean
|
||||
// 隐藏附件选择器
|
||||
hideSelectFile?: boolean
|
||||
// 可自定义el-upload的其他属性
|
||||
attr?: Partial<Writeable<UploadProps>>
|
||||
// 强制上传到本地存储
|
||||
forceLocal?: boolean
|
||||
}
|
||||
interface UploadFileExt extends UploadUserFile {
|
||||
serverUrl?: string
|
||||
}
|
||||
interface UploadProgressEvent extends AxiosProgressEvent {
|
||||
percent: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'image',
|
||||
data: () => {
|
||||
return {}
|
||||
},
|
||||
modelValue: () => [],
|
||||
returnFullUrl: false,
|
||||
hideSelectFile: false,
|
||||
attr: () => {
|
||||
return {}
|
||||
},
|
||||
forceLocal: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const upload = ref<UploadInstance>()
|
||||
const state: {
|
||||
key: string
|
||||
// 返回值类型,通过v-model类型动态计算
|
||||
defaultReturnType: 'string' | 'array'
|
||||
// 预览弹窗
|
||||
preview: {
|
||||
show: boolean
|
||||
url: string
|
||||
}
|
||||
// 文件列表
|
||||
fileList: UploadFileExt[]
|
||||
// el-upload的属性对象
|
||||
attr: Partial<UploadProps>
|
||||
// 正在上传的文件数量
|
||||
uploading: number
|
||||
// 显示选择文件窗口
|
||||
selectFile: {
|
||||
show: boolean
|
||||
type?: 'image' | 'file'
|
||||
limit?: number
|
||||
returnFullUrl: boolean
|
||||
}
|
||||
events: anyObj
|
||||
} = reactive({
|
||||
key: uuid(),
|
||||
defaultReturnType: 'string',
|
||||
preview: {
|
||||
show: false,
|
||||
url: '',
|
||||
},
|
||||
fileList: [],
|
||||
attr: {},
|
||||
uploading: 0,
|
||||
selectFile: {
|
||||
show: false,
|
||||
type: 'file',
|
||||
returnFullUrl: props.returnFullUrl,
|
||||
},
|
||||
events: [],
|
||||
})
|
||||
|
||||
const onElChange = (file: UploadFileExt, files: UploadFiles) => {
|
||||
// 将 file 换为 files 中的对象,以便修改属性等操作
|
||||
const fileIndex = getArrayKey(files, 'uid', file.uid!)
|
||||
if (!fileIndex) return
|
||||
file = files[fileIndex] as UploadFileExt
|
||||
if (!file || !file.raw) return
|
||||
if (typeof state.events['beforeUpload'] == 'function' && state.events['beforeUpload'](file) === false) return
|
||||
let fd = new FormData()
|
||||
fd.append('file', file.raw)
|
||||
fd = formDataAppend(fd)
|
||||
state.uploading++
|
||||
fileUpload(fd, { uuid: uuid() }, props.forceLocal, {
|
||||
onUploadProgress: (evt: AxiosProgressEvent) => {
|
||||
const progressEvt = evt as UploadProgressEvent
|
||||
if (evt.total && evt.total > 0) {
|
||||
progressEvt.percent = (evt.loaded / evt.total) * 100
|
||||
file.status = 'uploading'
|
||||
file.percentage = Math.round(progressEvt.percent)
|
||||
typeof state.events['onProgress'] == 'function' && state.events['onProgress'](progressEvt, file, files)
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code == 1) {
|
||||
file.serverUrl = res.data.file.url
|
||||
file.status = 'success'
|
||||
emits('update:modelValue', getAllUrls())
|
||||
typeof state.events['onSuccess'] == 'function' && state.events['onSuccess'](res, file, files)
|
||||
} else {
|
||||
file.status = 'fail'
|
||||
files.splice(fileIndex, 1)
|
||||
typeof state.events['onError'] == 'function' && state.events['onError'](res, file, files)
|
||||
}
|
||||
})
|
||||
.catch((res) => {
|
||||
file.status = 'fail'
|
||||
files.splice(fileIndex, 1)
|
||||
typeof state.events['onError'] == 'function' && state.events['onError'](res, file, files)
|
||||
})
|
||||
.finally(() => {
|
||||
state.uploading--
|
||||
onChange(file, files)
|
||||
})
|
||||
}
|
||||
|
||||
const onElRemove = (file: UploadUserFile, files: UploadFiles) => {
|
||||
typeof state.events['onRemove'] == 'function' && state.events['onRemove'](file, files)
|
||||
onChange(file, files)
|
||||
emits('update:modelValue', getAllUrls())
|
||||
}
|
||||
|
||||
const onElPreview = (file: UploadFileExt) => {
|
||||
typeof state.events['onPreview'] == 'function' && state.events['onPreview'](file)
|
||||
if (!file || !file.url) {
|
||||
return
|
||||
}
|
||||
if (props.type == 'file' || props.type == 'files') {
|
||||
window.open(fullUrl(file.url))
|
||||
return
|
||||
}
|
||||
state.preview.show = true
|
||||
state.preview.url = file.url
|
||||
}
|
||||
|
||||
const onElExceed = (files: UploadUserFile[]) => {
|
||||
const file = files[0] as UploadRawFile
|
||||
file.uid = genFileId()
|
||||
upload.value!.handleStart(file)
|
||||
typeof state.events['onExceed'] == 'function' && state.events['onExceed'](file, files)
|
||||
}
|
||||
|
||||
const onChoice = (files: string[]) => {
|
||||
let oldValArr = getAllUrls('array') as string[]
|
||||
files = oldValArr.concat(files)
|
||||
init(files)
|
||||
emits('update:modelValue', getAllUrls())
|
||||
onChange(files, state.fileList)
|
||||
state.selectFile.show = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化文件/图片的排序功能
|
||||
*/
|
||||
const initSort = () => {
|
||||
nextTick(() => {
|
||||
let uploadListEl = upload.value?.$el.querySelector('.el-upload-list')
|
||||
let uploadItemEl = uploadListEl.getElementsByClassName('el-upload-list__item')
|
||||
if (uploadItemEl.length >= 2) {
|
||||
Sortable.create(uploadListEl, {
|
||||
animation: 200,
|
||||
draggable: '.el-upload-list__item',
|
||||
onEnd: (evt: Sortable.SortableEvent) => {
|
||||
if (evt.oldIndex != evt.newIndex) {
|
||||
state.fileList[evt.newIndex!] = [
|
||||
state.fileList[evt.oldIndex!],
|
||||
(state.fileList[evt.oldIndex!] = state.fileList[evt.newIndex!]),
|
||||
][0]
|
||||
emits('update:modelValue', getAllUrls())
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.type == 'image' || props.type == 'file') {
|
||||
state.attr = { ...state.attr, limit: 1 }
|
||||
} else {
|
||||
state.attr = { ...state.attr, multiple: true }
|
||||
}
|
||||
|
||||
if (props.type == 'image' || props.type == 'images') {
|
||||
state.selectFile.type = 'image'
|
||||
state.attr = { ...state.attr, accept: 'image/*', listType: 'picture-card' }
|
||||
}
|
||||
|
||||
const addProps: anyObj = {}
|
||||
const evtArr = ['onPreview', 'onRemove', 'onSuccess', 'onError', 'onChange', 'onExceed', 'beforeUpload', 'onProgress']
|
||||
for (const key in props.attr) {
|
||||
if (evtArr.includes(key)) {
|
||||
state.events[key] = props.attr[key as keyof typeof props.attr]
|
||||
} else {
|
||||
addProps[key] = props.attr[key as keyof typeof props.attr]
|
||||
}
|
||||
}
|
||||
|
||||
state.attr = { ...state.attr, ...addProps }
|
||||
if (state.attr.limit) state.selectFile.limit = state.attr.limit
|
||||
|
||||
init(props.modelValue)
|
||||
|
||||
initSort()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (state.uploading > 0) return
|
||||
if (newVal === undefined || newVal === null) {
|
||||
return init('')
|
||||
}
|
||||
let newValArr = arrayFullUrl(stringToArray(cloneDeep(newVal)))
|
||||
let oldValArr = arrayFullUrl(getAllUrls('array'))
|
||||
if (newValArr.sort().toString() != oldValArr.sort().toString()) {
|
||||
init(newVal)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const limitExceed = () => {
|
||||
if (state.attr.limit && state.fileList.length > state.attr.limit) {
|
||||
state.fileList = state.fileList.slice(state.fileList.length - state.attr.limit)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const init = (modelValue: string | string[]) => {
|
||||
let urls = stringToArray(modelValue as string)
|
||||
state.fileList = []
|
||||
state.defaultReturnType = typeof modelValue === 'string' || props.type == 'file' || props.type == 'image' ? 'string' : 'array'
|
||||
|
||||
for (const key in urls) {
|
||||
state.fileList.push({
|
||||
name: getFileNameFromPath(urls[key]),
|
||||
url: fullUrl(urls[key]),
|
||||
serverUrl: urls[key],
|
||||
})
|
||||
}
|
||||
|
||||
// 超出过滤 || 确定返回的URL完整
|
||||
if (limitExceed() || props.returnFullUrl) {
|
||||
emits('update:modelValue', getAllUrls())
|
||||
}
|
||||
state.key = uuid()
|
||||
}
|
||||
|
||||
// 获取当前所有图片路径的列表
|
||||
const getAllUrls = (returnType: string = state.defaultReturnType) => {
|
||||
limitExceed()
|
||||
let urlList = []
|
||||
for (const key in state.fileList) {
|
||||
if (state.fileList[key].serverUrl) urlList.push(state.fileList[key].serverUrl)
|
||||
}
|
||||
if (props.returnFullUrl) urlList = arrayFullUrl(urlList as string[])
|
||||
return returnType === 'string' ? urlList.join(',') : (urlList as string[])
|
||||
}
|
||||
|
||||
const formDataAppend = (fd: FormData) => {
|
||||
if (props.data && !isEmpty(props.data)) {
|
||||
for (const key in props.data) {
|
||||
fd.append(key, props.data[key])
|
||||
}
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
const onChange = (file: string | string[] | UploadFileExt, files: UploadFileExt[]) => {
|
||||
initSort()
|
||||
typeof state.events['onChange'] == 'function' && state.events['onChange'](file, files)
|
||||
}
|
||||
|
||||
const getUploadRef = () => {
|
||||
return upload.value
|
||||
}
|
||||
|
||||
const showSelectFile = () => {
|
||||
state.selectFile.show = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getUploadRef,
|
||||
showSelectFile,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-upload-select-image {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-top: 1px dashed transparent;
|
||||
width: var(--el-upload-picture-card-size);
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 6px;
|
||||
border-bottom-right-radius: 20px;
|
||||
border-bottom-left-radius: 20px;
|
||||
text-align: center;
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
color: var(--el-text-color-regular);
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
border: 1px dashed var(--el-color-primary);
|
||||
border-top: 1px dashed var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
.ba-upload :deep(.el-upload:hover .ba-upload-icon) {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
:deep(.ba-upload-preview) .el-dialog__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
}
|
||||
.ba-upload-preview-scroll {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.ba-upload-preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
:deep(.el-dialog__headerbtn) {
|
||||
top: 2px;
|
||||
width: 37px;
|
||||
height: 37px;
|
||||
}
|
||||
.ba-upload.image :deep(.el-upload--picture-card),
|
||||
.ba-upload.images :deep(.el-upload--picture-card) {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ba-upload.file :deep(.el-upload-list),
|
||||
.ba-upload.files :deep(.el-upload-list) {
|
||||
margin-left: -10px;
|
||||
}
|
||||
.ba-upload.files,
|
||||
.ba-upload.images {
|
||||
:deep(.el-upload-list__item) {
|
||||
user-select: none;
|
||||
.el-upload-list__item-actions,
|
||||
.el-upload-list__item-name {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ml-6 {
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
39
src/components/baInput/components/editor.vue
Normal file
39
src/components/baInput/components/editor.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- 多编辑器共存支持 -->
|
||||
<!-- 所有编辑器的代码位于 @/components/mixins/editor 文件夹,一个文件为一种编辑器,文件名则为编辑器名称 -->
|
||||
<!-- 向本组件传递 editorType(文件名/编辑器名称)自动加载对应的编辑器进行渲染 -->
|
||||
<template>
|
||||
<div>
|
||||
<component v-bind="$attrs" :is="mixins[state.editorType]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface Props {
|
||||
editorType?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
editorType: 'default',
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
editorType: props.editorType,
|
||||
})
|
||||
|
||||
const mixins: Record<string, Component> = {}
|
||||
const mixinComponents: Record<string, any> = import.meta.glob('../../mixins/editor/**.vue', { eager: true })
|
||||
for (const key in mixinComponents) {
|
||||
const fileName = key.replace('../../mixins/editor/', '').replace('.vue', '')
|
||||
mixins[fileName] = mixinComponents[key].default
|
||||
|
||||
// 未安装富文本编辑器时,值为 default,安装之后,则值为最后一个编辑器的名称
|
||||
if (props.editorType == 'default' && fileName != 'default') {
|
||||
state.editorType = fileName
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
296
src/components/baInput/components/iconSelector.vue
Normal file
296
src/components/baInput/components/iconSelector.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<el-popover
|
||||
:placement="placement"
|
||||
trigger="focus"
|
||||
:hide-after="0"
|
||||
:width="state.selectorWidth"
|
||||
:visible="state.popoverVisible"
|
||||
>
|
||||
<div
|
||||
@mouseover.stop="state.iconSelectorMouseover = true"
|
||||
@mouseout.stop="state.iconSelectorMouseover = false"
|
||||
class="icon-selector"
|
||||
>
|
||||
<transition name="el-zoom-in-center">
|
||||
<div class="icon-selector-box">
|
||||
<div class="selector-header">
|
||||
<div class="selector-title">{{ title ? title : '请选择图标' }}</div>
|
||||
<!-- <div class="selector-tab">
|
||||
<span
|
||||
:title="'Element Puls ' + 'utils.Icon'"
|
||||
@click="onChangeTab('ele')"
|
||||
:class="state.iconType == 'ele' ? 'active' : ''"
|
||||
>ele</span
|
||||
>
|
||||
<span
|
||||
:title="'Font Awesome ' + 'utils.Icon'"
|
||||
@click="onChangeTab('awe')"
|
||||
:class="state.iconType == 'awe' ? 'active' : ''"
|
||||
>awe</span
|
||||
>
|
||||
<span :title="'utils.Ali iconcont Icon'" @click="onChangeTab('ali')" :class="state.iconType == 'ali' ? 'active' : ''"
|
||||
>ali</span
|
||||
>
|
||||
<span
|
||||
:title="'utils.Local icon title'"
|
||||
@click="onChangeTab('local')"
|
||||
:class="state.iconType == 'local' ? 'active' : ''"
|
||||
>local</span
|
||||
>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="selector-body">
|
||||
<el-scrollbar ref="selectorScrollbarRef">
|
||||
<div v-if="renderFontIconNames.length > 0">
|
||||
<div
|
||||
class="icon-selector-item"
|
||||
:title="item"
|
||||
@click="onIcon(item)"
|
||||
v-for="(item, key) in renderFontIconNames"
|
||||
:key="key"
|
||||
>
|
||||
<Icon :name="item" />
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<template #reference>
|
||||
<el-input
|
||||
v-model="state.inputValue"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
placeholder="搜索图标"
|
||||
ref="selectorInput"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
:class="'size-' + size"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="icon-prepend">
|
||||
<Icon
|
||||
:key="'icon' + state.iconKey"
|
||||
:name="state.prependIcon ? state.prependIcon : state.defaultModelValue"
|
||||
/>
|
||||
<div v-if="showIconName" class="name">
|
||||
{{ state.prependIcon ? state.prependIcon : state.defaultModelValue }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #append>
|
||||
<Icon @click="onInputRefresh" name="el-icon-RefreshRight" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { getElementPlusIconfontNames } from '@/utils/iconfont'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { Placement } from 'element-plus'
|
||||
|
||||
type IconType = 'ele' | 'awe' | 'ali' | 'local'
|
||||
|
||||
interface Props {
|
||||
size?: 'default' | 'small' | 'large'
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
type?: IconType
|
||||
placement?: Placement
|
||||
modelValue?: string
|
||||
showIconName?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
title: '',
|
||||
type: 'ele',
|
||||
placement: 'bottom',
|
||||
modelValue: '',
|
||||
showIconName: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const selectorInput = ref()
|
||||
const selectorScrollbarRef = ref()
|
||||
const state: {
|
||||
iconType: IconType
|
||||
selectorWidth: number
|
||||
popoverVisible: boolean
|
||||
inputFocus: boolean
|
||||
iconSelectorMouseover: boolean
|
||||
fontIconNames: string[]
|
||||
inputValue: string
|
||||
prependIcon: string
|
||||
defaultModelValue: string
|
||||
iconKey: number
|
||||
} = reactive({
|
||||
iconType: props.type,
|
||||
selectorWidth: 0,
|
||||
popoverVisible: false,
|
||||
inputFocus: false,
|
||||
iconSelectorMouseover: false,
|
||||
fontIconNames: [],
|
||||
inputValue: '',
|
||||
prependIcon: props.modelValue,
|
||||
defaultModelValue: props.modelValue || 'fa fa-circle-o',
|
||||
iconKey: 0, // 给icon标签准备个key,以随时使用 h 函数重新生成元素
|
||||
})
|
||||
|
||||
const onInputFocus = () => {
|
||||
state.inputFocus = state.popoverVisible = true
|
||||
}
|
||||
const onInputBlur = () => {
|
||||
state.inputFocus = false
|
||||
state.popoverVisible = state.iconSelectorMouseover
|
||||
}
|
||||
const onInputRefresh = () => {
|
||||
state.iconKey++
|
||||
state.prependIcon = state.defaultModelValue
|
||||
state.inputValue = ''
|
||||
emits('update:modelValue', state.defaultModelValue)
|
||||
emits('change', state.defaultModelValue)
|
||||
// }
|
||||
// const onChangeTab = (name: IconType) => {
|
||||
// state.iconType = name
|
||||
// state.fontIconNames = []
|
||||
// if (name == 'ele') {
|
||||
// getElementPlusIconfontNames().then((res) => {
|
||||
// state.fontIconNames = res
|
||||
// })
|
||||
// } else if (name == 'awe') {
|
||||
// getAwesomeIconfontNames().then((res) => {
|
||||
// state.fontIconNames = res.map((name) => `fa ${name}`)
|
||||
// })
|
||||
// } else if (name == 'ali') {
|
||||
// getIconfontNames().then((res) => {
|
||||
// state.fontIconNames = res.map((name) => `iconfont ${name}`)
|
||||
// })
|
||||
// } else if (name == 'local') {
|
||||
// getLocalIconfontNames().then((res) => {
|
||||
// state.fontIconNames = res
|
||||
// })
|
||||
// }
|
||||
}
|
||||
const onIcon = (icon: string) => {
|
||||
state.iconSelectorMouseover = state.popoverVisible = false
|
||||
state.iconKey++
|
||||
state.prependIcon = icon
|
||||
state.inputValue = ''
|
||||
emits('update:modelValue', icon)
|
||||
emits('change', icon)
|
||||
nextTick(() => {
|
||||
selectorInput.value.blur()
|
||||
})
|
||||
}
|
||||
|
||||
const renderFontIconNames = computed(() => {
|
||||
if (!state.inputValue) return state.fontIconNames
|
||||
|
||||
let inputValue = state.inputValue.trim().toLowerCase()
|
||||
return state.fontIconNames.filter((icon: string) => {
|
||||
if (icon.toLowerCase().indexOf(inputValue) !== -1) {
|
||||
return icon
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 获取 input 的宽度
|
||||
const getInputWidth = () => {
|
||||
nextTick(() => {
|
||||
state.selectorWidth = selectorInput.value.$el.offsetWidth < 260 ? 260 : selectorInput.value.$el.offsetWidth
|
||||
})
|
||||
}
|
||||
|
||||
const popoverVisible = () => {
|
||||
state.popoverVisible = state.inputFocus || state.iconSelectorMouseover ? true : false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
state.iconKey++
|
||||
if (props.modelValue != state.prependIcon) state.defaultModelValue = props.modelValue
|
||||
if (props.modelValue == '') state.defaultModelValue = 'fa fa-circle-o'
|
||||
state.prependIcon = props.modelValue
|
||||
}
|
||||
)
|
||||
onMounted(() => {
|
||||
getInputWidth()
|
||||
useEventListener(document, 'click', popoverVisible)
|
||||
getElementPlusIconfontNames().then((res) => {
|
||||
state.fontIconNames = res
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.size-small {
|
||||
height: 24px;
|
||||
}
|
||||
.size-large {
|
||||
height: 40px;
|
||||
}
|
||||
.size-default {
|
||||
height: 32px;
|
||||
}
|
||||
.icon-prepend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.name {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
.selector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.selector-tab {
|
||||
margin-left: auto;
|
||||
span {
|
||||
padding: 0 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
&.active,
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.selector-body {
|
||||
height: 250px;
|
||||
}
|
||||
.icon-selector-item {
|
||||
display: inline-block;
|
||||
padding: 10px 10px 6px 10px;
|
||||
margin: 3px;
|
||||
border: 1px solid var(--ba-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
.icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
&:hover {
|
||||
border: 1px solid var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
:deep(.el-input-group__prepend) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
</style>
|
||||
310
src/components/baInput/components/remoteSelect.vue
Normal file
310
src/components/baInput/components/remoteSelect.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<div class="w100">
|
||||
<!-- el-select 的远程下拉只在有搜索词时,才会加载数据(显示出 option 列表) -->
|
||||
<!-- 使用 el-popover 在无数据/无搜索词时,显示一个无数据的提醒 -->
|
||||
<el-popover
|
||||
width="100%"
|
||||
placement="bottom"
|
||||
popper-class="remote-select-popper"
|
||||
:visible="state.focusStatus && !state.loading && !state.keyword && !state.options.length"
|
||||
:teleported="false"
|
||||
:content="$t('utils.No data')"
|
||||
>
|
||||
<template #reference>
|
||||
<el-select
|
||||
ref="selectRef"
|
||||
class="w100"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
:loading="state.loading || state.accidentBlur"
|
||||
:filterable="true"
|
||||
:remote="true"
|
||||
clearable
|
||||
remote-show-suffix
|
||||
:remote-method="onLogKeyword"
|
||||
v-model="state.value"
|
||||
@change="onChangeSelect"
|
||||
:multiple="multiple"
|
||||
:key="state.selectKey"
|
||||
@clear="onClear"
|
||||
@visible-change="onVisibleChange"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<el-option
|
||||
class="remote-select-option"
|
||||
v-for="item in state.options"
|
||||
:label="item[field]"
|
||||
:value="item[state.primaryKey].toString()"
|
||||
:key="item[state.primaryKey]"
|
||||
>
|
||||
<el-tooltip placement="right" effect="light" v-if="!isEmpty(tooltipParams)">
|
||||
<template #content>
|
||||
<p v-for="(tooltipParam, key) in tooltipParams" :key="key">{{ key }}: {{ item[tooltipParam] }}</p>
|
||||
</template>
|
||||
<div>{{ item[field] }}</div>
|
||||
</el-tooltip>
|
||||
</el-option>
|
||||
<el-pagination
|
||||
v-if="state.total"
|
||||
:currentPage="state.currentPage"
|
||||
:page-size="state.pageSize"
|
||||
class="select-pagination"
|
||||
layout="->, prev, next"
|
||||
:total="state.total"
|
||||
@current-change="onSelectCurrentPageChange"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, onMounted, onUnmounted, ref, nextTick, getCurrentInstance, toRaw } from 'vue'
|
||||
import { getSelectData } from '@/api/common'
|
||||
import { uuid } from '@/utils/random'
|
||||
import type { ElSelect } from 'element-plus'
|
||||
import { isEmpty } from 'lodash-es'
|
||||
import { getArrayKey } from '@/utils/common'
|
||||
|
||||
const selectRef = ref<InstanceType<typeof ElSelect> | undefined>()
|
||||
type ElSelectProps = Partial<InstanceType<typeof ElSelect>['$props']>
|
||||
type valType = string | number | string[] | number[]
|
||||
|
||||
interface Props extends /* @vue-ignore */ ElSelectProps {
|
||||
pk?: string
|
||||
field?: string
|
||||
params?: anyObj
|
||||
multiple?: boolean
|
||||
remoteUrl: string
|
||||
modelValue: valType
|
||||
labelFormatter?: (optionData: anyObj, optionKey: string) => string
|
||||
tooltipParams?: anyObj
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pk: 'id',
|
||||
field: 'name',
|
||||
params: () => {
|
||||
return {}
|
||||
},
|
||||
remoteUrl: '',
|
||||
modelValue: '',
|
||||
multiple: false,
|
||||
tooltipParams: () => {
|
||||
return {}
|
||||
},
|
||||
})
|
||||
|
||||
const state: {
|
||||
// 主表字段名(不带表别名)
|
||||
primaryKey: string
|
||||
options: anyObj[]
|
||||
loading: boolean
|
||||
total: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
params: anyObj
|
||||
keyword: string
|
||||
value: valType
|
||||
selectKey: string
|
||||
initializeData: boolean
|
||||
accidentBlur: boolean
|
||||
focusStatus: boolean
|
||||
} = reactive({
|
||||
primaryKey: props.pk,
|
||||
options: [],
|
||||
loading: false,
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
params: props.params,
|
||||
keyword: '',
|
||||
value: props.modelValue ? props.modelValue : '',
|
||||
selectKey: uuid(),
|
||||
initializeData: false,
|
||||
accidentBlur: false,
|
||||
focusStatus: false,
|
||||
})
|
||||
|
||||
let io: null | IntersectionObserver = null
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: valType): void
|
||||
(e: 'row', value: any): void
|
||||
}>()
|
||||
|
||||
const onChangeSelect = (val: valType) => {
|
||||
emits('update:modelValue', val)
|
||||
if (typeof instance?.vnode.props?.onRow == 'function') {
|
||||
let pkArr = props.pk.split('.')
|
||||
let pk = pkArr[pkArr.length - 1]
|
||||
if (typeof val == 'number' || typeof val == 'string') {
|
||||
const dataKey = getArrayKey(state.options, pk, val.toString())
|
||||
emits('row', dataKey ? toRaw(state.options[dataKey]) : {})
|
||||
} else {
|
||||
const valueArr = []
|
||||
for (const key in val) {
|
||||
let dataKey = getArrayKey(state.options, pk, val[key].toString())
|
||||
if (dataKey) valueArr.push(toRaw(state.options[dataKey]))
|
||||
}
|
||||
emits('row', valueArr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibleChange = (val: boolean) => {
|
||||
// 保持面板状态和焦点状态一致
|
||||
if (!val) {
|
||||
nextTick(() => {
|
||||
selectRef.value?.blur()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
state.focusStatus = true
|
||||
if (selectRef.value?.query != state.keyword) {
|
||||
state.keyword = ''
|
||||
state.initializeData = false
|
||||
// el-select 自动清理搜索词会产生意外的脱焦
|
||||
state.accidentBlur = true
|
||||
}
|
||||
if (!state.initializeData) {
|
||||
getData()
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
state.focusStatus = false
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
state.keyword = ''
|
||||
state.initializeData = false
|
||||
}
|
||||
|
||||
const onLogKeyword = (q: string) => {
|
||||
if (state.keyword != q) {
|
||||
state.keyword = q
|
||||
getData()
|
||||
}
|
||||
}
|
||||
|
||||
const getData = (initValue: valType = '') => {
|
||||
state.loading = true
|
||||
state.params.page = state.currentPage
|
||||
state.params.initKey = props.pk
|
||||
state.params.initValue = initValue
|
||||
getSelectData(props.remoteUrl, state.keyword, state.params)
|
||||
.then((res) => {
|
||||
let initializeData = true
|
||||
let opts = res.data.options ? res.data.options : res.data.list
|
||||
if (typeof props.labelFormatter == 'function') {
|
||||
for (const key in opts) {
|
||||
opts[key][props.field] = props.labelFormatter(opts[key], key)
|
||||
}
|
||||
}
|
||||
state.options = opts
|
||||
state.total = res.data.total ?? 0
|
||||
if (initValue) {
|
||||
// 重新渲染组件,确保在赋值前,opts已加载到-兼容 modelValue 更新
|
||||
state.selectKey = uuid()
|
||||
initializeData = false
|
||||
}
|
||||
state.loading = false
|
||||
state.initializeData = initializeData
|
||||
if (state.accidentBlur) {
|
||||
nextTick(() => {
|
||||
const inputEl = selectRef.value?.$el.querySelector('.el-select__tags .el-select__input')
|
||||
inputEl && inputEl.focus()
|
||||
state.accidentBlur = false
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
state.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectCurrentPageChange = (val: number) => {
|
||||
state.currentPage = val
|
||||
getData()
|
||||
}
|
||||
|
||||
const initDefaultValue = () => {
|
||||
if (state.value) {
|
||||
// number[]转string[]确保默认值能够选中
|
||||
if (typeof state.value === 'object') {
|
||||
for (const key in state.value as string[]) {
|
||||
state.value[key] = state.value[key].toString()
|
||||
}
|
||||
} else if (typeof state.value === 'number') {
|
||||
state.value = state.value.toString()
|
||||
}
|
||||
getData(state.value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.pk.indexOf('.') > 0) {
|
||||
let pk = props.pk.split('.')
|
||||
state.primaryKey = pk[1] ? pk[1] : pk[0]
|
||||
}
|
||||
initDefaultValue()
|
||||
|
||||
setTimeout(() => {
|
||||
if (window?.IntersectionObserver) {
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const key in entries) {
|
||||
if (!entries[key].isIntersecting) selectRef.value?.blur()
|
||||
}
|
||||
})
|
||||
if (selectRef.value?.$el instanceof Element) {
|
||||
io.observe(selectRef.value.$el)
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
io?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (String(state.value) != String(newVal)) {
|
||||
state.value = newVal ? newVal : ''
|
||||
initDefaultValue()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getSelectRef = () => {
|
||||
return selectRef.value
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
selectRef.value?.focus()
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
selectRef.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
blur,
|
||||
focus,
|
||||
getSelectRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.remote-select-popper) {
|
||||
text-align: center;
|
||||
}
|
||||
.remote-select-option {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
244
src/components/baInput/components/selectFile.vue
Normal file
244
src/components/baInput/components/selectFile.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-dialog
|
||||
@close="emits('update:modelValue', false)"
|
||||
width="60%"
|
||||
:model-value="modelValue"
|
||||
class="ba-upload-select-dialog"
|
||||
:title="t('utils.Select File')"
|
||||
:append-to-body="true"
|
||||
:destroy-on-close="true"
|
||||
top="4vh"
|
||||
>
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('utils.Original name') })"
|
||||
>
|
||||
<el-tooltip :content="t('utils.choice')" placement="top">
|
||||
<el-button
|
||||
@click="onChoice"
|
||||
:disabled="baTable.table.selection!.length > 0 ? false : true"
|
||||
v-blur
|
||||
class="table-header-operate"
|
||||
type="primary"
|
||||
>
|
||||
<Icon name="fa fa-check" />
|
||||
<span class="table-header-operate-text">{{ t('utils.choice') }}</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<div class="ml-10" v-if="limit !== 0">
|
||||
{{ t('utils.You can also select') }}
|
||||
<span class="selection-count">{{ limit - baTable.table.selection!.length }}</span>
|
||||
{{ t('utils.items') }}
|
||||
</div>
|
||||
</TableHeader>
|
||||
|
||||
<Table ref="tableRef" @selection-change="onSelectionChange" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, provide, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Table from '@/components/table/index.vue'
|
||||
import TableHeader from '@/components/table/header/index.vue'
|
||||
import baTableClass from '@/utils/baTable'
|
||||
import { previewRenderFormatter } from '@/views/backend/routine/attachment'
|
||||
import { baTableApi } from '@/api/common'
|
||||
|
||||
interface Props {
|
||||
type?: 'image' | 'file'
|
||||
limit?: number
|
||||
modelValue: boolean
|
||||
returnFullUrl?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'file',
|
||||
limit: 0,
|
||||
modelValue: false,
|
||||
returnFullUrl: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'choice', value: string[]): void
|
||||
}>()
|
||||
|
||||
const tableRef = ref()
|
||||
const { t } = useI18n()
|
||||
const state = reactive({
|
||||
ready: false,
|
||||
tableSelectable: true,
|
||||
})
|
||||
|
||||
const optBtn: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'choice',
|
||||
text: t('utils.choice'),
|
||||
type: 'primary',
|
||||
icon: 'fa fa-check',
|
||||
class: 'table-row-choice',
|
||||
disabledTip: false,
|
||||
click: (row: TableRow) => {
|
||||
const elTableRef = tableRef.value.getRef()
|
||||
elTableRef.clearSelection()
|
||||
emits('choice', props.returnFullUrl ? [row.full_url] : [row.url])
|
||||
},
|
||||
},
|
||||
]
|
||||
const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
|
||||
column: [
|
||||
{
|
||||
type: 'selection',
|
||||
selectable: (row: TableRow) => {
|
||||
if (props.limit == 0) return true
|
||||
if (baTable.table.selection) {
|
||||
for (const key in baTable.table.selection) {
|
||||
if (row.id == baTable.table.selection[key].id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return state.tableSelectable
|
||||
},
|
||||
align: 'center',
|
||||
operator: false,
|
||||
},
|
||||
{ label: t('Id'), prop: 'id', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), width: 70 },
|
||||
{ label: t('utils.Breakdown'), prop: 'topic', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{
|
||||
label: t('utils.preview'),
|
||||
prop: 'suffix',
|
||||
align: 'center',
|
||||
renderFormatter: previewRenderFormatter,
|
||||
render: 'image',
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('utils.type'),
|
||||
prop: 'mimetype',
|
||||
align: 'center',
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('utils.size'),
|
||||
prop: 'size',
|
||||
align: 'center',
|
||||
formatter: (row: TableRow, column: TableColumn, cellValue: string) => {
|
||||
var size = parseFloat(cellValue)
|
||||
var i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return parseInt((size / Math.pow(1024, i)).toFixed(i < 2 ? 0 : 2)) * 1 + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]
|
||||
},
|
||||
operator: 'RANGE',
|
||||
sortable: 'custom',
|
||||
operatorPlaceholder: 'bytes',
|
||||
},
|
||||
{
|
||||
label: t('utils.Last upload time'),
|
||||
prop: 'last_upload_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
width: 160,
|
||||
sortable: 'custom',
|
||||
},
|
||||
{
|
||||
show: false,
|
||||
label: t('utils.Upload (Reference) times'),
|
||||
prop: 'quote',
|
||||
align: 'center',
|
||||
width: 150,
|
||||
operator: 'RANGE',
|
||||
sortable: 'custom',
|
||||
},
|
||||
{
|
||||
label: t('utils.Original name'),
|
||||
prop: 'name',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: '100',
|
||||
render: 'buttons',
|
||||
buttons: optBtn,
|
||||
operator: false,
|
||||
},
|
||||
],
|
||||
defaultOrder: { prop: 'last_upload_time', order: 'desc' },
|
||||
})
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
const getIndex = () => {
|
||||
if (props.type == 'image') {
|
||||
baTable.table.filter!.search = [{ field: 'mimetype', val: 'image', operator: 'LIKE' }]
|
||||
}
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.table.filter!.limit = 8
|
||||
baTable.getIndex()?.then(() => {
|
||||
baTable.initSort()
|
||||
})
|
||||
state.ready = true
|
||||
}
|
||||
|
||||
const onChoice = () => {
|
||||
if (baTable.table.selection?.length) {
|
||||
let files: string[] = []
|
||||
for (const key in baTable.table.selection) {
|
||||
files.push(props.returnFullUrl ? baTable.table.selection[key].full_url : baTable.table.selection[key].url)
|
||||
}
|
||||
emits('choice', files)
|
||||
const elTableRef = tableRef.value.getRef()
|
||||
elTableRef.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectionChange = (selection: TableRow[]) => {
|
||||
if (props.limit == 0) return
|
||||
if (selection.length > props.limit) {
|
||||
const elTableRef = tableRef.value.getRef()
|
||||
elTableRef.toggleRowSelection(selection[selection.length - 1], false)
|
||||
}
|
||||
state.tableSelectable = !(selection.length >= props.limit)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
baTable.mount()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal && !state.ready) {
|
||||
nextTick(() => {
|
||||
getIndex()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ba-upload-select-dialog .el-dialog__body {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.table-header-operate-text {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.ml-10 {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.selection-count {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
200
src/components/baInput/helper.ts
Normal file
200
src/components/baInput/helper.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { FieldData } from './index'
|
||||
|
||||
export const npuaFalse = () => {
|
||||
return {
|
||||
null: false,
|
||||
primaryKey: false,
|
||||
unsigned: false,
|
||||
autoIncrement: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有 Input 支持的类型对应的数据字段类型等数据(默认/示例设计)
|
||||
*/
|
||||
export const fieldData: FieldData = {
|
||||
string: {
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
password: {
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
number: {
|
||||
type: 'int',
|
||||
length: 10,
|
||||
precision: 0,
|
||||
default: '0',
|
||||
...npuaFalse(),
|
||||
},
|
||||
radio: {
|
||||
type: 'enum',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: '',
|
||||
...npuaFalse(),
|
||||
},
|
||||
checkbox: {
|
||||
type: 'set',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: '',
|
||||
...npuaFalse(),
|
||||
},
|
||||
switch: {
|
||||
type: 'tinyint',
|
||||
length: 1,
|
||||
precision: 0,
|
||||
default: '1',
|
||||
...npuaFalse(),
|
||||
unsigned: true,
|
||||
},
|
||||
textarea: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
array: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
datetime: {
|
||||
type: 'bigint',
|
||||
length: 16,
|
||||
precision: 0,
|
||||
default: 'null',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
unsigned: true,
|
||||
},
|
||||
year: {
|
||||
type: 'year',
|
||||
length: 4,
|
||||
precision: 0,
|
||||
default: 'null',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
date: {
|
||||
type: 'date',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: 'null',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
time: {
|
||||
type: 'time',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: 'null',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
select: {
|
||||
type: 'enum',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: '',
|
||||
...npuaFalse(),
|
||||
},
|
||||
selects: {
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
remoteSelect: {
|
||||
type: 'int',
|
||||
length: 10,
|
||||
precision: 0,
|
||||
default: '0',
|
||||
...npuaFalse(),
|
||||
unsigned: true,
|
||||
},
|
||||
remoteSelects: {
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
editor: {
|
||||
type: 'text',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
city: {
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
image: {
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
images: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
file: {
|
||||
type: 'varchar',
|
||||
length: 200,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
files: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
icon: {
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
color: {
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
precision: 0,
|
||||
default: 'empty string',
|
||||
...npuaFalse(),
|
||||
},
|
||||
}
|
||||
|
||||
export const stringToArray = (val: string | string[]) => {
|
||||
if (typeof val === 'string') {
|
||||
return val == '' ? [] : val.split(',')
|
||||
} else {
|
||||
return val as string[]
|
||||
}
|
||||
}
|
||||
204
src/components/baInput/index.ts
Normal file
204
src/components/baInput/index.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Component, CSSProperties } from 'vue'
|
||||
|
||||
/**
|
||||
* 支持的输入框类型
|
||||
* 若您正在设计数据表,可以找到 ./helper.ts 文件来参考对应类型的:数据字段设计示例
|
||||
*/
|
||||
export const inputTypes = [
|
||||
'string',
|
||||
'password',
|
||||
'number',
|
||||
'radio',
|
||||
'checkbox',
|
||||
'switch',
|
||||
'textarea',
|
||||
'array',
|
||||
'datetime',
|
||||
'year',
|
||||
'date',
|
||||
'time',
|
||||
'select',
|
||||
'selects',
|
||||
'remoteSelect',
|
||||
'remoteSelects',
|
||||
'editor',
|
||||
'city',
|
||||
'image',
|
||||
'images',
|
||||
'file',
|
||||
'files',
|
||||
'icon',
|
||||
'color',
|
||||
]
|
||||
export type modelValueTypes = string | number | boolean | object
|
||||
|
||||
export interface InputData {
|
||||
// 标题
|
||||
title?: string
|
||||
// 内容,比如radio的选项列表数据 content: { a: '选项1', b: '选项2' }
|
||||
content?: any
|
||||
// 提示信息
|
||||
tip?: string
|
||||
// 需要生成子级元素时,子级元素属性(比如radio)
|
||||
childrenAttr?: anyObj
|
||||
// 城市选择器等级,1=省,2=市,3=区
|
||||
level?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* input可用属性,用于代码提示,渲染不同输入组件时,需要的属性是不一样的
|
||||
* https://element-plus.org/zh-CN/component/input.html#input-属性
|
||||
*/
|
||||
export interface InputAttr {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
maxlength?: string | number
|
||||
minlength?: string | number
|
||||
showWordLimit?: boolean
|
||||
clearable?: boolean
|
||||
showPassword?: boolean
|
||||
disabled?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
prefixIcon?: string | Component
|
||||
suffixIcon?: string | Component
|
||||
rows?: number
|
||||
border?: boolean
|
||||
autosize?: boolean | anyObj
|
||||
autocomplete?: string
|
||||
readonly?: boolean
|
||||
max?: string | number
|
||||
min?: string | number
|
||||
step?: string | number
|
||||
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
|
||||
autofocus?: boolean
|
||||
form?: string
|
||||
label?: string
|
||||
tabindex?: string | number
|
||||
validateEvent?: boolean
|
||||
inputStyle?: anyObj
|
||||
// DateTimePicker属性
|
||||
editable?: boolean
|
||||
startPlaceholder?: string
|
||||
endPlaceholder?: string
|
||||
timeArrowControl?: boolean
|
||||
format?: string
|
||||
popperClass?: string
|
||||
rangeSeparator?: string
|
||||
defaultValue?: Date
|
||||
defaultTime?: Date | Date[]
|
||||
valueFormat?: string
|
||||
unlinkPanels?: boolean
|
||||
clearIcon?: string | Component
|
||||
shortcuts?: { text: string; value: Date | Function }[]
|
||||
disabledDate?: Function
|
||||
cellClassName?: Function
|
||||
teleported?: boolean
|
||||
// select属性
|
||||
multiple?: boolean
|
||||
valueKey?: string
|
||||
collapseTags?: string
|
||||
collapseTagsTooltip?: boolean
|
||||
multipleLimit?: number
|
||||
effect?: 'dark' | 'light'
|
||||
filterable?: boolean
|
||||
allowCreate?: boolean
|
||||
filterMethod?: Function
|
||||
remote?: false // 禁止使用远程搜索,如需使用请使用单独封装好的 remoteSelect 组件
|
||||
remoteMethod?: false
|
||||
labelFormatter?: (optionData: anyObj, optionKey: string) => string
|
||||
noMatchText?: string
|
||||
noDataText?: string
|
||||
reserveKeyword?: boolean
|
||||
defaultFirstOption?: boolean
|
||||
popperAppendToBody?: boolean
|
||||
persistent?: boolean
|
||||
automaticDropdown?: boolean
|
||||
fitInputWidth?: boolean
|
||||
tagType?: 'success' | 'info' | 'warning' | 'danger'
|
||||
params?: anyObj
|
||||
// 远程select属性
|
||||
pk?: string
|
||||
field?: string
|
||||
remoteUrl?: string
|
||||
tooltipParams?: anyObj
|
||||
// 图标选择器属性
|
||||
showIconName?: boolean
|
||||
placement?: string
|
||||
title?: string
|
||||
// 颜色选择器
|
||||
showAlpha?: boolean
|
||||
colorFormat?: string
|
||||
predefine?: string[]
|
||||
// 图片文件上传属性
|
||||
action?: string
|
||||
headers?: anyObj
|
||||
method?: string
|
||||
data?: anyObj
|
||||
withCredentials?: boolean
|
||||
showFileList?: boolean
|
||||
drag?: boolean
|
||||
accept?: string
|
||||
listType?: string
|
||||
autoUpload?: boolean
|
||||
limit?: number
|
||||
hideSelectFile?: boolean
|
||||
returnFullUrl?: boolean
|
||||
forceLocal?: boolean
|
||||
// editor属性
|
||||
height?: string
|
||||
mode?: string
|
||||
editorStyle?: CSSProperties
|
||||
style?: CSSProperties
|
||||
toolbarConfig?: anyObj
|
||||
editorConfig?: anyObj
|
||||
editorType?: string
|
||||
preview?: boolean
|
||||
language?: string
|
||||
theme?: 'light' | 'dark'
|
||||
toolbarsExclude?: string[]
|
||||
fileForceLocal?: boolean
|
||||
// array组件属性
|
||||
keyTitle?: string
|
||||
valueTitle?: string
|
||||
// 返回数据类型
|
||||
dataType?: string
|
||||
// 事件
|
||||
onPreview?: Function
|
||||
onRemove?: Function
|
||||
onSuccess?: Function
|
||||
onError?: Function
|
||||
onProgress?: Function
|
||||
onExceed?: Function
|
||||
onBeforeUpload?: Function
|
||||
onBeforeRemove?: Function
|
||||
onChange?: Function
|
||||
onInput?: Function
|
||||
onVisibleChange?: Function
|
||||
onRemoveTag?: Function
|
||||
onClear?: Function
|
||||
onBlur?: Function
|
||||
onFocus?: Function
|
||||
onCalendarChange?: Function
|
||||
onPanelChange?: Function
|
||||
onActiveChange?: Function
|
||||
onRow?: Function
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Input 支持的类型对应的数据字段设计数据
|
||||
*/
|
||||
export interface FieldData {
|
||||
[key: string]: {
|
||||
type: string // 数据类型
|
||||
length: number // 长度
|
||||
precision: number // 小数点
|
||||
default: string // 默认值
|
||||
null: boolean // 允许 null
|
||||
primaryKey: boolean // 主键
|
||||
unsigned: boolean // 无符号
|
||||
autoIncrement: boolean // 自动递增
|
||||
}
|
||||
}
|
||||
430
src/components/baInput/index.vue
Normal file
430
src/components/baInput/index.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import type { PropType, VNode } from 'vue'
|
||||
import type { modelValueTypes, InputAttr, InputData } from '@/components/baInput'
|
||||
import { createVNode, resolveComponent, defineComponent, computed, reactive } from 'vue'
|
||||
import { inputTypes } from '@/components/baInput'
|
||||
import Array from '@/components/baInput/components/array.vue'
|
||||
import RemoteSelect from '@/components/baInput/components/remoteSelect.vue'
|
||||
import IconSelector from '@/components/baInput/components/iconSelector.vue'
|
||||
import Editor from '@/components/baInput/components/editor.vue'
|
||||
import BaUpload from '@/components/baInput/components/baUpload.vue'
|
||||
import { getArea } from '@/api/common'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'baInput',
|
||||
props: {
|
||||
// 输入框类型,支持的输入框见 inputTypes
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value: string) => {
|
||||
return inputTypes.includes(value)
|
||||
},
|
||||
},
|
||||
// 双向绑定值
|
||||
modelValue: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
// 输入框的附加属性
|
||||
attr: {
|
||||
type: Object as PropType<InputAttr>,
|
||||
default: () => {},
|
||||
},
|
||||
// 额外数据,radio、checkbox的选项等数据
|
||||
data: {
|
||||
type: Object as PropType<InputData>,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const onValueUpdate = (value: modelValueTypes) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 子级元素属性
|
||||
let childrenAttr = props.data && props.data.childrenAttr ? props.data.childrenAttr : {}
|
||||
|
||||
// string number textarea password
|
||||
const sntp = () => {
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-input'), {
|
||||
type: props.type == 'string' ? 'text' : props.type,
|
||||
...props.attr,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
})
|
||||
}
|
||||
// radio checkbox
|
||||
const rc = () => {
|
||||
if (!props.data || !props.data.content) {
|
||||
console.warn('请传递 ' + props.type + '的 content')
|
||||
}
|
||||
let vNode: VNode[] = []
|
||||
for (const key in props.data.content) {
|
||||
vNode.push(
|
||||
createVNode(
|
||||
resolveComponent('el-' + props.type),
|
||||
{
|
||||
label: key,
|
||||
...childrenAttr,
|
||||
},
|
||||
() => props.data.content[key]
|
||||
)
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
const valueComputed = computed(() => {
|
||||
if (props.type == 'radio') {
|
||||
if (props.modelValue == undefined) return ''
|
||||
return '' + props.modelValue
|
||||
} else {
|
||||
let modelValueArr: anyObj = []
|
||||
for (const key in props.modelValue) {
|
||||
modelValueArr[key] = '' + props.modelValue[key]
|
||||
}
|
||||
return modelValueArr
|
||||
}
|
||||
})
|
||||
return createVNode(
|
||||
resolveComponent('el-' + props.type + '-group'),
|
||||
{
|
||||
...props.attr,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
() => vNode
|
||||
)
|
||||
}
|
||||
}
|
||||
// select selects
|
||||
const select = () => {
|
||||
let vNode: VNode[] = []
|
||||
if (!props.data || !props.data.content) {
|
||||
console.warn('请传递 ' + props.type + '的 content')
|
||||
}
|
||||
for (const key in props.data.content) {
|
||||
vNode.push(
|
||||
createVNode(resolveComponent('el-option'), {
|
||||
key: key,
|
||||
label: props.data.content[key],
|
||||
value: key,
|
||||
...childrenAttr,
|
||||
})
|
||||
)
|
||||
}
|
||||
return () => {
|
||||
const valueComputed = computed(() => {
|
||||
if (props.type == 'select') {
|
||||
if (props.modelValue == undefined) return ''
|
||||
return '' + props.modelValue
|
||||
} else {
|
||||
let modelValueArr: anyObj = []
|
||||
for (const key in props.modelValue) {
|
||||
modelValueArr[key] = '' + props.modelValue[key]
|
||||
}
|
||||
return modelValueArr
|
||||
}
|
||||
})
|
||||
return createVNode(
|
||||
resolveComponent('el-select'),
|
||||
{
|
||||
class: 'w100',
|
||||
multiple: props.type == 'select' ? false : true,
|
||||
clearable: true,
|
||||
...props.attr,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
() => vNode
|
||||
)
|
||||
}
|
||||
}
|
||||
// datetime
|
||||
const datetime = () => {
|
||||
let valueFormat = 'YYYY-MM-DD HH:mm:ss'
|
||||
switch (props.type) {
|
||||
case 'date':
|
||||
valueFormat = 'YYYY-MM-DD'
|
||||
break
|
||||
case 'year':
|
||||
valueFormat = 'YYYY'
|
||||
break
|
||||
}
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-date-picker'), {
|
||||
class: 'w100',
|
||||
type: props.type,
|
||||
'value-format': valueFormat,
|
||||
...props.attr,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
})
|
||||
}
|
||||
// upload
|
||||
const upload = () => {
|
||||
return () =>
|
||||
createVNode(BaUpload, {
|
||||
type: props.type,
|
||||
data: props.attr ? props.attr.data : {},
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
returnFullUrl: props.attr ? props.attr.returnFullUrl || props.attr['return-full-url'] : false,
|
||||
hideSelectFile: props.attr ? props.attr.hideSelectFile || props.attr['hide-select-file'] : false,
|
||||
attr: props.attr,
|
||||
forceLocal: props.attr ? props.attr.forceLocal || props.attr['force-local'] : false,
|
||||
})
|
||||
}
|
||||
|
||||
// remoteSelect remoteSelects
|
||||
const remoteSelect = () => {
|
||||
return () =>
|
||||
createVNode(RemoteSelect, {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
multiple: props.type == 'remoteSelect' ? false : true,
|
||||
...props.attr,
|
||||
})
|
||||
}
|
||||
|
||||
const buildFun = new Map([
|
||||
['string', sntp],
|
||||
['number', sntp],
|
||||
['textarea', sntp],
|
||||
['password', sntp],
|
||||
['radio', rc],
|
||||
['checkbox', rc],
|
||||
[
|
||||
'switch',
|
||||
() => {
|
||||
const valueType = computed(() => typeof props.modelValue)
|
||||
const valueComputed = computed(() => {
|
||||
if (valueType.value === 'boolean') {
|
||||
return props.modelValue
|
||||
} else {
|
||||
let valueTmp = parseInt(props.modelValue as string)
|
||||
return isNaN(valueTmp) || valueTmp <= 0 ? false : true
|
||||
}
|
||||
})
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-switch'), {
|
||||
...props.attr,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': (value: boolean) => {
|
||||
let newValue: boolean | string | number = value
|
||||
switch (valueType.value) {
|
||||
case 'string':
|
||||
newValue = value ? '1' : '0'
|
||||
break
|
||||
case 'number':
|
||||
newValue = value ? 1 : 0
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
},
|
||||
})
|
||||
},
|
||||
],
|
||||
['datetime', datetime],
|
||||
[
|
||||
'year',
|
||||
() => {
|
||||
return () => {
|
||||
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue))
|
||||
return createVNode(resolveComponent('el-date-picker'), {
|
||||
class: 'w100',
|
||||
type: props.type,
|
||||
'value-format': 'YYYY',
|
||||
...props.attr,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
})
|
||||
}
|
||||
},
|
||||
],
|
||||
['date', datetime],
|
||||
[
|
||||
'time',
|
||||
() => {
|
||||
const valueComputed = computed(() => {
|
||||
if (props.modelValue instanceof Date) {
|
||||
return props.modelValue
|
||||
} else if (!props.modelValue) {
|
||||
return ''
|
||||
} else {
|
||||
let date = new Date()
|
||||
return new Date(date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + ' ' + props.modelValue)
|
||||
}
|
||||
})
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-time-picker'), {
|
||||
class: 'w100',
|
||||
clearable: true,
|
||||
format: 'HH:mm:ss',
|
||||
...props.attr,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
})
|
||||
},
|
||||
],
|
||||
['select', select],
|
||||
['selects', select],
|
||||
[
|
||||
'array',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(Array, {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...props.attr,
|
||||
})
|
||||
},
|
||||
],
|
||||
['remoteSelect', remoteSelect],
|
||||
['remoteSelects', remoteSelect],
|
||||
[
|
||||
'city',
|
||||
() => {
|
||||
type Node = { value?: number; label?: string; leaf?: boolean }
|
||||
let maxLevel = props.data && props.data.level ? props.data.level - 1 : 2
|
||||
const lastLazyValue: {
|
||||
value: string | number[] | unknown
|
||||
nodes: Node[]
|
||||
key: string
|
||||
currentRequest: any
|
||||
} = reactive({
|
||||
value: 'ready',
|
||||
nodes: [],
|
||||
key: '',
|
||||
currentRequest: null,
|
||||
})
|
||||
|
||||
// 请求到的node备份-s
|
||||
let nodeEbak: anyObj = {}
|
||||
const getNodes = (level: number, key: string) => {
|
||||
if (nodeEbak[level] && nodeEbak[level][key]) {
|
||||
return nodeEbak[level][key]
|
||||
}
|
||||
return false
|
||||
}
|
||||
const setNodes = (level: number, key: string, nodes: Node[] = []) => {
|
||||
if (!nodeEbak[level]) {
|
||||
nodeEbak[level] = {}
|
||||
}
|
||||
nodeEbak[level][key] = nodes
|
||||
}
|
||||
// 请求到的node备份-e
|
||||
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-cascader'), {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
class: 'w100',
|
||||
clearable: true,
|
||||
props: {
|
||||
lazy: true,
|
||||
lazyLoad(node: any, resolve: any) {
|
||||
// lazyLoad会频繁触发,在本地存储请求结果,供重复触发时直接读取
|
||||
const { level, pathValues } = node
|
||||
let key = pathValues.join(',')
|
||||
key = key ? key : 'init'
|
||||
|
||||
let locaNode = getNodes(level, key)
|
||||
if (locaNode) {
|
||||
return resolve(locaNode)
|
||||
}
|
||||
|
||||
if (lastLazyValue.key == key && lastLazyValue.value == props.modelValue) {
|
||||
if (lastLazyValue.currentRequest) {
|
||||
return lastLazyValue.currentRequest
|
||||
}
|
||||
return resolve(lastLazyValue.nodes)
|
||||
}
|
||||
|
||||
let nodes: Node[] = []
|
||||
lastLazyValue.key = key
|
||||
lastLazyValue.value = props.modelValue
|
||||
lastLazyValue.currentRequest = getArea(pathValues).then((res) => {
|
||||
let toStr = false
|
||||
if (props.modelValue && typeof (props.modelValue as anyObj)[0] === 'string') {
|
||||
toStr = true
|
||||
}
|
||||
for (const key in res.data) {
|
||||
if (toStr) {
|
||||
res.data[key].value = res.data[key].value.toString()
|
||||
}
|
||||
res.data[key].leaf = level >= maxLevel
|
||||
nodes.push(res.data[key])
|
||||
}
|
||||
lastLazyValue.nodes = nodes
|
||||
lastLazyValue.currentRequest = null
|
||||
setNodes(level, key, nodes)
|
||||
resolve(nodes)
|
||||
})
|
||||
},
|
||||
},
|
||||
...props.attr,
|
||||
})
|
||||
},
|
||||
],
|
||||
['image', upload],
|
||||
['images', upload],
|
||||
['file', upload],
|
||||
['files', upload],
|
||||
[
|
||||
'icon',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(IconSelector, {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...props.attr,
|
||||
})
|
||||
},
|
||||
],
|
||||
[
|
||||
'color',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(resolveComponent('el-color-picker'), {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...props.attr,
|
||||
})
|
||||
},
|
||||
],
|
||||
[
|
||||
'editor',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(Editor, {
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...props.attr,
|
||||
})
|
||||
},
|
||||
],
|
||||
[
|
||||
'default',
|
||||
() => {
|
||||
console.warn('暂不支持' + props.type + '的输入框类型,你可以自行在 BaInput 组件内添加逻辑')
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
let action = buildFun.get(props.type) || buildFun.get('default')
|
||||
return action!.call(this)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-upload-image :deep(.el-upload--picture-card) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ba-upload-file :deep(.el-upload-list) {
|
||||
margin-left: -10px;
|
||||
}
|
||||
</style>
|
||||
108
src/components/contextmenu/index.vue
Normal file
108
src/components/contextmenu/index.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<transition name="el-zoom-in-center">
|
||||
<div
|
||||
class="el-popper is-pure is-light el-dropdown__popper ba-contextmenu"
|
||||
:style="`top: ${state.axis.y + 5}px;left: ${state.axis.x - 14}px;width:${props.width}px`"
|
||||
:key="Math.random()"
|
||||
v-show="state.show"
|
||||
aria-hidden="false"
|
||||
data-popper-placement="bottom"
|
||||
>
|
||||
<ul class="el-dropdown-menu">
|
||||
<template v-for="(item, idx) in props.items" :key="idx">
|
||||
<li class="el-dropdown-menu__item" :class="item.disabled ? 'is-disabled' : ''" tabindex="-1" @click="onContextmenuItem(item)">
|
||||
<Icon size="12" :name="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<span class="el-popper__arrow" :style="{ left: `${state.arrowAxis}px` }"></span>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, toRaw } from 'vue'
|
||||
import type { Axis, ContextmenuItemClickEmitArg, Props } from './interface'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 150,
|
||||
items: () => [],
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'contextmenuItemClick', item: ContextmenuItemClickEmitArg): void
|
||||
}>()
|
||||
|
||||
const state: {
|
||||
show: boolean
|
||||
axis: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
menu: RouteLocationNormalized | undefined
|
||||
arrowAxis: number
|
||||
} = reactive({
|
||||
show: false,
|
||||
axis: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
menu: undefined,
|
||||
arrowAxis: 10,
|
||||
})
|
||||
|
||||
const onShowContextmenu = (menu: RouteLocationNormalized, axis: Axis) => {
|
||||
state.menu = menu
|
||||
state.axis = axis
|
||||
state.show = true
|
||||
}
|
||||
|
||||
const onContextmenuItem = (item: ContextmenuItemClickEmitArg) => {
|
||||
if (item.disabled) return
|
||||
item.menu = toRaw(state.menu)
|
||||
emits('contextmenuItemClick', item)
|
||||
}
|
||||
|
||||
const onHideContextmenu = () => {
|
||||
state.show = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onShowContextmenu,
|
||||
onHideContextmenu,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
useEventListener(document, 'click', onHideContextmenu)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-contextmenu {
|
||||
z-index: 9999;
|
||||
}
|
||||
.el-popper,
|
||||
.el-popper.is-light .el-popper__arrow::before {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
}
|
||||
.el-dropdown-menu__item {
|
||||
padding: 8px 20px;
|
||||
user-select: none;
|
||||
}
|
||||
.el-dropdown-menu__item .icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.el-dropdown-menu__item:not(.is-disabled) {
|
||||
&:hover {
|
||||
background-color: var(--el-dropdown-menuItem-hover-fill);
|
||||
color: var(--el-dropdown-menuItem-hover-color);
|
||||
.fa {
|
||||
color: var(--el-dropdown-menuItem-hover-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/components/contextmenu/interface.ts
Normal file
22
src/components/contextmenu/interface.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface Axis {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
name: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ContextmenuItemClickEmitArg extends ContextMenuItem {
|
||||
menu?: RouteLocationNormalized
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
width?: number
|
||||
items: ContextMenuItem[]
|
||||
}
|
||||
43
src/components/icon/index.vue
Normal file
43
src/components/icon/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { createVNode, resolveComponent, defineComponent, computed, type CSSProperties } from 'vue'
|
||||
import svg from '@/components/icon/svg/index.vue'
|
||||
import { isExternal } from '@/utils/common'
|
||||
export default defineComponent({
|
||||
name: 'Icon',
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '18px'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#000000'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const iconStyle = computed((): CSSProperties => {
|
||||
const { size, color } = props
|
||||
let s = `${size.replace('px', '')}px`
|
||||
return {
|
||||
fontSize: s,
|
||||
color: color
|
||||
}
|
||||
})
|
||||
|
||||
if (props.name.indexOf('el-icon-') === 0) {
|
||||
return () =>
|
||||
createVNode('el-icon', { class: 'icon el-icon', style: iconStyle.value }, [
|
||||
createVNode(resolveComponent(props.name))
|
||||
])
|
||||
} else if (props.name.indexOf('local-') === 0 || isExternal(props.name)) {
|
||||
return () => createVNode(svg, { name: props.name, size: props.size, color: props.color })
|
||||
} else {
|
||||
return () => createVNode('i', { class: [props.name, 'icon'], style: iconStyle.value })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
71
src/components/icon/svg/index.ts
Normal file
71
src/components/icon/svg/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { readFileSync, readdirSync } from 'fs'
|
||||
|
||||
let idPerfix = ''
|
||||
const iconNames: string[] = []
|
||||
const svgTitle = /<svg([^>+].*?)>/
|
||||
const clearHeightWidth = /(width|height)="([^>+].*?)"/g
|
||||
const hasViewBox = /(viewBox="[^>+].*?")/g
|
||||
const clearReturn = /(\r)|(\n)/g
|
||||
// 清理 svg 的 fill
|
||||
const clearFill = /(fill="[^>+].*?")/g
|
||||
|
||||
function findSvgFile(dir: string): string[] {
|
||||
const svgRes = []
|
||||
const dirents = readdirSync(dir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
for (const dirent of dirents) {
|
||||
iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`)
|
||||
if (dirent.isDirectory()) {
|
||||
svgRes.push(...findSvgFile(dir + dirent.name + '/'))
|
||||
} else {
|
||||
const svg = readFileSync(dir + dirent.name)
|
||||
.toString()
|
||||
.replace(clearReturn, '')
|
||||
.replace(clearFill, 'fill=""')
|
||||
.replace(svgTitle, ($1, $2) => {
|
||||
let width = 0
|
||||
let height = 0
|
||||
let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {
|
||||
if (s2 === 'width') {
|
||||
width = s3
|
||||
} else if (s2 === 'height') {
|
||||
height = s3
|
||||
}
|
||||
return ''
|
||||
})
|
||||
if (!hasViewBox.test($2)) {
|
||||
content += `viewBox="0 0 ${width} ${height}"`
|
||||
}
|
||||
return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`
|
||||
})
|
||||
.replace('</svg>', '</symbol>')
|
||||
svgRes.push(svg)
|
||||
}
|
||||
}
|
||||
return svgRes
|
||||
}
|
||||
|
||||
export const svgBuilder = (path: string, perfix = 'local') => {
|
||||
if (path === '') return
|
||||
idPerfix = perfix
|
||||
const res = findSvgFile(path)
|
||||
return {
|
||||
name: 'svg-transform',
|
||||
transformIndexHtml(html: string) {
|
||||
/* eslint-disable */
|
||||
return html.replace(
|
||||
'<body>',
|
||||
`
|
||||
<body>
|
||||
<svg id="local-icon" data-icon-name="${iconNames.join(
|
||||
','
|
||||
)}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
|
||||
${res.join('')}
|
||||
</svg>
|
||||
`
|
||||
)
|
||||
/* eslint-enable */
|
||||
},
|
||||
}
|
||||
}
|
||||
49
src/components/icon/svg/index.vue
Normal file
49
src/components/icon/svg/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div v-if="isUrl" :style="urlIconStyle" class="url-svg svg-icon icon" />
|
||||
<svg v-else class="svg-icon icon" :style="iconStyle">
|
||||
<use :href="iconName" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties } from 'vue'
|
||||
import { isExternal } from '@/utils/common'
|
||||
interface Props {
|
||||
name: string
|
||||
size: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: '',
|
||||
size: '18px',
|
||||
color: '#000000',
|
||||
})
|
||||
|
||||
const s = `${props.size.replace('px', '')}px`
|
||||
const iconName = computed(() => `#${props.name}`)
|
||||
const iconStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
color: props.color,
|
||||
fontSize: s,
|
||||
}
|
||||
})
|
||||
const isUrl = computed(() => isExternal(props.name))
|
||||
const urlIconStyle = computed(() => {
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
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;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
53
src/layouts/admin/components/aside.vue
Normal file
53
src/layouts/admin/components/aside.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<el-aside v-if="!navTabs.state.tabFullScreen" :class="'layout-aside-' + config.layout.layoutMode + ' ' + (config.layout.shrink ? 'shrink' : '')">
|
||||
<Logo v-if="config.layout.menuShowTopBar" />
|
||||
<MenuVerticalChildren v-if="config.layout.layoutMode == 'Double'" />
|
||||
<MenuVertical v-else />
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Logo from '@/layouts/admin/components/logo.vue'
|
||||
import MenuVertical from '@/layouts/admin/components/menus/menuVertical.vue'
|
||||
import MenuVerticalChildren from '@/layouts/admin/components/menus/menuVerticalChildren.vue'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
|
||||
defineOptions({
|
||||
name: 'layout/aside',
|
||||
})
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
|
||||
const menuWidth = computed(() => config.menuWidth())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-aside-Default {
|
||||
background: var(--ba-bg-color-overlay);
|
||||
margin: 16px 0 16px 16px;
|
||||
height: calc(100vh - 32px);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease;
|
||||
width: v-bind(menuWidth);
|
||||
}
|
||||
.layout-aside-Classic,
|
||||
.layout-aside-Double {
|
||||
background: var(--ba-bg-color-overlay);
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease;
|
||||
width: v-bind(menuWidth);
|
||||
}
|
||||
.shrink {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999999;
|
||||
}
|
||||
</style>
|
||||
71
src/layouts/admin/components/closeFullScreen.vue
Normal file
71
src/layouts/admin/components/closeFullScreen.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div title="$'layouts.Exit full screen'" @mouseover.stop="onMouseover" @mouseout.stop="onMouseout">
|
||||
<div @click.stop="onCloseFullScreen" class="close-full-screen" :style="{ top: state.closeBoxTop + 'px' }">
|
||||
<Icon name="el-icon-Close" />
|
||||
</div>
|
||||
<div class="close-full-screen-on"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
|
||||
const navTabs = useNavTabs()
|
||||
|
||||
const state = reactive({
|
||||
closeBoxTop: 20,
|
||||
})
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
state.closeBoxTop = -30
|
||||
}, 300)
|
||||
})
|
||||
/*
|
||||
* 鼠标滑到顶部显示关闭全屏按钮
|
||||
* 要检查 hover 的元素在外部,直接使用事件而不是css
|
||||
*/
|
||||
const onMouseover = () => {
|
||||
state.closeBoxTop = 20
|
||||
}
|
||||
const onMouseout = () => {
|
||||
state.closeBoxTop = -30
|
||||
}
|
||||
const onCloseFullScreen = () => {
|
||||
navTabs.setFullScreen(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.close-full-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
right: calc(50% - 20px);
|
||||
z-index: 9999999;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
background-color: rgba($color: #000000, $alpha: 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: all 0.3s ease;
|
||||
.icon {
|
||||
color: rgba($color: #000000, $alpha: 0.6) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: #000000, $alpha: 0.3);
|
||||
.icon {
|
||||
color: rgba($color: #ffffff, $alpha: 0.6) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.close-full-screen-on {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 9999998;
|
||||
height: 60px;
|
||||
width: 100px;
|
||||
left: calc(50% - 50px);
|
||||
}
|
||||
</style>
|
||||
417
src/layouts/admin/components/config.vue
Normal file
417
src/layouts/admin/components/config.vue
Normal file
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="layout-config-drawer">
|
||||
<el-drawer
|
||||
:model-value="configStore.layout.showDrawer"
|
||||
title="布局配置"
|
||||
size="310px"
|
||||
@close="onCloseDrawer"
|
||||
>
|
||||
<el-scrollbar class="layout-mode-style-scrollbar">
|
||||
<el-form ref="formRef" :model="configStore.layout">
|
||||
<div class="layout-mode-styles-box">
|
||||
<el-divider border-style="dashed">全局</el-divider>
|
||||
<div class="layout-mode-box-style">
|
||||
<el-row class="layout-mode-box-style-row" :gutter="10">
|
||||
<el-col :span="12">
|
||||
<div
|
||||
@click="setLayoutMode('Default')"
|
||||
class="layout-mode-style default"
|
||||
:class="configStore.layout.layoutMode == 'Default' ? 'active' : ''"
|
||||
>
|
||||
<div class="layout-mode-style-box">
|
||||
<div class="layout-mode-style-aside"></div>
|
||||
<div class="layout-mode-style-container-box">
|
||||
<div class="layout-mode-style-header"></div>
|
||||
<div class="layout-mode-style-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-mode-style-name">默认</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div
|
||||
@click="setLayoutMode('Classic')"
|
||||
class="layout-mode-style classic"
|
||||
:class="configStore.layout.layoutMode == 'Classic' ? 'active' : ''"
|
||||
>
|
||||
<div class="layout-mode-style-box">
|
||||
<div class="layout-mode-style-aside"></div>
|
||||
<div class="layout-mode-style-container-box">
|
||||
<div class="layout-mode-style-header"></div>
|
||||
<div class="layout-mode-style-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-mode-style-name">经典</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="12">
|
||||
<div
|
||||
@click="setLayoutMode('Streamline')"
|
||||
class="layout-mode-style streamline"
|
||||
:class="configStore.layout.layoutMode == 'Streamline' ? 'active' : ''"
|
||||
>
|
||||
<div class="layout-mode-style-box">
|
||||
<div class="layout-mode-style-container-box">
|
||||
<div class="layout-mode-style-header"></div>
|
||||
<div class="layout-mode-style-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-mode-style-name">单栏</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div
|
||||
@click="setLayoutMode('Double')"
|
||||
class="layout-mode-style double"
|
||||
:class="configStore.layout.layoutMode == 'Double' ? 'active' : ''"
|
||||
>
|
||||
<div class="layout-mode-style-box">
|
||||
<div class="layout-mode-style-aside"></div>
|
||||
<div class="layout-mode-style-container-box">
|
||||
<div class="layout-mode-style-header"></div>
|
||||
<div class="layout-mode-style-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-mode-style-name">双栏</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-divider border-style="dashed">全局</el-divider>
|
||||
<div class="layout-config-global">
|
||||
<el-form-item label="'后台页面切换动画">
|
||||
<el-select
|
||||
@change="onCommitState($event, 'mainAnimation')"
|
||||
:model-value="configStore.layout.mainAnimation"
|
||||
:placeholder="'layouts.Please select an animation name'"
|
||||
>
|
||||
<el-option label="slide-right" value="slide-right"></el-option>
|
||||
<el-option label="slide-left" value="slide-left"></el-option>
|
||||
<el-option label="el-fade-in-linear" value="el-fade-in-linear"></el-option>
|
||||
<el-option label="el-fade-in" value="el-fade-in"></el-option>
|
||||
<el-option label="el-zoom-in-center" value="el-zoom-in-center"></el-option>
|
||||
<el-option label="el-zoom-in-top" value="el-zoom-in-top"></el-option>
|
||||
<el-option label="el-zoom-in-bottom" value="el-zoom-in-bottom"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-divider border-style="dashed">侧边栏</el-divider>
|
||||
<div class="layout-config-aside">
|
||||
<el-form-item label="侧边菜单栏背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'menuBackground')"
|
||||
:model-value="configStore.getColorVal('menuBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单文字颜色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'menuColor')"
|
||||
:model-value="configStore.getColorVal('menuColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单激活项背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'menuActiveBackground')"
|
||||
:model-value="configStore.getColorVal('menuActiveBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单激活项文字色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'menuActiveColor')"
|
||||
:model-value="configStore.getColorVal('menuActiveColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示侧边菜单顶栏(LOGO栏)">
|
||||
<el-switch
|
||||
@change="onCommitState($event, 'menuShowTopBar')"
|
||||
:model-value="configStore.layout.menuShowTopBar"
|
||||
></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单顶栏背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'menuTopBarBackground')"
|
||||
:model-value="configStore.getColorVal('menuTopBarBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单宽度(展开时)">
|
||||
<el-input
|
||||
@input="onCommitState($event, 'menuWidth')"
|
||||
type="number"
|
||||
:step="10"
|
||||
:model-value="configStore.layout.menuWidth"
|
||||
>
|
||||
<template #append>px</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单默认图标">
|
||||
<IconSelector
|
||||
@change="onCommitMenuDefaultIcon($event, 'menuDefaultIcon')"
|
||||
:model-value="configStore.layout.menuDefaultIcon"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单水平折叠">
|
||||
<el-switch
|
||||
@change="onCommitState($event, 'menuCollapse')"
|
||||
:model-value="configStore.layout.menuCollapse"
|
||||
></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item label="侧边菜单手风琴">
|
||||
<el-switch
|
||||
@change="onCommitState($event, 'menuUniqueOpened')"
|
||||
:model-value="configStore.layout.menuUniqueOpened"
|
||||
></el-switch>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-divider border-style="dashed">顶栏</el-divider>
|
||||
<div class="layout-config-aside">
|
||||
<el-form-item label="顶栏背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'headerBarBackground')"
|
||||
:model-value="configStore.getColorVal('headerBarBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="顶栏文字色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'headerBarTabColor')"
|
||||
:model-value="configStore.getColorVal('headerBarTabColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="顶栏悬停时背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'headerBarHoverBackground')"
|
||||
:model-value="configStore.getColorVal('headerBarHoverBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="顶栏菜单激活项背景色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'headerBarTabActiveBackground')"
|
||||
:model-value="configStore.getColorVal('headerBarTabActiveBackground')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="顶栏菜单激活项文字色">
|
||||
<el-color-picker
|
||||
@change="onCommitColorState($event, 'headerBarTabActiveColor')"
|
||||
:model-value="configStore.getColorVal('headerBarTabActiveColor')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-popconfirm
|
||||
@confirm="restoreDefault"
|
||||
:title="
|
||||
'layouts.Are you sure you want to restore all configurations to the default values?'
|
||||
"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="ba-center">
|
||||
<el-button class="w80" type="info">恢复默认</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-scrollbar>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { useRouter } from 'vue-router'
|
||||
import IconSelector from '@/components/baInput/components/iconSelector.vue'
|
||||
import { STORE_CONFIG } from '@/stores/constant/cacheKey'
|
||||
import { Local, Session } from '@/utils/storage'
|
||||
import type { Layout } from '@/stores/interface'
|
||||
|
||||
const configStore = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const router = useRouter()
|
||||
|
||||
const onCommitState = (value: any, name: any) => {
|
||||
configStore.setLayout(name, value)
|
||||
}
|
||||
|
||||
const onCommitColorState = (value: string | null, name: keyof Layout) => {
|
||||
if (value === null) return
|
||||
const colors = configStore.layout[name] as string[]
|
||||
if (configStore.layout.isDark) {
|
||||
colors[1] = value
|
||||
} else {
|
||||
colors[0] = value
|
||||
}
|
||||
configStore.setLayout(name, colors)
|
||||
}
|
||||
|
||||
const setLayoutMode = (mode: string) => {
|
||||
configStore.setLayoutMode(mode)
|
||||
}
|
||||
|
||||
// 修改默认菜单图标
|
||||
const onCommitMenuDefaultIcon = (value: any, name: any) => {
|
||||
configStore.setLayout(name, value)
|
||||
|
||||
const menus = navTabs.state.tabsViewRoutes
|
||||
navTabs.setTabsViewRoutes([])
|
||||
setTimeout(() => {
|
||||
navTabs.setTabsViewRoutes(menus)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onCloseDrawer = () => {
|
||||
configStore.setLayout('showDrawer', false)
|
||||
}
|
||||
|
||||
const restoreDefault = () => {
|
||||
Local.remove(STORE_CONFIG)
|
||||
router.go(0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-config-drawer :deep(.el-input__inner) {
|
||||
padding: 0 0 0 6px;
|
||||
}
|
||||
.layout-config-drawer :deep(.el-input-group__append) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.layout-config-drawer :deep(.el-drawer__header) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.layout-config-drawer :deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
.layout-mode-styles-box {
|
||||
padding: 20px;
|
||||
}
|
||||
.layout-mode-box-style-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.layout-mode-style {
|
||||
position: relative;
|
||||
height: 100px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-small);
|
||||
&:hover,
|
||||
&.active {
|
||||
border: 1px solid var(--el-color-primary);
|
||||
}
|
||||
.layout-mode-style-name {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-color-primary-light-5);
|
||||
border-radius: 50%;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
border: 1px solid var(--el-color-primary-light-3);
|
||||
}
|
||||
.layout-mode-style-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
&.default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.layout-mode-style-aside {
|
||||
width: 18%;
|
||||
height: 90%;
|
||||
background-color: var(--el-border-color-lighter);
|
||||
}
|
||||
.layout-mode-style-container-box {
|
||||
width: 68%;
|
||||
height: 90%;
|
||||
margin-left: 4%;
|
||||
.layout-mode-style-header {
|
||||
width: 100%;
|
||||
height: 10%;
|
||||
background-color: var(--el-border-color-lighter);
|
||||
}
|
||||
.layout-mode-style-container {
|
||||
width: 100%;
|
||||
height: 85%;
|
||||
background-color: var(--el-border-color-extra-light);
|
||||
margin-top: 5%;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.classic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.layout-mode-style-aside {
|
||||
width: 18%;
|
||||
height: 100%;
|
||||
background-color: var(--el-border-color-lighter);
|
||||
}
|
||||
.layout-mode-style-container-box {
|
||||
width: 82%;
|
||||
height: 100%;
|
||||
.layout-mode-style-header {
|
||||
width: 100%;
|
||||
height: 10%;
|
||||
background-color: var(--el-border-color);
|
||||
}
|
||||
.layout-mode-style-container {
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
background-color: var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.streamline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.layout-mode-style-container-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.layout-mode-style-header {
|
||||
width: 100%;
|
||||
height: 10%;
|
||||
background-color: var(--el-border-color);
|
||||
}
|
||||
.layout-mode-style-container {
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
background-color: var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.double {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.layout-mode-style-aside {
|
||||
width: 18%;
|
||||
height: 100%;
|
||||
background-color: var(--el-border-color);
|
||||
}
|
||||
.layout-mode-style-container-box {
|
||||
width: 82%;
|
||||
height: 100%;
|
||||
.layout-mode-style-header {
|
||||
width: 100%;
|
||||
height: 10%;
|
||||
background-color: var(--el-border-color);
|
||||
}
|
||||
.layout-mode-style-container {
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
background-color: var(--el-border-color-extra-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.w80 {
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
28
src/layouts/admin/components/header.vue
Normal file
28
src/layouts/admin/components/header.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<el-header v-if="!navTabs.state.tabFullScreen" class="layout-header">
|
||||
<component :is="config.layout.layoutMode + 'NavBar'"></component>
|
||||
</el-header>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import DefaultNavBar from '@/layouts/admin/components/navBar/default.vue'
|
||||
import ClassicNavBar from '@/layouts/admin/components/navBar/classic.vue'
|
||||
import StreamlineNavBar from '@/layouts/admin/components/menus/menuHorizontal.vue'
|
||||
import DoubleNavBar from '@/layouts/admin/components/navBar/double.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'layout/header',
|
||||
components: { DefaultNavBar, ClassicNavBar, StreamlineNavBar, DoubleNavBar },
|
||||
})
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-header {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
77
src/layouts/admin/components/logo.vue
Normal file
77
src/layouts/admin/components/logo.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="layout-logo">
|
||||
<img v-if="!config.layout.menuCollapse" class="logo-img" src="@/assets/vue.svg" alt="logo" />
|
||||
<div
|
||||
v-if="!config.layout.menuCollapse"
|
||||
:style="{ color: config.getColorVal('menuActiveColor') }"
|
||||
class="website-name"
|
||||
>
|
||||
灿能
|
||||
</div>
|
||||
<Icon
|
||||
v-if="config.layout.layoutMode != 'Streamline'"
|
||||
@click="onMenuCollapse"
|
||||
:name="config.layout.menuCollapse ? 'fa fa-indent' : 'fa fa-dedent'"
|
||||
:class="config.layout.menuCollapse ? 'unfold' : ''"
|
||||
:color="config.getColorVal('menuActiveColor')"
|
||||
size="18"
|
||||
class="fold"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { closeShade } from '@/utils/pageShade'
|
||||
import { Session } from '@/utils/storage'
|
||||
import { setNavTabsWidth } from '@/utils/layout'
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const onMenuCollapse = function () {
|
||||
if (config.layout.shrink && !config.layout.menuCollapse) {
|
||||
closeShade()
|
||||
}
|
||||
|
||||
config.setLayout('menuCollapse', !config.layout.menuCollapse)
|
||||
|
||||
// 等待侧边栏动画结束后重新计算导航栏宽度
|
||||
setTimeout(() => {
|
||||
setNavTabsWidth()
|
||||
}, 350)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-logo {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
background: v-bind(
|
||||
'config.layout.layoutMode != "Streamline" ? config.getColorVal("menuTopBarBackground"):"transparent"'
|
||||
);
|
||||
}
|
||||
.logo-img {
|
||||
width: 28px;
|
||||
}
|
||||
.website-name {
|
||||
display: block;
|
||||
width: 180px;
|
||||
padding-left: 4px;
|
||||
font-size: var(--el-font-size-extra-large);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fold {
|
||||
margin-left: auto;
|
||||
}
|
||||
.unfold {
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
18
src/layouts/admin/components/menus/helper.ts
Normal file
18
src/layouts/admin/components/menus/helper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 寻找当前路由在顶栏菜单中的数据
|
||||
*/
|
||||
export const currentRouteTopActivity = (path: string, menus: RouteRecordRaw[]): RouteRecordRaw | false => {
|
||||
for (let i = 0; i < menus.length; i++) {
|
||||
const item: RouteRecordRaw = menus[i]
|
||||
// 找到目标
|
||||
if (item.path == path) return item
|
||||
// 从子级继续寻找
|
||||
if (item.children && item.children.length > 0) {
|
||||
const find = currentRouteTopActivity(path, item.children)
|
||||
if (find) return item
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
105
src/layouts/admin/components/menus/menuHorizontal.vue
Normal file
105
src/layouts/admin/components/menus/menuHorizontal.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="layouts-menu-horizontal">
|
||||
<div class="menu-horizontal-logo" v-if="config.layout.menuShowTopBar">
|
||||
<Logo />
|
||||
</div>
|
||||
<el-scrollbar ref="horizontalMenusRef" class="horizontal-menus-scrollbar">
|
||||
<el-menu class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive" :key="state.menuKey">
|
||||
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="menus" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<NavMenus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import Logo from '@/layouts/admin/components/logo.vue'
|
||||
import MenuTree from '@/layouts/admin/components/menus/menuTree.vue'
|
||||
import { useRoute, onBeforeRouteUpdate, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
import NavMenus from '@/layouts/admin/components/navMenus.vue'
|
||||
import { uuid } from '@/utils/random'
|
||||
|
||||
const horizontalMenusRef = ref<ScrollbarInstance>()
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const route = useRoute()
|
||||
|
||||
const state = reactive({
|
||||
menuKey: uuid(),
|
||||
defaultActive: '',
|
||||
})
|
||||
|
||||
const menus = computed(() => {
|
||||
state.menuKey = uuid() // eslint-disable-line
|
||||
return navTabs.state.tabsViewRoutes
|
||||
})
|
||||
|
||||
// 激活当前路由的菜单
|
||||
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
|
||||
state.defaultActive = currentRoute.path
|
||||
}
|
||||
|
||||
// 滚动条滚动到激活菜单所在位置
|
||||
const verticalMenusScroll = () => {
|
||||
nextTick(() => {
|
||||
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
|
||||
if (!activeMenu) return false
|
||||
horizontalMenusRef.value?.setScrollTop(activeMenu.offsetTop)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentRouteActive(route)
|
||||
verticalMenusScroll()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
currentRouteActive(to)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layouts-menu-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
height: 60px;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
border-bottom: solid 1px var(--el-color-info-light-8);
|
||||
}
|
||||
.menu-horizontal-logo {
|
||||
width: 180px;
|
||||
&:hover {
|
||||
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
|
||||
}
|
||||
}
|
||||
.horizontal-menus-scrollbar {
|
||||
flex: 1;
|
||||
}
|
||||
.menu-horizontal {
|
||||
border: none;
|
||||
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
|
||||
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
|
||||
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
|
||||
}
|
||||
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu-item.is-active {
|
||||
background-color: v-bind('config.getColorVal("menuActiveBackground")');
|
||||
}
|
||||
</style>
|
||||
81
src/layouts/admin/components/menus/menuTree.vue
Normal file
81
src/layouts/admin/components/menus/menuTree.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<template v-for="menu in props.menus">
|
||||
<template v-if="menu.children && menu.children.length > 0">
|
||||
<el-sub-menu @click="onClickSubMenu(menu)" :index="menu.path" :key="menu.path">
|
||||
<template #title>
|
||||
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
|
||||
<span>{{ menu.meta?.title ? menu.meta?.title : 'noTitle' }}</span>
|
||||
</template>
|
||||
<menu-tree :extends="{ ...props.extends, level: props.extends.level + 1 }" :menus="menu.children"></menu-tree>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-menu-item :index="menu.path" :key="menu.path" @click="onClickMenu(menu)">
|
||||
<Icon :color="config.getColorVal('menuColor')" :name="menu.meta?.icon ? menu.meta?.icon : config.layout.menuDefaultIcon" />
|
||||
<span>{{ menu.meta?.title ? menu.meta?.title : 'noTitle' }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { getFirstRoute, onClickMenu } from '@/utils/router'
|
||||
import { ElNotification } from 'element-plus'
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
interface Props {
|
||||
menus: RouteRecordRaw[]
|
||||
extends?: {
|
||||
level: number
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menus: () => [],
|
||||
extends: () => {
|
||||
return {
|
||||
level: 1,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* sub-menu-item 被点击 - 用于单栏布局和双栏布局
|
||||
* 顶栏菜单:点击时打开第一个菜单
|
||||
* 侧边菜单(若有):点击只展开收缩
|
||||
*
|
||||
* sub-menu-item 被点击时,也会触发到 menu-item 的点击事件,由 el-menu 内部触发,无法很好的排除,在此检查 level 值
|
||||
*/
|
||||
const onClickSubMenu = (menu: RouteRecordRaw) => {
|
||||
if (props.extends?.position == 'horizontal' && props.extends.level <= 1 && menu.children?.length) {
|
||||
const firstRoute = getFirstRoute(menu.children)
|
||||
if (firstRoute) {
|
||||
onClickMenu(firstRoute)
|
||||
} else {
|
||||
ElNotification({
|
||||
type: 'error',
|
||||
message: 'utils.No child menu to jump to!',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active > .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu-item.is-active {
|
||||
background-color: v-bind('config.getColorVal("menuActiveBackground")');
|
||||
}
|
||||
</style>
|
||||
80
src/layouts/admin/components/menus/menuVertical.vue
Normal file
80
src/layouts/admin/components/menus/menuVertical.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<el-scrollbar ref="verticalMenusRef" class="vertical-menus-scrollbar">
|
||||
<el-menu
|
||||
class="layouts-menu-vertical"
|
||||
:collapse-transition="false"
|
||||
:unique-opened="config.layout.menuUniqueOpened"
|
||||
:default-active="state.defaultActive"
|
||||
:collapse="config.layout.menuCollapse"
|
||||
>
|
||||
<MenuTree :menus="navTabs.state.tabsViewRoutes" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import MenuTree from '@/layouts/admin/components/menus/menuTree.vue'
|
||||
import { useRoute, onBeforeRouteUpdate, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const route = useRoute()
|
||||
|
||||
const verticalMenusRef = ref<ScrollbarInstance>()
|
||||
|
||||
const state = reactive({
|
||||
defaultActive: '',
|
||||
})
|
||||
|
||||
const verticalMenusScrollbarHeight = computed(() => {
|
||||
let menuTopBarHeight = 0
|
||||
if (config.layout.menuShowTopBar) {
|
||||
menuTopBarHeight = 50
|
||||
}
|
||||
if (config.layout.layoutMode == 'Default') {
|
||||
return 'calc(100vh - ' + (32 + menuTopBarHeight) + 'px)'
|
||||
} else {
|
||||
return 'calc(100vh - ' + menuTopBarHeight + 'px)'
|
||||
}
|
||||
})
|
||||
|
||||
// 激活当前路由的菜单
|
||||
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
|
||||
state.defaultActive = currentRoute.path
|
||||
}
|
||||
|
||||
// 滚动条滚动到激活菜单所在位置
|
||||
const verticalMenusScroll = () => {
|
||||
nextTick(() => {
|
||||
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical li.is-active')
|
||||
if (!activeMenu) return false
|
||||
verticalMenusRef.value?.setScrollTop(activeMenu.offsetTop)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentRouteActive(route)
|
||||
verticalMenusScroll()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
currentRouteActive(to)
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.vertical-menus-scrollbar {
|
||||
height: v-bind(verticalMenusScrollbarHeight);
|
||||
background-color: v-bind('config.getColorVal("menuBackground")');
|
||||
}
|
||||
.layouts-menu-vertical {
|
||||
border: 0;
|
||||
padding-bottom: 30px;
|
||||
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
|
||||
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
|
||||
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
|
||||
}
|
||||
</style>
|
||||
100
src/layouts/admin/components/menus/menuVerticalChildren.vue
Normal file
100
src/layouts/admin/components/menus/menuVerticalChildren.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<el-scrollbar ref="verticalMenusRef" class="children-vertical-menus-scrollbar">
|
||||
<el-menu
|
||||
class="layouts-menu-vertical-children"
|
||||
:collapse-transition="false"
|
||||
:unique-opened="config.layout.menuUniqueOpened"
|
||||
:default-active="state.defaultActive"
|
||||
:collapse="config.layout.menuCollapse"
|
||||
>
|
||||
<MenuTree v-if="state.routeChildren.length > 0" :menus="state.routeChildren" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import MenuTree from '@/layouts/admin/components/menus/menuTree.vue'
|
||||
import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from 'vue-router'
|
||||
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { currentRouteTopActivity } from '@/layouts/admin/components/menus/helper'
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const route = useRoute()
|
||||
|
||||
const verticalMenusRef = ref<ScrollbarInstance>()
|
||||
|
||||
const state: {
|
||||
defaultActive: string
|
||||
routeChildren: RouteRecordRaw[]
|
||||
} = reactive({
|
||||
defaultActive: '',
|
||||
routeChildren: [],
|
||||
})
|
||||
|
||||
const verticalMenusScrollbarHeight = computed(() => {
|
||||
let menuTopBarHeight = 0
|
||||
if (config.layout.menuShowTopBar) {
|
||||
menuTopBarHeight = 50
|
||||
}
|
||||
if (config.layout.layoutMode == 'Default') {
|
||||
return 'calc(100vh - ' + (32 + menuTopBarHeight) + 'px)'
|
||||
} else {
|
||||
return 'calc(100vh - ' + menuTopBarHeight + 'px)'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 激活当前路由的菜单
|
||||
* @param currentRoute 当前路由
|
||||
*/
|
||||
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
|
||||
let routeChildren = currentRouteTopActivity(currentRoute.path, navTabs.state.tabsViewRoutes)
|
||||
if (routeChildren) {
|
||||
state.defaultActive = currentRoute.path
|
||||
if (routeChildren.children && routeChildren.children.length > 0) {
|
||||
state.routeChildren = routeChildren.children
|
||||
} else {
|
||||
state.routeChildren = [routeChildren]
|
||||
}
|
||||
} else if (!state.routeChildren) {
|
||||
state.routeChildren = navTabs.state.tabsViewRoutes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏菜单滚动条滚动到激活菜单所在位置
|
||||
*/
|
||||
const verticalMenusScroll = () => {
|
||||
nextTick(() => {
|
||||
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.layouts-menu-vertical-children li.is-active')
|
||||
if (!activeMenu) return false
|
||||
verticalMenusRef.value?.setScrollTop(activeMenu.offsetTop)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentRouteActive(route)
|
||||
verticalMenusScroll()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
currentRouteActive(to)
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.children-vertical-menus-scrollbar {
|
||||
height: v-bind(verticalMenusScrollbarHeight);
|
||||
background-color: v-bind('config.getColorVal("menuBackground")');
|
||||
}
|
||||
.layouts-menu-vertical-children {
|
||||
border: 0;
|
||||
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
|
||||
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
|
||||
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
|
||||
}
|
||||
</style>
|
||||
78
src/layouts/admin/components/navBar/classic.vue
Normal file
78
src/layouts/admin/components/navBar/classic.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="nav-bar">
|
||||
<div v-if="config.layout.shrink && config.layout.menuCollapse" class="unfold">
|
||||
<Icon @click="onMenuCollapse" name="fa fa-indent" :color="config.getColorVal('menuActiveColor')" size="18" />
|
||||
</div>
|
||||
<NavTabs v-if="!config.layout.shrink" />
|
||||
<NavMenus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import NavTabs from '@/layouts/admin/components/navBar/tabs.vue'
|
||||
import NavMenus from '../navMenus.vue'
|
||||
import { showShade } from '@/utils/pageShade'
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const onMenuCollapse = () => {
|
||||
showShade('ba-aside-menu-shade', () => {
|
||||
config.setLayout('menuCollapse', true)
|
||||
})
|
||||
config.setLayout('menuCollapse', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
background-color: v-bind('config.getColorVal("headerBarBackground")');
|
||||
:deep(.nav-tabs) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
.ba-nav-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
color: v-bind('config.getColorVal("headerBarTabColor")');
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
.close-icon {
|
||||
padding: 2px;
|
||||
margin: 2px 0 0 4px;
|
||||
}
|
||||
.close-icon:hover {
|
||||
background: var(--ba-color-primary-light);
|
||||
color: var(--el-border-color) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.active {
|
||||
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
|
||||
}
|
||||
&:hover {
|
||||
background-color: v-bind('config.getColorVal("headerBarHoverBackground")');
|
||||
}
|
||||
}
|
||||
.nav-tabs-active-box {
|
||||
position: absolute;
|
||||
height: 50px;
|
||||
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
.unfold {
|
||||
align-self: center;
|
||||
padding-left: var(--ba-main-space);
|
||||
}
|
||||
</style>
|
||||
62
src/layouts/admin/components/navBar/default.vue
Normal file
62
src/layouts/admin/components/navBar/default.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="nav-bar">
|
||||
<NavTabs />
|
||||
<NavMenus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfig } from '@/stores/config'
|
||||
import NavTabs from '@/layouts/admin/components/navBar/tabs.vue'
|
||||
import NavMenus from '../navMenus.vue'
|
||||
|
||||
const config = useConfig()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
height: 50px;
|
||||
margin: 20px var(--ba-main-space) 0 var(--ba-main-space);
|
||||
:deep(.nav-tabs) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
.ba-nav-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
opacity: 0.7;
|
||||
color: v-bind('config.getColorVal("headerBarTabColor")');
|
||||
.close-icon {
|
||||
padding: 2px;
|
||||
margin: 2px 0 0 4px;
|
||||
}
|
||||
.close-icon:hover {
|
||||
background: var(--ba-color-primary-light);
|
||||
color: var(--el-border-color) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.active {
|
||||
color: v-bind('config.getColorVal("headerBarTabActiveColor")');
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.nav-tabs-active-box {
|
||||
position: absolute;
|
||||
height: 40px;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background-color: v-bind('config.getColorVal("headerBarTabActiveBackground")');
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
96
src/layouts/admin/components/navBar/double.vue
Normal file
96
src/layouts/admin/components/navBar/double.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="layouts-menu-horizontal-double">
|
||||
<el-scrollbar ref="horizontalMenusRef" class="double-menus-scrollbar">
|
||||
<el-menu class="menu-horizontal" mode="horizontal" :default-active="state.defaultActive" :key="state.menuKey">
|
||||
<MenuTree :extends="{ position: 'horizontal', level: 1 }" :menus="menus" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<NavMenus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, onBeforeRouteUpdate, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { currentRouteTopActivity } from '@/layouts/admin/components/menus/helper'
|
||||
import MenuTree from '@/layouts/admin/components/menus/menuTree.vue'
|
||||
import NavMenus from '@/layouts/admin/components/navMenus.vue'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { uuid } from '@/utils/random'
|
||||
|
||||
const horizontalMenusRef = ref<ScrollbarInstance>()
|
||||
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const route = useRoute()
|
||||
|
||||
const state = reactive({
|
||||
menuKey: uuid(),
|
||||
defaultActive: '',
|
||||
})
|
||||
|
||||
const menus = computed(() => {
|
||||
state.menuKey = uuid() // eslint-disable-line
|
||||
return navTabs.state.tabsViewRoutes
|
||||
})
|
||||
|
||||
// 激活当前路由的菜单
|
||||
const currentRouteActive = (currentRoute: RouteLocationNormalizedLoaded) => {
|
||||
let routeChildren = currentRouteTopActivity(currentRoute.path, navTabs.state.tabsViewRoutes)
|
||||
if (routeChildren) state.defaultActive = currentRoute.path
|
||||
}
|
||||
|
||||
// 滚动条滚动到激活菜单所在位置
|
||||
const verticalMenusScroll = () => {
|
||||
nextTick(() => {
|
||||
let activeMenu: HTMLElement | null = document.querySelector('.el-menu.menu-horizontal li.is-active')
|
||||
if (!activeMenu) return false
|
||||
horizontalMenusRef.value?.setScrollTop(activeMenu.offsetTop)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentRouteActive(route)
|
||||
verticalMenusScroll()
|
||||
})
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
currentRouteActive(to)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layouts-menu-horizontal-double {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
background-color: var(--ba-bg-color-overlay);
|
||||
border-bottom: solid 1px var(--el-color-info-light-8);
|
||||
}
|
||||
.double-menus-scrollbar {
|
||||
width: 70vw;
|
||||
}
|
||||
.menu-horizontal {
|
||||
border: none;
|
||||
--el-menu-bg-color: v-bind('config.getColorVal("menuBackground")');
|
||||
--el-menu-text-color: v-bind('config.getColorVal("menuColor")');
|
||||
--el-menu-active-color: v-bind('config.getColorVal("menuActiveColor")');
|
||||
}
|
||||
|
||||
.el-sub-menu .icon,
|
||||
.el-menu-item .icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 5px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.is-active .icon {
|
||||
color: var(--el-menu-active-color) !important;
|
||||
}
|
||||
.el-menu-item.is-active {
|
||||
background-color: v-bind('config.getColorVal("menuActiveBackground")');
|
||||
}
|
||||
</style>
|
||||
232
src/layouts/admin/components/navBar/tabs.vue
Normal file
232
src/layouts/admin/components/navBar/tabs.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="nav-tabs" ref="tabScrollbarRef">
|
||||
<div
|
||||
v-for="(item, idx) in navTabs.state.tabsView"
|
||||
@click="onTab(item)"
|
||||
@contextmenu.prevent="onContextmenu(item, $event)"
|
||||
class="ba-nav-tab"
|
||||
:class="navTabs.state.activeIndex == idx ? 'active' : ''"
|
||||
:ref="tabsRefs.set"
|
||||
:key="idx"
|
||||
>
|
||||
{{ item.meta.title }}
|
||||
<transition @after-leave="selectNavTab(tabsRefs[navTabs.state.activeIndex])" name="el-fade-in">
|
||||
<Icon
|
||||
v-show="navTabs.state.tabsView.length > 1"
|
||||
class="close-icon"
|
||||
@click.stop="closeTab(item)"
|
||||
size="15"
|
||||
name="el-icon-Close"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
<div :style="activeBoxStyle" class="nav-tabs-active-box"></div>
|
||||
</div>
|
||||
<Contextmenu ref="contextmenuRef" :items="state.contextmenuItems" @contextmenuItemClick="onContextmenuItem" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { useTemplateRefsList } from '@vueuse/core'
|
||||
import type { ContextMenuItem, ContextmenuItemClickEmitArg } from '@/components/contextmenu/interface'
|
||||
import useCurrentInstance from '@/utils/useCurrentInstance'
|
||||
import Contextmenu from '@/components/contextmenu/index.vue'
|
||||
import horizontalScroll from '@/utils/horizontalScroll'
|
||||
import { getFirstRoute, routePush } from '@/utils/router'
|
||||
import { adminBaseRoutePath } from '@/router/static'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
|
||||
const { proxy } = useCurrentInstance()
|
||||
const tabScrollbarRef = ref()
|
||||
const tabsRefs = useTemplateRefsList<HTMLDivElement>()
|
||||
|
||||
const contextmenuRef = ref()
|
||||
|
||||
const state: {
|
||||
contextmenuItems: ContextMenuItem[]
|
||||
} = reactive({
|
||||
contextmenuItems: [
|
||||
{ name: 'refresh', label: '重新加载', icon: 'fa fa-refresh' },
|
||||
{ name: 'close', label: '关闭标签', icon: 'fa fa-times' },
|
||||
{ name: 'fullScreen', label: '当前标签全屏', icon: 'el-icon-FullScreen' },
|
||||
{ name: 'closeOther', label: '关闭其他标签', icon: 'fa fa-minus' },
|
||||
{ name: 'closeAll', label: '关闭全部标签', icon: 'fa fa-stop' }
|
||||
]
|
||||
})
|
||||
|
||||
const activeBoxStyle = reactive({
|
||||
width: '0',
|
||||
transform: 'translateX(0px)'
|
||||
})
|
||||
|
||||
const onTab = (menu: RouteLocationNormalized) => {
|
||||
router.push(menu)
|
||||
}
|
||||
|
||||
const onContextmenu = (menu: RouteLocationNormalized, el: MouseEvent) => {
|
||||
// 禁用刷新
|
||||
state.contextmenuItems[0].disabled = route.path !== menu.path
|
||||
// 禁用关闭其他和关闭全部
|
||||
state.contextmenuItems[4].disabled = state.contextmenuItems[3].disabled =
|
||||
navTabs.state.tabsView.length == 1 ? true : false
|
||||
|
||||
const { clientX, clientY } = el
|
||||
contextmenuRef.value.onShowContextmenu(menu, {
|
||||
x: clientX,
|
||||
y: clientY
|
||||
})
|
||||
}
|
||||
|
||||
// tab 激活状态切换
|
||||
const selectNavTab = function (dom: HTMLDivElement) {
|
||||
if (!dom) {
|
||||
return false
|
||||
}
|
||||
activeBoxStyle.width = dom.clientWidth + 'px'
|
||||
activeBoxStyle.transform = `translateX(${dom.offsetLeft}px)`
|
||||
|
||||
let scrollLeft = dom.offsetLeft + dom.clientWidth - tabScrollbarRef.value.clientWidth
|
||||
if (dom.offsetLeft < tabScrollbarRef.value.scrollLeft) {
|
||||
tabScrollbarRef.value.scrollTo(dom.offsetLeft, 0)
|
||||
} else if (scrollLeft > tabScrollbarRef.value.scrollLeft) {
|
||||
tabScrollbarRef.value.scrollTo(scrollLeft, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const toLastTab = () => {
|
||||
const lastTab = navTabs.state.tabsView.slice(-1)[0]
|
||||
if (lastTab) {
|
||||
router.push(lastTab)
|
||||
} else {
|
||||
router.push(adminBaseRoutePath)
|
||||
}
|
||||
}
|
||||
|
||||
const closeTab = (route: RouteLocationNormalized) => {
|
||||
navTabs.closeTab(route)
|
||||
proxy.eventBus.emit('onTabViewClose', route)
|
||||
if (navTabs.state.activeRoute?.path === route.path) {
|
||||
toLastTab()
|
||||
} else {
|
||||
navTabs.setActiveRoute(navTabs.state.activeRoute!)
|
||||
nextTick(() => {
|
||||
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
|
||||
})
|
||||
}
|
||||
|
||||
contextmenuRef.value.onHideContextmenu()
|
||||
}
|
||||
|
||||
const closeOtherTab = (menu: RouteLocationNormalized) => {
|
||||
navTabs.closeTabs(menu)
|
||||
navTabs.setActiveRoute(menu)
|
||||
if (navTabs.state.activeRoute?.path !== route.path) {
|
||||
router.push(menu!.path)
|
||||
}
|
||||
}
|
||||
|
||||
const closeAllTab = (menu: RouteLocationNormalized) => {
|
||||
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
|
||||
if (firstRoute && firstRoute.path == menu.path) {
|
||||
return closeOtherTab(menu)
|
||||
}
|
||||
if (firstRoute && firstRoute.path == navTabs.state.activeRoute?.path) {
|
||||
return closeOtherTab(navTabs.state.activeRoute)
|
||||
}
|
||||
navTabs.closeTabs(false)
|
||||
if (firstRoute) routePush(firstRoute.path)
|
||||
}
|
||||
|
||||
const onContextmenuItem = async (item: ContextmenuItemClickEmitArg) => {
|
||||
const { name, menu } = item
|
||||
if (!menu) return
|
||||
switch (name) {
|
||||
case 'refresh':
|
||||
proxy.eventBus.emit('onTabViewRefresh', menu)
|
||||
break
|
||||
case 'close':
|
||||
closeTab(menu)
|
||||
break
|
||||
case 'closeOther':
|
||||
closeOtherTab(menu)
|
||||
break
|
||||
case 'closeAll':
|
||||
closeAllTab(menu)
|
||||
break
|
||||
case 'fullScreen':
|
||||
if (route.path !== menu?.path) {
|
||||
router.push(menu?.path as string)
|
||||
}
|
||||
navTabs.setFullScreen(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const updateTab = function (newRoute: RouteLocationNormalized) {
|
||||
// 添加tab
|
||||
navTabs.addTab(newRoute)
|
||||
// 激活当前tab
|
||||
navTabs.setActiveRoute(newRoute)
|
||||
|
||||
nextTick(() => {
|
||||
selectNavTab(tabsRefs.value[navTabs.state.activeIndex])
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeRouteUpdate(async to => {
|
||||
updateTab(to)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
updateTab(router.currentRoute.value)
|
||||
new horizontalScroll(tabScrollbarRef.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dark {
|
||||
.close-icon {
|
||||
color: v-bind('config.getColorVal("headerBarTabColor")') !important;
|
||||
}
|
||||
.ba-nav-tab.active {
|
||||
.close-icon {
|
||||
color: v-bind('config.getColorVal("headerBarTabActiveColor")') !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.nav-tabs {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-right: var(--ba-main-space);
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #eaeaea;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: v-bind('config.layout.layoutMode == "Default" ? "none":config.getColorVal("headerBarBackground")');
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #c8c9cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ba-nav-tab {
|
||||
white-space: nowrap;
|
||||
height: 40px;
|
||||
}
|
||||
</style>
|
||||
219
src/layouts/admin/components/navMenus.vue
Normal file
219
src/layouts/admin/components/navMenus.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="nav-menus" :class="configStore.layout.layoutMode">
|
||||
<router-link class="h100" target="_blank" title="'Home'" to="/">
|
||||
<div class="nav-menu-item">
|
||||
<Icon
|
||||
:color="configStore.getColorVal('headerBarTabColor')"
|
||||
class="nav-menu-icon"
|
||||
name="el-icon-Monitor"
|
||||
size="18"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
<div @click="onFullScreen" class="nav-menu-item" :class="state.isFullScreen ? 'hover' : ''">
|
||||
<Icon
|
||||
:color="configStore.getColorVal('headerBarTabColor')"
|
||||
class="nav-menu-icon"
|
||||
v-if="state.isFullScreen"
|
||||
name="local-full-screen-cancel"
|
||||
size="18"
|
||||
/>
|
||||
<Icon
|
||||
:color="configStore.getColorVal('headerBarTabColor')"
|
||||
class="nav-menu-icon"
|
||||
v-else
|
||||
name="el-icon-FullScreen"
|
||||
size="18"
|
||||
/>
|
||||
</div>
|
||||
<el-popover
|
||||
@show="onCurrentNavMenu(true, 'adminInfo')"
|
||||
@hide="onCurrentNavMenu(false, 'adminInfo')"
|
||||
placement="bottom-end"
|
||||
:hide-after="0"
|
||||
:width="260"
|
||||
trigger="click"
|
||||
popper-class="admin-info-box"
|
||||
v-model:visible="state.showAdminInfoPopover"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="admin-info" :class="state.currentNavMenu == 'adminInfo' ? 'hover' : ''">
|
||||
<el-avatar :size="25" fit="fill">
|
||||
<img :src="fullUrl(adminInfo.avatar)" alt="" />
|
||||
</el-avatar>
|
||||
<div class="admin-name">{{ adminInfo.nickname }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div class="admin-info-base">
|
||||
<el-avatar :size="70" fit="fill">
|
||||
<img :src="fullUrl(adminInfo.avatar)" alt="" />
|
||||
</el-avatar>
|
||||
<div class="admin-info-other">
|
||||
<div class="admin-info-name">{{ adminInfo.nickname }}</div>
|
||||
<div class="admin-info-lasttime">{{ adminInfo.last_login_time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-info-footer">
|
||||
<el-button @click="onAdminInfo" type="primary" plain>{{ 'layouts.personal data' }}</el-button>
|
||||
<el-button @click="onLogout" type="danger" plain>{{ 'layouts.cancellation' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
<div @click="configStore.setLayout('showDrawer', true)" class="nav-menu-item">
|
||||
<Icon
|
||||
:color="configStore.getColorVal('headerBarTabColor')"
|
||||
class="nav-menu-icon"
|
||||
name="fa fa-cogs"
|
||||
size="18"
|
||||
/>
|
||||
</div>
|
||||
<Config />
|
||||
<!-- <TerminalVue /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive } from 'vue'
|
||||
import screenfull from 'screenfull'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Config from './config.vue'
|
||||
import { useAdminInfo } from '@/stores/adminInfo'
|
||||
import { Local, Session } from '@/utils/storage'
|
||||
import { ADMIN_INFO } from '@/stores/constant/cacheKey'
|
||||
import router from '@/router'
|
||||
import { routePush } from '@/utils/router'
|
||||
import { fullUrl } from '@/utils/common'
|
||||
|
||||
const adminInfo = useAdminInfo()
|
||||
const configStore = useConfig()
|
||||
|
||||
const state = reactive({
|
||||
isFullScreen: false,
|
||||
currentNavMenu: '',
|
||||
showLayoutDrawer: false,
|
||||
showAdminInfoPopover: false
|
||||
})
|
||||
|
||||
const onCurrentNavMenu = (status: boolean, name: string) => {
|
||||
state.currentNavMenu = status ? name : ''
|
||||
}
|
||||
|
||||
const onFullScreen = () => {
|
||||
if (!screenfull.isEnabled) {
|
||||
ElMessage.warning('layouts.Full screen is not supported')
|
||||
return false
|
||||
}
|
||||
screenfull.toggle()
|
||||
screenfull.onchange(() => {
|
||||
state.isFullScreen = screenfull.isFullscreen
|
||||
})
|
||||
}
|
||||
|
||||
const onAdminInfo = () => {
|
||||
state.showAdminInfoPopover = false
|
||||
routePush({ name: 'routine/adminInfo' })
|
||||
}
|
||||
|
||||
const onLogout = () => {}
|
||||
|
||||
// const onClearCache = (type: string) => {
|
||||
// if (type == 'storage' || type == 'all') {
|
||||
// const adminInfo = Local.get(ADMIN_INFO)
|
||||
// Session.clear()
|
||||
// Local.clear()
|
||||
// Local.set(ADMIN_INFO, adminInfo)
|
||||
// if (type == 'storage') return
|
||||
// }
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nav-menus.Default {
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
.nav-menus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-left: auto;
|
||||
background-color: v-bind('configStore.getColorVal("headerBarBackground")');
|
||||
.nav-menu-item {
|
||||
height: 100%;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
.nav-menu-icon {
|
||||
box-sizing: content-box;
|
||||
color: v-bind('configStore.getColorVal("headerBarTabColor")');
|
||||
}
|
||||
&:hover {
|
||||
.icon {
|
||||
animation: twinkle 0.3s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
.admin-info {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: v-bind('configStore.getColorVal("headerBarTabColor")');
|
||||
}
|
||||
.admin-name {
|
||||
padding-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-menu-item:hover,
|
||||
.admin-info:hover,
|
||||
.nav-menu-item.hover,
|
||||
.admin-info.hover {
|
||||
background: v-bind('configStore.getColorVal("headerBarHoverBackground")');
|
||||
}
|
||||
}
|
||||
.dropdown-menu-box :deep(.el-dropdown-menu__item) {
|
||||
justify-content: center;
|
||||
}
|
||||
.admin-info-base {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 10px;
|
||||
.admin-info-other {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
.admin-info-name {
|
||||
font-size: var(--el-font-size-large);
|
||||
}
|
||||
}
|
||||
}
|
||||
.admin-info-footer {
|
||||
padding: 10px 0;
|
||||
margin: 0 -12px -12px -12px;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.pt2 {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
src/layouts/admin/container/classic.vue
Normal file
31
src/layouts/admin/container/classic.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<Aside />
|
||||
<el-container class="content-wrapper">
|
||||
<Header />
|
||||
<Main />
|
||||
</el-container>
|
||||
</el-container>
|
||||
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Aside from '@/layouts/admin/components/aside.vue'
|
||||
import Header from '@/layouts/admin/components/header.vue'
|
||||
import Main from '@/layouts/admin/router-view/main.vue'
|
||||
import CloseFullScreen from '@/layouts/admin/components/closeFullScreen.vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
const navTabs = useNavTabs()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
31
src/layouts/admin/container/default.vue
Normal file
31
src/layouts/admin/container/default.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<Aside />
|
||||
<el-container class="content-wrapper">
|
||||
<Header />
|
||||
<Main />
|
||||
</el-container>
|
||||
</el-container>
|
||||
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Aside from '@/layouts/admin/components/aside.vue'
|
||||
import Header from '@/layouts/admin/components/header.vue'
|
||||
import Main from '@/layouts/admin/router-view/main.vue'
|
||||
import CloseFullScreen from '@/layouts/admin/components/closeFullScreen.vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
const navTabs = useNavTabs()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
31
src/layouts/admin/container/double.vue
Normal file
31
src/layouts/admin/container/double.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<Aside />
|
||||
<el-container class="content-wrapper">
|
||||
<Header />
|
||||
<Main />
|
||||
</el-container>
|
||||
</el-container>
|
||||
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Aside from '@/layouts/admin/components/aside.vue'
|
||||
import Header from '@/layouts/admin/components/header.vue'
|
||||
import Main from '@/layouts/admin/router-view/main.vue'
|
||||
import CloseFullScreen from '@/layouts/admin/components/closeFullScreen.vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
const navTabs = useNavTabs()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
29
src/layouts/admin/container/streamline.vue
Normal file
29
src/layouts/admin/container/streamline.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-container class="content-wrapper">
|
||||
<Header />
|
||||
<Main />
|
||||
</el-container>
|
||||
</el-container>
|
||||
<CloseFullScreen v-if="navTabs.state.tabFullScreen" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Header from '@/layouts/admin/components/header.vue'
|
||||
import Main from '@/layouts/admin/router-view/main.vue'
|
||||
import CloseFullScreen from '@/layouts/admin/components/closeFullScreen.vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
const navTabs = useNavTabs()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.content-wrapper {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
124
src/layouts/admin/index.vue
Normal file
124
src/layouts/admin/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<component :is="config.layout.layoutMode"></component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { useAdminInfo } from '@/stores/adminInfo'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Default from '@/layouts/admin/container/default.vue'
|
||||
import Classic from '@/layouts/admin/container/classic.vue'
|
||||
import Streamline from '@/layouts/admin/container/streamline.vue'
|
||||
import Double from '@/layouts/admin/container/double.vue'
|
||||
import { onMounted, onBeforeMount } from 'vue'
|
||||
import { handleAdminRoute, getFirstRoute, routePush } from '@/utils/router'
|
||||
import router from '@/router/index'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { isEmpty } from 'lodash-es'
|
||||
import { setNavTabsWidth } from '@/utils/layout'
|
||||
import { adminBaseRoutePath } from '@/router/static'
|
||||
|
||||
defineOptions({
|
||||
components: { Default, Classic, Streamline, Double }
|
||||
})
|
||||
|
||||
const navTabs = useNavTabs()
|
||||
const config = useConfig()
|
||||
const route = useRoute()
|
||||
const adminInfo = useAdminInfo()
|
||||
|
||||
const state = reactive({
|
||||
autoMenuCollapseLock: false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// if (!adminInfo.token) return router.push({ name: 'login' })
|
||||
|
||||
init()
|
||||
setNavTabsWidth()
|
||||
useEventListener(window, 'resize', setNavTabsWidth)
|
||||
})
|
||||
onBeforeMount(() => {
|
||||
onAdaptiveLayout()
|
||||
useEventListener(window, 'resize', onAdaptiveLayout)
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
/**
|
||||
* 后台初始化请求,获取站点配置,动态路由等信息
|
||||
*/
|
||||
handleAdminRoute([
|
||||
{
|
||||
id: 1,
|
||||
pid: 0,
|
||||
type: 'menu',
|
||||
title: '控制台',
|
||||
name: 'dashboard',
|
||||
path: 'dashboard',
|
||||
icon: 'fa fa-dashboard',
|
||||
menu_type: 'tab',
|
||||
url: '',
|
||||
component: '/src/views/dashboard/index.vue',
|
||||
keepalive: 'dashboard',
|
||||
extend: 'none',
|
||||
children: [
|
||||
{
|
||||
id: 94,
|
||||
pid: 1,
|
||||
type: 'button',
|
||||
title: '查看',
|
||||
name: 'dashboard/index',
|
||||
path: '',
|
||||
icon: '',
|
||||
menu_type: null,
|
||||
url: '',
|
||||
component: '',
|
||||
keepalive: 0,
|
||||
extend: 'none'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
// 预跳转到上次路径
|
||||
if (route.params.to) {
|
||||
const lastRoute = JSON.parse(route.params.to as string)
|
||||
if (lastRoute.path != adminBaseRoutePath) {
|
||||
let query = !isEmpty(lastRoute.query) ? lastRoute.query : {}
|
||||
routePush({ path: lastRoute.path, query: query })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到第一个菜单
|
||||
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
|
||||
if (firstRoute) routePush(firstRoute.path)
|
||||
}
|
||||
|
||||
const onAdaptiveLayout = () => {
|
||||
let defaultBeforeResizeLayout = {
|
||||
layoutMode: config.layout.layoutMode,
|
||||
menuCollapse: config.layout.menuCollapse
|
||||
}
|
||||
|
||||
const clientWidth = document.body.clientWidth
|
||||
if (clientWidth < 1024) {
|
||||
/**
|
||||
* 锁定窗口改变自动调整 menuCollapse
|
||||
* 避免已是小窗且打开了菜单栏时,意外的自动关闭菜单栏
|
||||
*/
|
||||
if (!state.autoMenuCollapseLock) {
|
||||
state.autoMenuCollapseLock = true
|
||||
config.setLayout('menuCollapse', true)
|
||||
}
|
||||
config.setLayout('shrink', true)
|
||||
config.setLayoutMode('Classic')
|
||||
} else {
|
||||
state.autoMenuCollapseLock = false
|
||||
config.setLayout('menuCollapse', defaultBeforeResizeLayout.menuCollapse)
|
||||
config.setLayout('shrink', false)
|
||||
config.setLayoutMode(defaultBeforeResizeLayout.layoutMode)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
104
src/layouts/admin/router-view/main.vue
Normal file
104
src/layouts/admin/router-view/main.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<el-main class="layout-main">
|
||||
<el-scrollbar class="layout-main-scrollbar" :style="layoutMainScrollbarStyle()" ref="mainScrollbarRef">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition :name="config.layout.mainAnimation" mode="out-in">
|
||||
<keep-alive :include="state.keepAliveComponentNameList">
|
||||
<component :is="Component" :key="state.componentKey" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-scrollbar>
|
||||
</el-main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, onBeforeMount, onUnmounted, nextTick, provide } from 'vue'
|
||||
import { useRoute, type RouteLocationNormalized } from 'vue-router'
|
||||
import { mainHeight as layoutMainScrollbarStyle } from '@/utils/layout'
|
||||
import useCurrentInstance from '@/utils/useCurrentInstance'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
|
||||
defineOptions({
|
||||
name: 'layout/main',
|
||||
})
|
||||
|
||||
const { proxy } = useCurrentInstance()
|
||||
|
||||
const route = useRoute()
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
const mainScrollbarRef = ref<ScrollbarInstance>()
|
||||
|
||||
const state: {
|
||||
componentKey: string
|
||||
keepAliveComponentNameList: string[]
|
||||
} = reactive({
|
||||
componentKey: route.path,
|
||||
keepAliveComponentNameList: [],
|
||||
})
|
||||
|
||||
const addKeepAliveComponentName = function (keepAliveName: string | undefined) {
|
||||
if (keepAliveName) {
|
||||
let exist = state.keepAliveComponentNameList.find((name: string) => {
|
||||
return name === keepAliveName
|
||||
})
|
||||
if (exist) return
|
||||
state.keepAliveComponentNameList.push(keepAliveName)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
proxy.eventBus.on('onTabViewRefresh', (menu: RouteLocationNormalized) => {
|
||||
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
|
||||
state.componentKey = ''
|
||||
nextTick(() => {
|
||||
state.componentKey = menu.path
|
||||
addKeepAliveComponentName(menu.meta.keepalive as string)
|
||||
})
|
||||
})
|
||||
proxy.eventBus.on('onTabViewClose', (menu: RouteLocationNormalized) => {
|
||||
state.keepAliveComponentNameList = state.keepAliveComponentNameList.filter((name: string) => menu.meta.keepalive !== name)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
proxy.eventBus.off('onTabViewRefresh')
|
||||
proxy.eventBus.off('onTabViewClose')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 确保刷新页面时也能正确取得当前路由 keepalive 参数
|
||||
if (typeof navTabs.state.activeRoute?.meta.keepalive == 'string') {
|
||||
addKeepAliveComponentName(navTabs.state.activeRoute?.meta.keepalive)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
state.componentKey = route.path
|
||||
if (typeof navTabs.state.activeRoute?.meta.keepalive == 'string') {
|
||||
addKeepAliveComponentName(navTabs.state.activeRoute?.meta.keepalive)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
provide('mainScrollbarRef', mainScrollbarRef)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-container .layout-main {
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.layout-main-scrollbar {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
75
src/layouts/common/components/darkSwitch.vue
Normal file
75
src/layouts/common/components/darkSwitch.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="theme-toggle-content">
|
||||
<div class="switch">
|
||||
<div class="switch-action">
|
||||
<Icon name="local-dark" color="#f2f2f2" size="13px" class="switch-icon dark-icon" />
|
||||
<Icon name="local-light" color="#303133" size="13px" class="switch-icon light-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-toggle-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.switch {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--ba-bg-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s, background-color 0.5s;
|
||||
}
|
||||
.switch-action {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
transform: translate(0);
|
||||
color: var(--el-text-color-primary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.switch-icon {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
bottom: 1px;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@at-root .dark {
|
||||
.switch {
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.switch-action {
|
||||
transform: translate(20px);
|
||||
background-color: #141414;
|
||||
}
|
||||
.dark-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.light-icon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/layouts/common/components/loading.vue
Normal file
59
src/layouts/common/components/loading.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-loading="true"
|
||||
element-loading-background="var(--ba-bg-color-overlay)"
|
||||
element-loading-text="$'utils.Loading'"
|
||||
class="default-main ba-main-loading"
|
||||
></div>
|
||||
<div v-if="state.showReload" class="loading-footer">
|
||||
<el-button @click="refresh" type="warning">utils.Reload</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, reactive } from 'vue'
|
||||
import router from '@/router/index'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { isAdminApp } from '@/utils/common'
|
||||
import { getFirstRoute, routePush } from '@/utils/router'
|
||||
let timer: number
|
||||
|
||||
const navTabs = useNavTabs()
|
||||
const state = reactive({
|
||||
maximumWait: 1000 * 6,
|
||||
showReload: false,
|
||||
})
|
||||
|
||||
const refresh = () => {
|
||||
router.go(0)
|
||||
}
|
||||
|
||||
if (isAdminApp() && navTabs.state.tabsViewRoutes) {
|
||||
let firstRoute = getFirstRoute(navTabs.state.tabsViewRoutes)
|
||||
if (firstRoute) routePush(firstRoute.path)
|
||||
}
|
||||
|
||||
timer = window.setTimeout(() => {
|
||||
state.showReload = true
|
||||
}, state.maximumWait)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-main-loading {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
37
src/layouts/common/router-view/iframe.vue
Normal file
37
src/layouts/common/router-view/iframe.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="iframe-main" v-loading="state.loading">
|
||||
<iframe
|
||||
:src="state.iframeSrc"
|
||||
:style="iframeStyle(35)"
|
||||
frameborder="0"
|
||||
height="100%"
|
||||
width="100%"
|
||||
id="iframe"
|
||||
ref="iframeRef"
|
||||
@load="hideLoading"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { mainHeight as iframeStyle } from '@/utils/layout'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
iframeSrc: router.currentRoute.value.meta.url as string,
|
||||
})
|
||||
|
||||
const hideLoading = () => {
|
||||
state.loading = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.iframe-main {
|
||||
margin: var(--ba-main-space);
|
||||
}
|
||||
</style>
|
||||
21
src/main.ts
Normal file
21
src/main.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from '@/stores/index'
|
||||
import { registerIcons } from '@/utils/common'
|
||||
import mitt from 'mitt'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/display.css'
|
||||
// modules import mark, Please do not remove.
|
||||
import '@/styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus)
|
||||
registerIcons(app) // icons
|
||||
|
||||
app.mount('#app')
|
||||
app.config.globalProperties.eventBus = mitt()
|
||||
75
src/router/index.ts
Normal file
75
src/router/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import staticRoutes from '@/router/static'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes
|
||||
})
|
||||
|
||||
// router.beforeEach((to, from, next) => {
|
||||
// NProgress.configure({ showSpinner: false })
|
||||
// NProgress.start()
|
||||
// if (!window.existLoading) {
|
||||
// loading.show()
|
||||
// window.existLoading = true
|
||||
// }
|
||||
|
||||
// // 按需动态加载页面的语言包-start
|
||||
// let loadPath: string[] = []
|
||||
// const config = useConfig()
|
||||
// if (to.path in langAutoLoadMap) {
|
||||
// loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
|
||||
// }
|
||||
// let prefix = ''
|
||||
// if (isAdminApp(to.fullPath)) {
|
||||
// prefix = './backend/' + config.lang.defaultLang
|
||||
|
||||
// // 去除 path 中的 /admin
|
||||
// const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
|
||||
// if (adminPath) loadPath.push(prefix + adminPath + '.ts')
|
||||
// } else {
|
||||
// prefix = './frontend/' + config.lang.defaultLang
|
||||
// loadPath.push(prefix + to.path + '.ts')
|
||||
// }
|
||||
|
||||
// // 根据路由 name 加载的语言包
|
||||
// if (to.name) {
|
||||
// loadPath.push(prefix + '/' + to.name.toString() + '.ts')
|
||||
// }
|
||||
|
||||
// if (!window.loadLangHandle.publicMessageLoaded) window.loadLangHandle.publicMessageLoaded = []
|
||||
// const publicMessagePath = prefix + '.ts'
|
||||
// if (!window.loadLangHandle.publicMessageLoaded.includes(publicMessagePath)) {
|
||||
// loadPath.push(publicMessagePath)
|
||||
// window.loadLangHandle.publicMessageLoaded.push(publicMessagePath)
|
||||
// }
|
||||
|
||||
// // 去重
|
||||
// loadPath = uniq(loadPath)
|
||||
|
||||
// for (const key in loadPath) {
|
||||
// loadPath[key] = loadPath[key].replaceAll('${lang}', config.lang.defaultLang)
|
||||
// if (loadPath[key] in window.loadLangHandle) {
|
||||
// window.loadLangHandle[loadPath[key]]().then((res: { default: anyObj }) => {
|
||||
// const pathName = loadPath[key].slice(
|
||||
// loadPath[key].lastIndexOf(prefix) + (prefix.length + 1),
|
||||
// loadPath[key].lastIndexOf('.')
|
||||
// )
|
||||
// mergeMessage(res.default, pathName)
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// // 动态加载语言包-end
|
||||
|
||||
// next()
|
||||
// })
|
||||
|
||||
// // 路由加载后
|
||||
// router.afterEach(() => {
|
||||
// if (window.existLoading) {
|
||||
// loading.hide()
|
||||
// }
|
||||
// NProgress.done()
|
||||
// })
|
||||
|
||||
export default router
|
||||
62
src/router/static.ts
Normal file
62
src/router/static.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const pageTitle = (name: string): string => {
|
||||
return `pagesTitle.${name}`
|
||||
}
|
||||
/**
|
||||
* 后台基础路由路径
|
||||
*/
|
||||
export const adminBaseRoutePath = '/admin'
|
||||
export const adminBaseRoute = {
|
||||
path: adminBaseRoutePath,
|
||||
name: 'admin',
|
||||
component: () => import('@/layouts/admin/index.vue'),
|
||||
// 直接重定向到 loading 路由
|
||||
redirect: adminBaseRoutePath + '/loading',
|
||||
meta: {
|
||||
title: `pagesTitle.admin`
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'loading/:to?',
|
||||
name: 'adminMainLoading',
|
||||
component: () => import('@/layouts/common/components/loading.vue'),
|
||||
meta: {
|
||||
title: `pagesTitle.loading`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/*
|
||||
* 静态路由
|
||||
* 自动加载 ./static 目录的所有文件,并 push 到以下数组
|
||||
*/
|
||||
const staticRoutes: Array<RouteRecordRaw> = [
|
||||
adminBaseRoute,
|
||||
{
|
||||
// 管理员登录页 - 不放在 adminBaseRoute.children 因为登录页不需要使用后台的布局
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/user/login.vue'),
|
||||
meta: {
|
||||
title: pageTitle('login')
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/:path(.*)*',
|
||||
redirect: '/404'
|
||||
},
|
||||
{
|
||||
// 404
|
||||
path: '/404',
|
||||
name: 'notFound',
|
||||
component: () => import('@/views/common/error/404.vue'),
|
||||
meta: {
|
||||
title: pageTitle('notFound') // 页面不存在
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default staticRoutes
|
||||
37
src/stores/adminInfo.ts
Normal file
37
src/stores/adminInfo.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ADMIN_INFO } from '@/stores/constant/cacheKey'
|
||||
import type { AdminInfo } from '@/stores/interface'
|
||||
|
||||
export const useAdminInfo = defineStore('adminInfo', {
|
||||
state: (): AdminInfo => {
|
||||
return {
|
||||
id: 0,
|
||||
username: '',
|
||||
nickname: '',
|
||||
avatar: '',
|
||||
last_login_time: '',
|
||||
token: '',
|
||||
refresh_token: '',
|
||||
super: false
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
dataFill(state: AdminInfo) {
|
||||
this.$state = { ...this.$state, ...state }
|
||||
},
|
||||
removeToken() {
|
||||
this.token = ''
|
||||
this.refresh_token = ''
|
||||
},
|
||||
setToken(token: string, type: 'auth' | 'refresh') {
|
||||
const field = type == 'auth' ? 'token' : 'refresh_token'
|
||||
this[field] = token
|
||||
},
|
||||
getToken(type: 'auth' | 'refresh' = 'auth') {
|
||||
return type === 'auth' ? this.token : this.refresh_token
|
||||
}
|
||||
},
|
||||
persist: {
|
||||
key: ADMIN_INFO
|
||||
}
|
||||
})
|
||||
125
src/stores/config.ts
Normal file
125
src/stores/config.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { reactive } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { STORE_CONFIG } from '@/stores/constant/cacheKey'
|
||||
import type { Layout } from '@/stores/interface'
|
||||
|
||||
export const useConfig = defineStore(
|
||||
'config',
|
||||
() => {
|
||||
const layout: Layout = reactive({
|
||||
/* 全局 */
|
||||
showDrawer: false,
|
||||
// 是否收缩布局(小屏设备)
|
||||
shrink: false,
|
||||
// 后台布局方式,可选值<Default|Classic|Streamline|Double>
|
||||
layoutMode: 'Default',
|
||||
// 后台主页面切换动画,可选值<slide-right|slide-left|el-fade-in-linear|el-fade-in|el-zoom-in-center|el-zoom-in-top|el-zoom-in-bottom>
|
||||
mainAnimation: 'slide-right',
|
||||
// 是否暗黑模式
|
||||
isDark: false,
|
||||
|
||||
/* 侧边菜单 */
|
||||
// 侧边菜单背景色
|
||||
menuBackground: ['#ffffff', '#1d1e1f'],
|
||||
// 侧边菜单文字颜色
|
||||
menuColor: ['#303133', '#CFD3DC'],
|
||||
// 侧边菜单激活项背景色
|
||||
menuActiveBackground: ['#ffffff', '#1d1e1f'],
|
||||
// 侧边菜单激活项文字色
|
||||
menuActiveColor: ['#409eff', '#3375b9'],
|
||||
// 侧边菜单顶栏背景色
|
||||
menuTopBarBackground: ['#fcfcfc', '#1d1e1f'],
|
||||
// 侧边菜单宽度(展开时),单位px
|
||||
menuWidth: 260,
|
||||
// 侧边菜单项默认图标
|
||||
menuDefaultIcon: 'fa fa-circle-o',
|
||||
// 是否水平折叠收起菜单
|
||||
menuCollapse: false,
|
||||
// 是否只保持一个子菜单的展开(手风琴)
|
||||
menuUniqueOpened: false,
|
||||
// 显示菜单栏顶栏(LOGO)
|
||||
menuShowTopBar: true,
|
||||
|
||||
/* 顶栏 */
|
||||
// 顶栏文字色
|
||||
headerBarTabColor: ['#000000', '#CFD3DC'],
|
||||
// 顶栏激活项背景色
|
||||
headerBarTabActiveBackground: ['#ffffff', '#1d1e1f'],
|
||||
// 顶栏激活项文字色
|
||||
headerBarTabActiveColor: ['#000000', '#409EFF'],
|
||||
// 顶栏背景色
|
||||
headerBarBackground: ['#ffffff', '#1d1e1f'],
|
||||
// 顶栏悬停时背景色
|
||||
headerBarHoverBackground: ['#f5f5f5', '#18222c']
|
||||
})
|
||||
|
||||
const lang = reactive({
|
||||
// 默认语言,可选值<zh-cn|en>
|
||||
defaultLang: 'zh-cn',
|
||||
// 当在默认语言包找不到翻译时,继续在 fallbackLang 语言包内查找翻译
|
||||
fallbackLang: 'zh-cn',
|
||||
// 支持的语言列表
|
||||
langArray: [
|
||||
{ name: 'zh-cn', value: '中文简体' },
|
||||
{ name: 'en', value: 'English' }
|
||||
]
|
||||
})
|
||||
|
||||
function menuWidth() {
|
||||
if (layout.shrink) {
|
||||
return layout.menuCollapse ? '0px' : layout.menuWidth + 'px'
|
||||
}
|
||||
// 菜单是否折叠
|
||||
return layout.menuCollapse ? '64px' : layout.menuWidth + 'px'
|
||||
}
|
||||
|
||||
function setLang(val: string) {
|
||||
lang.defaultLang = val
|
||||
}
|
||||
|
||||
function onSetLayoutColor(data = layout.layoutMode) {
|
||||
// 切换布局时,如果是为默认配色方案,对菜单激活背景色重新赋值
|
||||
const tempValue = layout.isDark
|
||||
? { idx: 1, color: '#1d1e1f', newColor: '#141414' }
|
||||
: { idx: 0, color: '#ffffff', newColor: '#f5f5f5' }
|
||||
if (
|
||||
data == 'Classic' &&
|
||||
layout.headerBarBackground[tempValue.idx] == tempValue.color &&
|
||||
layout.headerBarTabActiveBackground[tempValue.idx] == tempValue.color
|
||||
) {
|
||||
layout.headerBarTabActiveBackground[tempValue.idx] = tempValue.newColor
|
||||
} else if (
|
||||
data == 'Default' &&
|
||||
layout.headerBarBackground[tempValue.idx] == tempValue.color &&
|
||||
layout.headerBarTabActiveBackground[tempValue.idx] == tempValue.newColor
|
||||
) {
|
||||
layout.headerBarTabActiveBackground[tempValue.idx] = tempValue.color
|
||||
}
|
||||
}
|
||||
|
||||
function setLayoutMode(data: string) {
|
||||
layout.layoutMode = data
|
||||
onSetLayoutColor(data)
|
||||
}
|
||||
|
||||
const setLayout = (name: keyof Layout, value: any) => {
|
||||
layout[name] = value as never
|
||||
}
|
||||
|
||||
const getColorVal = function (name: keyof Layout): string {
|
||||
const colors = layout[name] as string[]
|
||||
if (layout.isDark) {
|
||||
return colors[1]
|
||||
} else {
|
||||
return colors[0]
|
||||
}
|
||||
}
|
||||
|
||||
return { layout, lang, menuWidth, setLang, setLayoutMode, setLayout, getColorVal, onSetLayoutColor }
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: STORE_CONFIG
|
||||
}
|
||||
}
|
||||
)
|
||||
12
src/stores/constant/cacheKey.ts
Normal file
12
src/stores/constant/cacheKey.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 本地缓存Key
|
||||
*/
|
||||
|
||||
// 管理员资料
|
||||
export const ADMIN_INFO = 'adminInfo'
|
||||
|
||||
// WEB端布局配置
|
||||
export const STORE_CONFIG = 'storeConfig'
|
||||
|
||||
// 后台标签页
|
||||
export const STORE_TAB_VIEW_CONFIG = 'storeTabViewConfig'
|
||||
7
src/stores/index.ts
Normal file
7
src/stores/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
export default pinia
|
||||
45
src/stores/interface/index.ts
Normal file
45
src/stores/interface/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 变量名对应含义请在 /stores/* 里边找
|
||||
import type { RouteRecordRaw, RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
export interface Layout {
|
||||
showDrawer: boolean
|
||||
shrink: boolean
|
||||
layoutMode: string
|
||||
mainAnimation: string
|
||||
isDark: boolean
|
||||
menuWidth: number
|
||||
menuDefaultIcon: string
|
||||
menuCollapse: boolean
|
||||
menuUniqueOpened: boolean
|
||||
menuShowTopBar: boolean
|
||||
menuBackground: string[]
|
||||
menuColor: string[]
|
||||
menuActiveBackground: string[]
|
||||
menuActiveColor: string[]
|
||||
menuTopBarBackground: string[]
|
||||
headerBarTabColor: string[]
|
||||
headerBarBackground: string[]
|
||||
headerBarHoverBackground: string[]
|
||||
headerBarTabActiveBackground: string[]
|
||||
headerBarTabActiveColor: string[]
|
||||
}
|
||||
|
||||
export interface NavTabs {
|
||||
activeIndex: number
|
||||
activeRoute: RouteLocationNormalized | null
|
||||
tabsView: RouteLocationNormalized[]
|
||||
tabFullScreen: boolean
|
||||
tabsViewRoutes: RouteRecordRaw[]
|
||||
authNode: Map<string, string[]>
|
||||
}
|
||||
|
||||
export interface AdminInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
last_login_time: string
|
||||
token: string
|
||||
refresh_token: string
|
||||
super:boolean
|
||||
}
|
||||
108
src/stores/navTabs.ts
Normal file
108
src/stores/navTabs.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { reactive } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { STORE_TAB_VIEW_CONFIG } from '@/stores/constant/cacheKey'
|
||||
import type { NavTabs } from '@/stores/interface/index'
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
|
||||
import { adminBaseRoutePath } from '@/router/static'
|
||||
|
||||
export const useNavTabs = defineStore(
|
||||
'navTabs',
|
||||
() => {
|
||||
const state: NavTabs = reactive({
|
||||
// 激活tab的index
|
||||
activeIndex: 0,
|
||||
// 激活的tab
|
||||
activeRoute: null,
|
||||
// tab列表
|
||||
tabsView: [],
|
||||
// 当前tab是否全屏
|
||||
tabFullScreen: false,
|
||||
// 从后台加载到的菜单路由列表
|
||||
tabsViewRoutes: [],
|
||||
// 按钮权限节点
|
||||
authNode: new Map(),
|
||||
})
|
||||
|
||||
function addTab(route: RouteLocationNormalized) {
|
||||
if (!route.meta.addtab) return
|
||||
for (const key in state.tabsView) {
|
||||
if (state.tabsView[key].path === route.path) {
|
||||
state.tabsView[key].params = route.params ? route.params : state.tabsView[key].params
|
||||
state.tabsView[key].query = route.query ? route.query : state.tabsView[key].query
|
||||
return
|
||||
}
|
||||
}
|
||||
state.tabsView.push(route)
|
||||
}
|
||||
|
||||
function closeTab(route: RouteLocationNormalized) {
|
||||
state.tabsView.map((v, k) => {
|
||||
if (v.path == route.path) {
|
||||
state.tabsView.splice(k, 1)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭多个标签
|
||||
* @param retainMenu 需要保留的标签,否则关闭全部标签
|
||||
*/
|
||||
const closeTabs = (retainMenu: RouteLocationNormalized | false = false) => {
|
||||
if (retainMenu) {
|
||||
state.tabsView = [retainMenu]
|
||||
} else {
|
||||
state.tabsView = []
|
||||
}
|
||||
}
|
||||
|
||||
const setActiveRoute = (route: RouteLocationNormalized): void => {
|
||||
const currentRouteIndex: number = state.tabsView.findIndex((item: RouteLocationNormalized) => {
|
||||
return item.path === route.path
|
||||
})
|
||||
if (currentRouteIndex === -1) return
|
||||
state.activeRoute = route
|
||||
state.activeIndex = currentRouteIndex
|
||||
}
|
||||
|
||||
const setTabsViewRoutes = (data: RouteRecordRaw[]): void => {
|
||||
state.tabsViewRoutes = encodeRoutesURI(data)
|
||||
}
|
||||
|
||||
const setAuthNode = (key: string, data: string[]) => {
|
||||
state.authNode.set(key, data)
|
||||
}
|
||||
|
||||
const fillAuthNode = (data: Map<string, string[]>) => {
|
||||
state.authNode = data
|
||||
}
|
||||
|
||||
const setFullScreen = (fullScreen: boolean): void => {
|
||||
state.tabFullScreen = fullScreen
|
||||
}
|
||||
|
||||
return { state, addTab, closeTab, closeTabs, setActiveRoute, setTabsViewRoutes, setAuthNode, fillAuthNode, setFullScreen }
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: STORE_TAB_VIEW_CONFIG,
|
||||
paths: ['state.tabFullScreen'],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 对iframe的url进行编码
|
||||
*/
|
||||
function encodeRoutesURI(data: RouteRecordRaw[]) {
|
||||
data.forEach((item) => {
|
||||
if (item.meta?.menu_type == 'iframe') {
|
||||
item.path = adminBaseRoutePath + '/iframe/' + encodeURIComponent(item.path)
|
||||
}
|
||||
|
||||
if (item.children && item.children.length) {
|
||||
item.children = encodeRoutesURI(item.children)
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
79
src/style.css
Normal file
79
src/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
232
src/styles/app.scss
Normal file
232
src/styles/app.scss
Normal file
@@ -0,0 +1,232 @@
|
||||
/* 基本样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, SimSun, sans-serif;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
background-color: var(--ba-bg-color);
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 阿里 iconfont Symbol引用css
|
||||
.iconfont-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.w100 {
|
||||
width: 100% !important;
|
||||
}
|
||||
.h100 {
|
||||
height: 100% !important;
|
||||
}
|
||||
.ba-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.default-main {
|
||||
margin: var(--ba-main-space) var(--ba-main-space) 60px var(--ba-main-space);
|
||||
}
|
||||
.zoom-handle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
.block-help {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
/* 表格顶部菜单-s */
|
||||
.table-header {
|
||||
.table-header-operate .icon {
|
||||
font-size: 14px !important;
|
||||
color: var(--el-color-white) !important;
|
||||
}
|
||||
.el-button.is-disabled .icon {
|
||||
color: var(--el-button-disabled-text-color) !important;
|
||||
}
|
||||
}
|
||||
/* 表格顶部菜单-e */
|
||||
|
||||
/* 鼠标置入浮动效果-s */
|
||||
.suspension {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.suspension:hover {
|
||||
-webkit-transform: translateY(-4px) scale(1.02);
|
||||
-moz-transform: translateY(-4px) scale(1.02);
|
||||
-ms-transform: translateY(-4px) scale(1.02);
|
||||
-o-transform: translateY(-4px) scale(1.02);
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
-webkit-box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.2);
|
||||
z-index: 999;
|
||||
border-radius: 6px;
|
||||
}
|
||||
/* 鼠标置入浮动效果-e */
|
||||
|
||||
/* 表格-s */
|
||||
.ba-table-box {
|
||||
border-radius: var(--el-border-radius-round);
|
||||
}
|
||||
.ba-table-alert {
|
||||
background-color: var(--el-fill-color-darker) !important;
|
||||
border: 1px solid var(--ba-boder-color);
|
||||
border-bottom: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
/* 表格-e */
|
||||
|
||||
/* 新增/编辑表单-s */
|
||||
.ba-operate-dialog {
|
||||
overflow: hidden;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
.ba-operate-dialog .el-dialog__header {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--ba-bg-color);
|
||||
}
|
||||
.ba-operate-dialog .el-dialog__body {
|
||||
height: 60vh;
|
||||
padding-top: 0;
|
||||
padding-bottom: 52px;
|
||||
}
|
||||
.ba-operate-dialog .el-dialog__footer {
|
||||
padding: 10px var(--el-dialog-padding-primary);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
.ba-operate-form {
|
||||
padding-top: 20px;
|
||||
}
|
||||
/* 新增/编辑表单-e */
|
||||
|
||||
/* 全局遮罩-s */
|
||||
.ba-layout-shade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999990;
|
||||
}
|
||||
/* 全局遮罩-e */
|
||||
|
||||
/* 图片上传预览-s */
|
||||
.img-preview-dialog .el-dialog__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
/* 图片上传预览-e */
|
||||
|
||||
/* 页面切换动画-s */
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active,
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
will-change: transform;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
// slide-right
|
||||
.slide-right-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
.slide-right-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
// slide-left
|
||||
.slide-left-enter-from {
|
||||
@extend .slide-right-leave-to;
|
||||
}
|
||||
.slide-left-leave-to {
|
||||
@extend .slide-right-enter-from;
|
||||
}
|
||||
/* 页面切换动画-e */
|
||||
|
||||
/* 布局相关-s */
|
||||
.frontend-footer-brother {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
.user-views {
|
||||
padding-left: 15px;
|
||||
.user-views-card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
.ba-aside-drawer {
|
||||
.el-drawer__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
/* 布局相关-e */
|
||||
|
||||
/* 暗黑模式公共样式-s */
|
||||
.ba-icon-dark {
|
||||
color: var(--el-text-color-primary) !important;
|
||||
}
|
||||
/* 暗黑模式公共样式-e */
|
||||
|
||||
/* NProgress-s */
|
||||
#nprogress {
|
||||
.bar,
|
||||
.spinner {
|
||||
z-index: 999999;
|
||||
}
|
||||
}
|
||||
/* NProgress-e */
|
||||
|
||||
/* 自适应-s */
|
||||
@media screen and (max-width: 768px) {
|
||||
.xs-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 1024px) {
|
||||
.ba-operate-dialog {
|
||||
width: 96%;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 991px) {
|
||||
.user-views {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
/* 自适应-e */
|
||||
68
src/styles/element.scss
Normal file
68
src/styles/element.scss
Normal file
@@ -0,0 +1,68 @@
|
||||
/* 修复 Chrome 浏览器输入框内选中字符行高异常的bug-s */
|
||||
.el-input .el-input__inner {
|
||||
line-height: calc(var(--el-input-height, 40px) - 4px);
|
||||
}
|
||||
/* 修复 Chrome 浏览器输入框内选中字符行高异常的bug-e */
|
||||
|
||||
.datetime-picker {
|
||||
height: 32px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.el-divider__text.is-center {
|
||||
transform: translateX(-50%) translateY(-62%);
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
user-select: none;
|
||||
.el-sub-menu__title:hover {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: var(--ba-border-color);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: none;
|
||||
}
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
}
|
||||
.el-textarea__inner {
|
||||
padding: 5px 11px;
|
||||
}
|
||||
|
||||
/* dialog滚动条-s */
|
||||
.el-overlay-dialog,
|
||||
.ba-scroll-style {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #eaeaea;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
}
|
||||
&:hover {
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #c8c9cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* dialog滚动条-e */
|
||||
|
||||
/* 小屏设备 el-radio-group 样式优化-s */
|
||||
.ba-input-item-radio {
|
||||
margin-bottom: 10px;
|
||||
.el-radio-group {
|
||||
.el-radio {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 小屏设备 el-radio-group 样式调整-e */
|
||||
3
src/styles/index.scss
Normal file
3
src/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@use '@/styles/app';
|
||||
@use '@/styles/element';
|
||||
@use '@/styles/var';
|
||||
30
src/styles/mixins.scss
Normal file
30
src/styles/mixins.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@mixin set-css-var-value($name, $value) {
|
||||
#{joinVarName($name)}: #{$value};
|
||||
}
|
||||
|
||||
@function joinVarName($list) {
|
||||
$name: '--ba';
|
||||
@each $item in $list {
|
||||
@if $item != '' {
|
||||
$name: $name + '-' + $item;
|
||||
}
|
||||
}
|
||||
@return $name;
|
||||
}
|
||||
|
||||
@function getCssVarName($args...) {
|
||||
@return joinVarName($args);
|
||||
}
|
||||
|
||||
/*
|
||||
* 通过映射设置所有的CSS变量
|
||||
*/
|
||||
@mixin set-component-css-var($name, $variables) {
|
||||
@each $attribute, $value in $variables {
|
||||
@if $attribute == 'default' {
|
||||
#{getCssVarName($name)}: #{$value};
|
||||
} @else {
|
||||
#{getCssVarName($name, $attribute)}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/styles/var.scss
Normal file
32
src/styles/var.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@use 'sass:map';
|
||||
@use 'mixins' as *;
|
||||
|
||||
// 后台主体窗口左右间距
|
||||
$main-space: 16px;
|
||||
$primary-light: #3f6ad8;
|
||||
|
||||
// --ba-background
|
||||
$bg-color: () !default;
|
||||
$bg-color: map.merge(
|
||||
(
|
||||
'': #f5f5f5,
|
||||
'overlay': #ffffff,
|
||||
),
|
||||
$bg-color
|
||||
);
|
||||
|
||||
// --ba-border-color
|
||||
$border-color: () !default;
|
||||
$border-color: map.merge(
|
||||
(
|
||||
'': #f6f6f6,
|
||||
),
|
||||
$border-color
|
||||
);
|
||||
|
||||
:root {
|
||||
@include set-css-var-value('main-space', $main-space);
|
||||
@include set-css-var-value('color-primary-light', $primary-light);
|
||||
@include set-component-css-var('bg-color', $bg-color);
|
||||
@include set-component-css-var('border-color', $border-color);
|
||||
}
|
||||
65
src/utils/common.ts
Normal file
65
src/utils/common.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { App } from 'vue'
|
||||
import { adminBaseRoutePath } from '@/router/static'
|
||||
import router from '@/router/index'
|
||||
import { trimStart } from 'lodash-es'
|
||||
import * as elIcons from '@element-plus/icons-vue'
|
||||
import Icon from '@/components/icon/index.vue'
|
||||
|
||||
export function registerIcons(app: App) {
|
||||
/*
|
||||
* 全局注册 Icon
|
||||
* 使用方式: <Icon name="name" size="size" color="color" />
|
||||
* 详见<待完善>
|
||||
*/
|
||||
app.component('Icon', Icon)
|
||||
|
||||
/*
|
||||
* 全局注册element Plus的icon
|
||||
*/
|
||||
const icons = elIcons as any
|
||||
for (const i in icons) {
|
||||
app.component(`el-icon-${icons[i].name}`, icons[i])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否在后台应用内
|
||||
* @param path 不传递则通过当前路由 path 检查
|
||||
*/
|
||||
export const isAdminApp = (path = '') => {
|
||||
const regex = new RegExp(`^${adminBaseRoutePath}`)
|
||||
if (path) {
|
||||
return regex.test(path)
|
||||
}
|
||||
if (regex.test(getCurrentRoutePath())) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路由 path
|
||||
*/
|
||||
export const getCurrentRoutePath = () => {
|
||||
let path = router.currentRoute.value.path
|
||||
if (path == '/') path = trimStart(window.location.hash, '#')
|
||||
if (path.indexOf('?') !== -1) path = path.replace(/\?.*/, '')
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源完整地址
|
||||
* @param relativeUrl 资源相对地址
|
||||
* @param domain 指定域名
|
||||
*/
|
||||
export const fullUrl = (relativeUrl: string, domain = '') => {
|
||||
return domain + relativeUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是外部链接
|
||||
* @param path
|
||||
*/
|
||||
export function isExternal(path: string): boolean {
|
||||
return /^(https?|ftp|mailto|tel):/.test(path)
|
||||
}
|
||||
33
src/utils/horizontalScroll.ts
Normal file
33
src/utils/horizontalScroll.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 横向滚动条
|
||||
*/
|
||||
export default class horizontalScroll {
|
||||
private el: HTMLElement
|
||||
|
||||
constructor(nativeElement: HTMLElement) {
|
||||
this.el = nativeElement
|
||||
this.handleWheelEvent()
|
||||
}
|
||||
|
||||
handleWheelEvent() {
|
||||
let wheel = ''
|
||||
|
||||
if ('onmousewheel' in this.el) {
|
||||
wheel = 'mousewheel'
|
||||
} else if ('onwheel' in this.el) {
|
||||
wheel = 'wheel'
|
||||
} else if ('attachEvent' in window) {
|
||||
wheel = 'onmousewheel'
|
||||
} else {
|
||||
wheel = 'DOMMouseScroll'
|
||||
}
|
||||
this.el['addEventListener'](wheel, this.scroll, { passive: true })
|
||||
}
|
||||
|
||||
scroll = (event: any) => {
|
||||
if (this.el.clientWidth >= this.el.scrollWidth) {
|
||||
return
|
||||
}
|
||||
this.el.scrollLeft += event.deltaY ? event.deltaY : event.detail && event.detail !== 0 ? event.detail : -event.wheelDelta
|
||||
}
|
||||
}
|
||||
21
src/utils/iconfont.ts
Normal file
21
src/utils/iconfont.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { nextTick } from 'vue'
|
||||
import * as elIcons from '@element-plus/icons-vue'
|
||||
/*
|
||||
* 获取element plus 自带的图标
|
||||
*/
|
||||
export function getElementPlusIconfontNames() {
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
nextTick(() => {
|
||||
const iconfonts = []
|
||||
const icons = elIcons as any
|
||||
for (const i in icons) {
|
||||
iconfonts.push(`el-icon-${icons[i].name}`)
|
||||
}
|
||||
if (iconfonts.length > 0) {
|
||||
resolve(iconfonts)
|
||||
} else {
|
||||
reject('No ElementPlus Icons')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
41
src/utils/layout.ts
Normal file
41
src/utils/layout.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { useConfig } from '@/stores/config'
|
||||
|
||||
/**
|
||||
* main高度
|
||||
* @param extra main高度额外减去的px数,可以实现隐藏原有的滚动条
|
||||
* @returns CSSProperties
|
||||
*/
|
||||
export function mainHeight(extra = 0): CSSProperties {
|
||||
let height = extra
|
||||
const adminLayoutMainExtraHeight: anyObj = {
|
||||
Default: 70,
|
||||
Classic: 50,
|
||||
Streamline: 60
|
||||
}
|
||||
const config = useConfig()
|
||||
const navTabs = useNavTabs()
|
||||
if (!navTabs.state.tabFullScreen) {
|
||||
height += adminLayoutMainExtraHeight[config.layout.layoutMode]
|
||||
}
|
||||
|
||||
return {
|
||||
height: 'calc(100vh - ' + height.toString() + 'px)'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置导航栏宽度
|
||||
* @returns
|
||||
*/
|
||||
export function setNavTabsWidth() {
|
||||
const navTabs = document.querySelector('.nav-tabs') as HTMLElement
|
||||
if (!navTabs) {
|
||||
return
|
||||
}
|
||||
const navBar = document.querySelector('.nav-bar') as HTMLElement
|
||||
const navMenus = document.querySelector('.nav-menus') as HTMLElement
|
||||
const minWidth = navBar.offsetWidth - (navMenus.offsetWidth + 20)
|
||||
navTabs.style.width = minWidth.toString() + 'px'
|
||||
}
|
||||
22
src/utils/pageShade.ts
Normal file
22
src/utils/pageShade.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
/*
|
||||
* 显示页面遮罩
|
||||
*/
|
||||
export const showShade = function (className = 'shade', closeCallBack: Function): void {
|
||||
const containerEl = document.querySelector('.layout-container') as HTMLElement
|
||||
const shadeDiv = document.createElement('div')
|
||||
shadeDiv.setAttribute('class', 'ba-layout-shade ' + className)
|
||||
containerEl.appendChild(shadeDiv)
|
||||
useEventListener(shadeDiv, 'click', () => closeShade(closeCallBack))
|
||||
}
|
||||
|
||||
/*
|
||||
* 隐藏页面遮罩
|
||||
*/
|
||||
export const closeShade = function (closeCallBack: Function = () => {}): void {
|
||||
const shadeEl = document.querySelector('.ba-layout-shade') as HTMLElement
|
||||
shadeEl && shadeEl.remove()
|
||||
|
||||
closeCallBack()
|
||||
}
|
||||
57
src/utils/random.ts
Normal file
57
src/utils/random.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const hexList: string[] = []
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
hexList[i] = i.toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机数
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 生成的随机数
|
||||
*/
|
||||
export function randomNum(min: number, max: number) {
|
||||
switch (arguments.length) {
|
||||
case 1:
|
||||
return parseInt((Math.random() * min + 1).toString(), 10)
|
||||
break
|
||||
case 2:
|
||||
return parseInt((Math.random() * (max - min + 1) + min).toString(), 10)
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成全球唯一标识
|
||||
* @returns uuid
|
||||
*/
|
||||
export function uuid(): string {
|
||||
let uuid = ''
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
if (i === 9 || i === 14 || i === 19 || i === 24) {
|
||||
uuid += '-'
|
||||
} else if (i === 15) {
|
||||
uuid += 4
|
||||
} else if (i === 20) {
|
||||
uuid += hexList[(Math.random() * 4) | 8]
|
||||
} else {
|
||||
uuid += hexList[(Math.random() * 16) | 0]
|
||||
}
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一标识
|
||||
* @param prefix 前缀
|
||||
* @returns 唯一标识
|
||||
*/
|
||||
export function shortUuid(prefix = ''): string {
|
||||
const time = Date.now()
|
||||
const random = Math.floor(Math.random() * 1000000000)
|
||||
if (!window.unique) window.unique = 0
|
||||
window.unique++
|
||||
return prefix + '_' + random + window.unique + String(time)
|
||||
}
|
||||
295
src/utils/router.ts
Normal file
295
src/utils/router.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import router from '@/router/index'
|
||||
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
|
||||
import type { RouteRecordRaw, RouteLocationRaw } from 'vue-router'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { useConfig } from '@/stores/config'
|
||||
import { useNavTabs } from '@/stores/navTabs'
|
||||
import { closeShade } from '@/utils/pageShade'
|
||||
import { adminBaseRoute } from '@/router/static'
|
||||
import { compact, isEmpty, reverse } from 'lodash-es'
|
||||
import { isAdminApp } from '@/utils/common'
|
||||
|
||||
/**
|
||||
* 导航失败有错误消息的路由push
|
||||
* @param to — 导航位置,同 router.push
|
||||
*/
|
||||
export const routePush = async (to: RouteLocationRaw) => {
|
||||
try {
|
||||
const failure = await router.push(to)
|
||||
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
|
||||
ElNotification({
|
||||
message: 'utils.Navigation failed, navigation guard intercepted!',
|
||||
type: 'error'
|
||||
})
|
||||
} else if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
ElNotification({
|
||||
message: 'utils.Navigation failed, it is at the navigation target position!',
|
||||
type: 'warning'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
ElNotification({
|
||||
message: 'utils.Navigation failed, invalid route!',
|
||||
type: 'error'
|
||||
})
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取第一个菜单
|
||||
*/
|
||||
export const getFirstRoute = (routes: RouteRecordRaw[], menuType = 'tab'): false | RouteRecordRaw => {
|
||||
const routerPaths: string[] = []
|
||||
const routers = router.getRoutes()
|
||||
routers.forEach(item => {
|
||||
if (item.path) routerPaths.push(item.path)
|
||||
})
|
||||
let find: boolean | RouteRecordRaw = false
|
||||
for (const key in routes) {
|
||||
if (
|
||||
routes[key].meta?.type == 'menu' &&
|
||||
routes[key].meta?.menu_type == menuType &&
|
||||
routerPaths.indexOf(routes[key].path) !== -1
|
||||
) {
|
||||
return routes[key]
|
||||
} else if (routes[key].children && routes[key].children?.length) {
|
||||
find = getFirstRoute(routes[key].children!)
|
||||
if (find) return find
|
||||
}
|
||||
}
|
||||
return find
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开侧边菜单
|
||||
* @param menu 菜单数据
|
||||
*/
|
||||
export const onClickMenu = (menu: RouteRecordRaw) => {
|
||||
switch (menu.meta?.menu_type) {
|
||||
case 'iframe':
|
||||
case 'tab':
|
||||
routePush({ path: menu.path })
|
||||
break
|
||||
case 'link':
|
||||
window.open(menu.path, '_blank')
|
||||
break
|
||||
|
||||
default:
|
||||
ElNotification({
|
||||
message: 'utils.Navigation failed, the menu type is unrecognized!',
|
||||
type: 'error'
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const config = useConfig()
|
||||
if (config.layout.shrink) {
|
||||
closeShade(() => {
|
||||
config.setLayout('menuCollapse', true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理后台的路由
|
||||
*/
|
||||
export const handleAdminRoute = (routes: any) => {
|
||||
const viewsComponent = import.meta.glob('/src/views/**/*.vue')
|
||||
addRouteAll(viewsComponent, routes, adminBaseRoute.name as string)
|
||||
const menuAdminBaseRoute = (adminBaseRoute.path as string) + '/'
|
||||
|
||||
// 更新stores中的路由菜单数据
|
||||
const navTabs = useNavTabs()
|
||||
navTabs.setTabsViewRoutes(handleMenuRule(routes, menuAdminBaseRoute))
|
||||
navTabs.fillAuthNode(handleAuthNode(routes, menuAdminBaseRoute))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单的paths
|
||||
*/
|
||||
export const getMenuPaths = (menus: RouteRecordRaw[]): string[] => {
|
||||
let menuPaths: string[] = []
|
||||
menus.forEach(item => {
|
||||
menuPaths.push(item.path)
|
||||
if (item.children && item.children.length > 0) {
|
||||
menuPaths = menuPaths.concat(getMenuPaths(item.children))
|
||||
}
|
||||
})
|
||||
return menuPaths
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台的菜单处理
|
||||
*/
|
||||
const handleMenuRule = (routes: any, pathPrefix = '/', type = ['menu', 'menu_dir']) => {
|
||||
const menuRule: RouteRecordRaw[] = []
|
||||
for (const key in routes) {
|
||||
if (routes[key].extend == 'add_rules_only') {
|
||||
continue
|
||||
}
|
||||
if (!type.includes(routes[key].type)) {
|
||||
continue
|
||||
}
|
||||
if (routes[key].type == 'menu_dir' && routes[key].children && !routes[key].children.length) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
['route', 'menu', 'nav_user_menu', 'nav'].includes(routes[key].type) &&
|
||||
((routes[key].menu_type == 'tab' && !routes[key].component) ||
|
||||
(['link', 'iframe'].includes(routes[key].menu_type) && !routes[key].url))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const currentPath = ['link', 'iframe'].includes(routes[key].menu_type)
|
||||
? routes[key].url
|
||||
: pathPrefix + routes[key].path
|
||||
let children: RouteRecordRaw[] = []
|
||||
if (routes[key].children && routes[key].children.length > 0) {
|
||||
children = handleMenuRule(routes[key].children, pathPrefix, type)
|
||||
}
|
||||
menuRule.push({
|
||||
path: currentPath,
|
||||
name: routes[key].name,
|
||||
component: routes[key].component,
|
||||
meta: {
|
||||
id: routes[key].id,
|
||||
title: routes[key].title,
|
||||
icon: routes[key].icon,
|
||||
keepalive: routes[key].keepalive,
|
||||
menu_type: routes[key].menu_type,
|
||||
type: routes[key].type
|
||||
},
|
||||
children: children
|
||||
})
|
||||
}
|
||||
return menuRule
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理权限节点
|
||||
* @param routes 路由数据
|
||||
* @param prefix 节点前缀
|
||||
* @returns 组装好的权限节点
|
||||
*/
|
||||
const handleAuthNode = (routes: any, prefix = '/') => {
|
||||
const authNode: Map<string, string[]> = new Map([])
|
||||
assembleAuthNode(routes, authNode, prefix, prefix)
|
||||
return authNode
|
||||
}
|
||||
const assembleAuthNode = (routes: any, authNode: Map<string, string[]>, prefix = '/', parent = '/') => {
|
||||
const authNodeTemp = []
|
||||
for (const key in routes) {
|
||||
if (routes[key].type == 'button') authNodeTemp.push(prefix + routes[key].name)
|
||||
if (routes[key].children && routes[key].children.length > 0) {
|
||||
assembleAuthNode(routes[key].children, authNode, prefix, prefix + routes[key].name)
|
||||
}
|
||||
}
|
||||
if (authNodeTemp && authNodeTemp.length > 0) {
|
||||
authNode.set(parent, authNodeTemp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态添加路由-带子路由
|
||||
* @param viewsComponent
|
||||
* @param routes
|
||||
* @param parentName
|
||||
* @param analyticRelation 根据 name 从已注册路由分析父级路由
|
||||
*/
|
||||
export const addRouteAll = (
|
||||
viewsComponent: Record<string, any>,
|
||||
routes: any,
|
||||
parentName: string,
|
||||
analyticRelation = false
|
||||
) => {
|
||||
for (const idx in routes) {
|
||||
if (routes[idx].extend == 'add_menu_only') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
(routes[idx].menu_type == 'tab' && viewsComponent[routes[idx].component]) ||
|
||||
routes[idx].menu_type == 'iframe'
|
||||
) {
|
||||
addRouteItem(viewsComponent, routes[idx], parentName, analyticRelation)
|
||||
}
|
||||
|
||||
if (routes[idx].children && routes[idx].children.length > 0) {
|
||||
addRouteAll(viewsComponent, routes[idx].children, parentName, analyticRelation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态添加路由
|
||||
* @param viewsComponent
|
||||
* @param route
|
||||
* @param parentName
|
||||
* @param analyticRelation 根据 name 从已注册路由分析父级路由
|
||||
*/
|
||||
export const addRouteItem = (
|
||||
viewsComponent: Record<string, any>,
|
||||
route: any,
|
||||
parentName: string,
|
||||
analyticRelation: boolean
|
||||
) => {
|
||||
let path = '',
|
||||
component
|
||||
if (route.menu_type == 'iframe') {
|
||||
path = (isAdminApp() ? adminBaseRoute.path : '') + '/iframe/' + encodeURIComponent(route.url)
|
||||
component = () => import('@/layouts/common/router-view/iframe.vue')
|
||||
} else {
|
||||
path = parentName ? route.path : '/' + route.path
|
||||
component = viewsComponent[route.component]
|
||||
}
|
||||
|
||||
if (route.menu_type == 'tab' && analyticRelation) {
|
||||
const parentNames = getParentNames(route.name)
|
||||
if (parentNames.length) {
|
||||
for (const key in parentNames) {
|
||||
if (router.hasRoute(parentNames[key])) {
|
||||
parentName = parentNames[key]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeBaseInfo: RouteRecordRaw = {
|
||||
path: path,
|
||||
name: route.name,
|
||||
component: component,
|
||||
meta: {
|
||||
title: route.title,
|
||||
extend: route.extend,
|
||||
icon: route.icon,
|
||||
keepalive: route.keepalive,
|
||||
menu_type: route.menu_type,
|
||||
type: route.type,
|
||||
url: route.url,
|
||||
addtab: true
|
||||
}
|
||||
}
|
||||
if (parentName) {
|
||||
router.addRoute(parentName, routeBaseInfo)
|
||||
} else {
|
||||
router.addRoute(routeBaseInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据name字符串,获取父级name组合的数组
|
||||
* @param name
|
||||
*/
|
||||
const getParentNames = (name: string) => {
|
||||
const names = compact(name.split('/'))
|
||||
const tempNames = []
|
||||
const parentNames = []
|
||||
for (const key in names) {
|
||||
tempNames.push(names[key])
|
||||
if (parseInt(key) != names.length - 1) {
|
||||
parentNames.push(tempNames.join('/'))
|
||||
}
|
||||
}
|
||||
return reverse(parentNames)
|
||||
}
|
||||
45
src/utils/storage.ts
Normal file
45
src/utils/storage.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* window.localStorage
|
||||
* @method set 设置
|
||||
* @method get 获取
|
||||
* @method remove 移除
|
||||
* @method clear 移除全部
|
||||
*/
|
||||
export const Local = {
|
||||
set(key: string, val: any) {
|
||||
window.localStorage.setItem(key, JSON.stringify(val))
|
||||
},
|
||||
get(key: string) {
|
||||
const json: any = window.localStorage.getItem(key)
|
||||
return JSON.parse(json)
|
||||
},
|
||||
remove(key: string) {
|
||||
window.localStorage.removeItem(key)
|
||||
},
|
||||
clear() {
|
||||
window.localStorage.clear()
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* window.sessionStorage
|
||||
* @method set 设置会话缓存
|
||||
* @method get 获取会话缓存
|
||||
* @method remove 移除会话缓存
|
||||
* @method clear 移除全部会话缓存
|
||||
*/
|
||||
export const Session = {
|
||||
set(key: string, val: any) {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(val))
|
||||
},
|
||||
get(key: string) {
|
||||
const json: any = window.sessionStorage.getItem(key)
|
||||
return JSON.parse(json)
|
||||
},
|
||||
remove(key: string) {
|
||||
window.sessionStorage.removeItem(key)
|
||||
},
|
||||
clear() {
|
||||
window.sessionStorage.clear()
|
||||
},
|
||||
}
|
||||
13
src/utils/useCurrentInstance.ts
Normal file
13
src/utils/useCurrentInstance.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import type { ComponentInternalInstance } from 'vue'
|
||||
|
||||
export default function useCurrentInstance() {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useCurrentInstance() can only be used inside setup() or functional components!')
|
||||
}
|
||||
const { appContext } = getCurrentInstance() as ComponentInternalInstance
|
||||
const proxy = appContext.config.globalProperties
|
||||
return {
|
||||
proxy,
|
||||
}
|
||||
}
|
||||
3
src/views/common/error/404.vue
Normal file
3
src/views/common/error/404.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
404
|
||||
</template>
|
||||
78
src/views/dashboard/index.vue
Normal file
78
src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="Activity name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity zone">
|
||||
<el-select v-model="form.region" placeholder="please select your zone">
|
||||
<el-option label="Zone one" value="shanghai" />
|
||||
<el-option label="Zone two" value="beijing" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity time">
|
||||
<el-col :span="11">
|
||||
<el-date-picker
|
||||
v-model="form.date1"
|
||||
type="date"
|
||||
placeholder="Pick a date"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="2" class="text-center">
|
||||
<span class="text-gray-500">-</span>
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-time-picker
|
||||
v-model="form.date2"
|
||||
placeholder="Pick a time"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Instant delivery">
|
||||
<el-switch v-model="form.delivery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity type">
|
||||
<el-checkbox-group v-model="form.type">
|
||||
<el-checkbox label="Online activities" name="type" />
|
||||
<el-checkbox label="Promotion activities" name="type" />
|
||||
<el-checkbox label="Offline activities" name="type" />
|
||||
<el-checkbox label="Simple brand exposure" name="type" />
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="Resources">
|
||||
<el-radio-group v-model="form.resource">
|
||||
<el-radio label="Sponsor" />
|
||||
<el-radio label="Venue" />
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity form">
|
||||
<el-input v-model="form.desc" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSubmit">Create</el-button>
|
||||
<el-button>Cancel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive } from 'vue'
|
||||
|
||||
// do not use same name with ref
|
||||
const form = reactive({
|
||||
name: '',
|
||||
region: '',
|
||||
date1: '',
|
||||
date2: '',
|
||||
delivery: false,
|
||||
type: [],
|
||||
resource: '',
|
||||
desc: '',
|
||||
})
|
||||
|
||||
const onSubmit = () => {
|
||||
console.log('submit!')
|
||||
}
|
||||
</script>
|
||||
|
||||
1
src/views/user/login.vue
Normal file
1
src/views/user/login.vue
Normal file
@@ -0,0 +1 @@
|
||||
<template>login</template>
|
||||
Reference in New Issue
Block a user