first commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"htmlWhitespaceSensitivity": "ignore"
|
||||||
|
}
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||||
|
}
|
||||||
18
README.md
Normal file
18
README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support For `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Vue + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "canneng-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vueuse/core": "^10.7.0",
|
||||||
|
"element-plus": "^2.4.4",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
|
"screenfull": "^6.0.2",
|
||||||
|
"vue": "^3.3.11",
|
||||||
|
"vue-router": "4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
"sass": "^1.69.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8",
|
||||||
|
"vue-tsc": "^1.8.25"
|
||||||
|
}
|
||||||
|
}
|
||||||
1081
pnpm-lock.yaml
generated
Normal file
1081
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/vite.svg
Normal file
1
public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
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>
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"removeComments": false,
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"sourceMap": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["element-plus/global"]
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
28
types/global.d.ts
vendored
Normal file
28
types/global.d.ts
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
interface Window {
|
||||||
|
existLoading: boolean
|
||||||
|
lazy: number
|
||||||
|
unique: number
|
||||||
|
tokenRefreshing: boolean
|
||||||
|
requests: Function[]
|
||||||
|
eventSource: EventSource
|
||||||
|
loadLangHandle: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface anyObj {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableDefaultData<T = any> {
|
||||||
|
list: T
|
||||||
|
remark: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
data: T
|
||||||
|
msg: string
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiPromise<T = any> = Promise<ApiResponse<T>>
|
||||||
7
types/vite-env.d.ts
vendored
Normal file
7
types/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
const nodeResolve = (dir: string) => path.resolve(__dirname, '.', dir)
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': nodeResolve('src'),
|
||||||
|
'~': nodeResolve('public'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user