first commit

This commit is contained in:
仲么了
2023-12-21 16:42:39 +08:00
commit 0f7b59f55b
79 changed files with 7638 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

18
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
public/vite.svg Normal file
View 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
View File

@@ -0,0 +1,4 @@
<template>
<router-view></router-view>
</template>
<script setup lang="ts"></script>

1
src/assets/vue.svg Normal file
View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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[]
}
}

View 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 // 自动递增
}
}

View 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>

View 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>

View 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[]
}

View 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>

View 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 */
},
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
}
}
)

View 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
View File

@@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
@use '@/styles/app';
@use '@/styles/element';
@use '@/styles/var';

30
src/styles/mixins.scss Normal file
View 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
View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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()
},
}

View 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,
}
}

View File

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

View 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
View File

@@ -0,0 +1 @@
<template>login</template>

22
tsconfig.json Normal file
View 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
View 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
View 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
View 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'),
}
}
})