feature/20260628/refactor-frontend-modernization
parent
90ac382237
commit
b77957c16e
@ -0,0 +1,375 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-tabs type="border-card">
|
||||||
|
<el-tab-pane label="秒" v-if="shouldHide('second')">
|
||||||
|
<CrontabSecond
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
ref="cronsecondRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="分钟" v-if="shouldHide('min')">
|
||||||
|
<CrontabMin
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
ref="cronminRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="小时" v-if="shouldHide('hour')">
|
||||||
|
<CrontabHour
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
ref="cronhourRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="日" v-if="shouldHide('day')">
|
||||||
|
<CrontabDay
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
:cron="crontabValueObj"
|
||||||
|
ref="crondayRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="月" v-if="shouldHide('month')">
|
||||||
|
<CrontabMonth
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
ref="cronmonthRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="周" v-if="shouldHide('week')">
|
||||||
|
<CrontabWeek
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
:cron="crontabValueObj"
|
||||||
|
ref="cronweekRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="年" v-if="shouldHide('year')">
|
||||||
|
<CrontabYear
|
||||||
|
@update="updateCrontabValue"
|
||||||
|
:check="checkNumber"
|
||||||
|
ref="cronyearRef"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<div class="popup-main">
|
||||||
|
<div class="popup-result">
|
||||||
|
<p class="title">时间表达式</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<th v-for="item of tabTitles" :key="item" width="40">{{ item }}</th>
|
||||||
|
<th>Cron 表达式</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<td><span>{{ crontabValueObj.second }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.min }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.hour }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.day }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.month }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.week }}</span></td>
|
||||||
|
<td><span>{{ crontabValueObj.year }}</span></td>
|
||||||
|
<td><span>{{ crontabValueString }}</span></td>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<CrontabResult :ex="crontabValueString" />
|
||||||
|
|
||||||
|
<div class="pop_btn">
|
||||||
|
<el-button size="small" type="primary" @click="submitFill">确定</el-button>
|
||||||
|
<el-button size="small" type="warning" @click="clearCron">重置</el-button>
|
||||||
|
<el-button size="small" @click="hidePopup">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import CrontabSecond from './second.vue'
|
||||||
|
import CrontabMin from './min.vue'
|
||||||
|
import CrontabHour from './hour.vue'
|
||||||
|
import CrontabDay from './day.vue'
|
||||||
|
import CrontabMonth from './month.vue'
|
||||||
|
import CrontabWeek from './week.vue'
|
||||||
|
import CrontabYear from './year.vue'
|
||||||
|
import CrontabResult from './result.vue'
|
||||||
|
import type { CrontabValueObj } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
expression?: string
|
||||||
|
hideComponent?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
expression: '',
|
||||||
|
hideComponent: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
hide: []
|
||||||
|
fill: [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tabTitles = ['秒', '分钟', '小时', '日', '月', '周', '年']
|
||||||
|
|
||||||
|
const crontabValueObj = ref<CrontabValueObj>({
|
||||||
|
second: '*',
|
||||||
|
min: '*',
|
||||||
|
hour: '*',
|
||||||
|
day: '*',
|
||||||
|
month: '*',
|
||||||
|
week: '?',
|
||||||
|
year: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const cronsecondRef = ref<any>(null)
|
||||||
|
const cronminRef = ref<any>(null)
|
||||||
|
const cronhourRef = ref<any>(null)
|
||||||
|
const crondayRef = ref<any>(null)
|
||||||
|
const cronmonthRef = ref<any>(null)
|
||||||
|
const cronweekRef = ref<any>(null)
|
||||||
|
const cronyearRef = ref<any>(null)
|
||||||
|
|
||||||
|
const crontabValueString = computed(() => {
|
||||||
|
const obj = crontabValueObj.value
|
||||||
|
return `${obj.second} ${obj.min} ${obj.hour} ${obj.day} ${obj.month} ${obj.week}${obj.year === '' ? '' : ' ' + obj.year}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function shouldHide(key: string): boolean {
|
||||||
|
if (props.hideComponent && props.hideComponent.includes(key)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNumber(value: number, minLimit: number, maxLimit: number): number {
|
||||||
|
value = Math.floor(value)
|
||||||
|
if (value < minLimit) value = minLimit
|
||||||
|
else if (value > maxLimit) value = maxLimit
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCrontabValue(name: string, value: string, from?: string) {
|
||||||
|
crontabValueObj.value[name as keyof CrontabValueObj] = value
|
||||||
|
if (from && from !== name) {
|
||||||
|
changeRadio(name, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeRadio(name: string, value: string) {
|
||||||
|
const arr = ['second', 'min', 'hour', 'month']
|
||||||
|
const refName = 'cron' + name
|
||||||
|
|
||||||
|
const refMap: Record<string, any> = {
|
||||||
|
cronsecond: cronsecondRef.value,
|
||||||
|
cronmin: cronminRef.value,
|
||||||
|
cronhour: cronhourRef.value,
|
||||||
|
cronday: crondayRef.value,
|
||||||
|
cronmonth: cronmonthRef.value,
|
||||||
|
cronweek: cronweekRef.value,
|
||||||
|
cronyear: cronyearRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const compRef = refMap[refName]
|
||||||
|
if (!compRef) return
|
||||||
|
|
||||||
|
let insValue = 1
|
||||||
|
|
||||||
|
if (arr.includes(name)) {
|
||||||
|
if (value === '*') {
|
||||||
|
insValue = 1
|
||||||
|
} else if (value.includes('-')) {
|
||||||
|
const indexArr = value.split('-')
|
||||||
|
compRef.cycle01 = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
compRef.cycle02 = Number(indexArr[1])
|
||||||
|
insValue = 2
|
||||||
|
} else if (value.includes('/')) {
|
||||||
|
const indexArr = value.split('/')
|
||||||
|
compRef.average01 = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
compRef.average02 = Number(indexArr[1])
|
||||||
|
insValue = 3
|
||||||
|
} else {
|
||||||
|
insValue = 4
|
||||||
|
compRef.checkboxList = value.split(',').map(Number)
|
||||||
|
}
|
||||||
|
} else if (name === 'day') {
|
||||||
|
if (value === '*') {
|
||||||
|
insValue = 1
|
||||||
|
} else if (value === '?') {
|
||||||
|
insValue = 2
|
||||||
|
} else if (value.includes('-')) {
|
||||||
|
const indexArr = value.split('-')
|
||||||
|
compRef.cycle01 = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
compRef.cycle02 = Number(indexArr[1])
|
||||||
|
insValue = 3
|
||||||
|
} else if (value.includes('/')) {
|
||||||
|
const indexArr = value.split('/')
|
||||||
|
compRef.average01 = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
compRef.average02 = Number(indexArr[1])
|
||||||
|
insValue = 4
|
||||||
|
} else if (value.includes('W')) {
|
||||||
|
const indexArr = value.split('W')
|
||||||
|
compRef.workday = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
insValue = 5
|
||||||
|
} else if (value === 'L') {
|
||||||
|
insValue = 6
|
||||||
|
} else {
|
||||||
|
compRef.checkboxList = value.split(',').map(Number)
|
||||||
|
insValue = 7
|
||||||
|
}
|
||||||
|
} else if (name === 'week') {
|
||||||
|
if (value === '*') {
|
||||||
|
insValue = 1
|
||||||
|
} else if (value === '?') {
|
||||||
|
insValue = 2
|
||||||
|
} else if (value.includes('-')) {
|
||||||
|
const indexArr = value.split('-')
|
||||||
|
compRef.cycle01 = isNaN(Number(indexArr[0])) ? 0 : Number(indexArr[0])
|
||||||
|
compRef.cycle02 = Number(indexArr[1])
|
||||||
|
insValue = 3
|
||||||
|
} else if (value.includes('#')) {
|
||||||
|
const indexArr = value.split('#')
|
||||||
|
compRef.average01 = isNaN(Number(indexArr[0])) ? 1 : Number(indexArr[0])
|
||||||
|
compRef.average02 = Number(indexArr[1])
|
||||||
|
insValue = 4
|
||||||
|
} else if (value.includes('L')) {
|
||||||
|
const indexArr = value.split('L')
|
||||||
|
compRef.weekday = isNaN(Number(indexArr[0])) ? 1 : Number(indexArr[0])
|
||||||
|
insValue = 5
|
||||||
|
} else {
|
||||||
|
compRef.checkboxList = value.split(',')
|
||||||
|
insValue = 6
|
||||||
|
}
|
||||||
|
} else if (name === 'year') {
|
||||||
|
if (value === '') {
|
||||||
|
insValue = 1
|
||||||
|
} else if (value === '*') {
|
||||||
|
insValue = 2
|
||||||
|
} else if (value.includes('-')) {
|
||||||
|
insValue = 3
|
||||||
|
} else if (value.includes('/')) {
|
||||||
|
insValue = 4
|
||||||
|
} else {
|
||||||
|
compRef.checkboxList = value.split(',').map(Number)
|
||||||
|
insValue = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compRef.setRadioValue(insValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExp() {
|
||||||
|
if (props.expression) {
|
||||||
|
const arr = props.expression.split(' ')
|
||||||
|
if (arr.length >= 6) {
|
||||||
|
const obj = {
|
||||||
|
second: arr[0],
|
||||||
|
min: arr[1],
|
||||||
|
hour: arr[2],
|
||||||
|
day: arr[3],
|
||||||
|
month: arr[4],
|
||||||
|
week: arr[5],
|
||||||
|
year: arr[6] || ''
|
||||||
|
}
|
||||||
|
crontabValueObj.value = { ...obj }
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj[key as keyof typeof obj]) {
|
||||||
|
changeRadio(key, obj[key as keyof typeof obj])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearCron()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFill() {
|
||||||
|
emit('fill', crontabValueString.value)
|
||||||
|
hidePopup()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCron() {
|
||||||
|
crontabValueObj.value = {
|
||||||
|
second: '*',
|
||||||
|
min: '*',
|
||||||
|
hour: '*',
|
||||||
|
day: '*',
|
||||||
|
month: '*',
|
||||||
|
week: '?',
|
||||||
|
year: ''
|
||||||
|
}
|
||||||
|
for (const key in crontabValueObj.value) {
|
||||||
|
changeRadio(key, crontabValueObj.value[key as keyof CrontabValueObj])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePopup() {
|
||||||
|
emit('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.expression, resolveExp)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resolveExp()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pop_btn {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-main {
|
||||||
|
position: relative;
|
||||||
|
margin: 10px auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result {
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 25px auto;
|
||||||
|
padding: 15px 10px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result .title {
|
||||||
|
position: absolute;
|
||||||
|
top: -28px;
|
||||||
|
left: 50%;
|
||||||
|
width: 140px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: -70px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 30px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result table {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result table span {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-family: arial;
|
||||||
|
line-height: 30px;
|
||||||
|
height: 30px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<div class="popup-result">
|
||||||
|
<p class="title">最近5次运行时间</p>
|
||||||
|
<ul class="popup-result-scroll">
|
||||||
|
<template v-if="isShow">
|
||||||
|
<li v-for="item in resultList" :key="item">{{ item }}</li>
|
||||||
|
</template>
|
||||||
|
<li v-else>计算结果中...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ex: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const dayRule = ref('')
|
||||||
|
const dayRuleSup = ref<any>('')
|
||||||
|
const dateArr = ref<any[][]>([])
|
||||||
|
const resultList = ref<string[]>([])
|
||||||
|
const isShow = ref(false)
|
||||||
|
|
||||||
|
function expressionChange() {
|
||||||
|
isShow.value = false
|
||||||
|
|
||||||
|
const ruleArr = props.ex.split(' ')
|
||||||
|
let nums = 0
|
||||||
|
const resultArr: string[] = []
|
||||||
|
|
||||||
|
const nTime = new Date()
|
||||||
|
let nYear = nTime.getFullYear()
|
||||||
|
let nMonth = nTime.getMonth() + 1
|
||||||
|
let nDay = nTime.getDate()
|
||||||
|
let nHour = nTime.getHours()
|
||||||
|
let nMin = nTime.getMinutes()
|
||||||
|
let nSecond = nTime.getSeconds()
|
||||||
|
|
||||||
|
getSecondArr(ruleArr[0])
|
||||||
|
getMinArr(ruleArr[1])
|
||||||
|
getHourArr(ruleArr[2])
|
||||||
|
getDayArr(ruleArr[3])
|
||||||
|
getMonthArr(ruleArr[4])
|
||||||
|
getWeekArr(ruleArr[5])
|
||||||
|
getYearArr(ruleArr[6], nYear)
|
||||||
|
|
||||||
|
const sDate = dateArr.value[0] || []
|
||||||
|
const mDate = dateArr.value[1] || []
|
||||||
|
const hDate = dateArr.value[2] || []
|
||||||
|
const DDate = dateArr.value[3] || []
|
||||||
|
const MDate = dateArr.value[4] || []
|
||||||
|
const YDate = dateArr.value[5] || []
|
||||||
|
|
||||||
|
let sIdx = getIndex(sDate, nSecond)
|
||||||
|
let mIdx = getIndex(mDate, nMin)
|
||||||
|
let hIdx = getIndex(hDate, nHour)
|
||||||
|
let DIdx = getIndex(DDate, nDay)
|
||||||
|
let MIdx = getIndex(MDate, nMonth)
|
||||||
|
let YIdx = getIndex(YDate, nYear)
|
||||||
|
|
||||||
|
const resetSecond = () => { sIdx = 0; nSecond = sDate[sIdx] }
|
||||||
|
const resetMin = () => { mIdx = 0; nMin = mDate[mIdx]; resetSecond() }
|
||||||
|
const resetHour = () => { hIdx = 0; nHour = hDate[hIdx]; resetMin() }
|
||||||
|
const resetDay = () => { DIdx = 0; nDay = DDate[DIdx]; resetHour() }
|
||||||
|
const resetMonth = () => { MIdx = 0; nMonth = MDate[MIdx]; resetDay() }
|
||||||
|
|
||||||
|
if (nYear !== YDate[YIdx]) resetMonth()
|
||||||
|
if (nMonth !== MDate[MIdx]) resetDay()
|
||||||
|
if (nDay !== DDate[DIdx]) resetHour()
|
||||||
|
if (nHour !== hDate[hIdx]) resetMin()
|
||||||
|
if (nMin !== mDate[mIdx]) resetSecond()
|
||||||
|
|
||||||
|
goYear: for (let Yi = YIdx; Yi < YDate.length; Yi++) {
|
||||||
|
const YY = YDate[Yi]
|
||||||
|
if (nMonth > MDate[MDate.length - 1]) { resetMonth(); continue }
|
||||||
|
|
||||||
|
goMonth: for (let Mi = MIdx; Mi < MDate.length; Mi++) {
|
||||||
|
let MM = MDate[Mi]
|
||||||
|
const mmStr = MM < 10 ? '0' + MM : String(MM)
|
||||||
|
if (nDay > DDate[DDate.length - 1]) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
goDay: for (let Di = DIdx; Di < DDate.length; Di++) {
|
||||||
|
let DD = DDate[Di]
|
||||||
|
const ddStr = DD < 10 ? '0' + DD : String(DD)
|
||||||
|
|
||||||
|
if (nHour > hDate[hDate.length - 1]) {
|
||||||
|
resetHour()
|
||||||
|
if (Di === DDate.length - 1) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkDate(`${YY}-${mmStr}-${ddStr} 00:00:00`) !== true && dayRule.value !== 'workDay' && dayRule.value !== 'lastWeek' && dayRule.value !== 'lastDay') {
|
||||||
|
resetDay()
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dayRule.value === 'lastDay') {
|
||||||
|
if (checkDate(`${YY}-${mmStr}-${ddStr} 00:00:00`) !== true) {
|
||||||
|
while (DD > 0 && checkDate(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`) !== true) DD--
|
||||||
|
}
|
||||||
|
} else if (dayRule.value === 'workDay') {
|
||||||
|
if (checkDate(`${YY}-${mmStr}-${ddStr} 00:00:00`) !== true) {
|
||||||
|
while (DD > 0 && checkDate(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`) !== true) DD--
|
||||||
|
}
|
||||||
|
const thisWeek = formatDate(new Date(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`), 'week')
|
||||||
|
if (thisWeek === 1) {
|
||||||
|
DD++
|
||||||
|
if (checkDate(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`) !== true) DD -= 3
|
||||||
|
} else if (thisWeek === 7) {
|
||||||
|
if (dayRuleSup.value !== 1) DD--
|
||||||
|
else DD += 2
|
||||||
|
}
|
||||||
|
} else if (dayRule.value === 'weekDay') {
|
||||||
|
const thisWeek = formatDate(new Date(`${YY}-${mmStr}-${DD} 00:00:00`), 'week')
|
||||||
|
if (!(dayRuleSup.value as number[]).includes(thisWeek)) {
|
||||||
|
if (Di === DDate.length - 1) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (dayRule.value === 'assWeek') {
|
||||||
|
const thisWeek = formatDate(new Date(`${YY}-${mmStr}-${DD} 00:00:00`), 'week')
|
||||||
|
const ruleSup = dayRuleSup.value as number[]
|
||||||
|
if (ruleSup[1] >= thisWeek) {
|
||||||
|
DD = (ruleSup[0] - 1) * 7 + ruleSup[1] - thisWeek + 1
|
||||||
|
} else {
|
||||||
|
DD = ruleSup[0] * 7 + ruleSup[1] - thisWeek + 1
|
||||||
|
}
|
||||||
|
} else if (dayRule.value === 'lastWeek') {
|
||||||
|
if (checkDate(`${YY}-${mmStr}-${ddStr} 00:00:00`) !== true) {
|
||||||
|
while (DD > 0 && checkDate(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`) !== true) DD--
|
||||||
|
}
|
||||||
|
const thisWeek = formatDate(new Date(`${YY}-${mmStr}-${(DD < 10 ? '0' + DD : DD)} 00:00:00`), 'week')
|
||||||
|
const ruleSup = dayRuleSup.value as number
|
||||||
|
if (ruleSup < thisWeek) DD -= thisWeek - ruleSup
|
||||||
|
else if (ruleSup > thisWeek) DD -= 7 - (ruleSup - thisWeek)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDD = DD < 10 ? '0' + DD : String(DD)
|
||||||
|
|
||||||
|
goHour: for (let hi = hIdx; hi < hDate.length; hi++) {
|
||||||
|
const hh = hDate[hi] < 10 ? '0' + hDate[hi] : String(hDate[hi])
|
||||||
|
if (nMin > mDate[mDate.length - 1]) {
|
||||||
|
resetMin()
|
||||||
|
if (hi === hDate.length - 1) {
|
||||||
|
resetHour()
|
||||||
|
if (Di === DDate.length - 1) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
continue goDay
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
goMin: for (let mi = mIdx; mi < mDate.length; mi++) {
|
||||||
|
const mm = mDate[mi] < 10 ? '0' + mDate[mi] : String(mDate[mi])
|
||||||
|
if (nSecond > sDate[sDate.length - 1]) {
|
||||||
|
resetSecond()
|
||||||
|
if (mi === mDate.length - 1) {
|
||||||
|
resetMin()
|
||||||
|
if (hi === hDate.length - 1) {
|
||||||
|
resetHour()
|
||||||
|
if (Di === DDate.length - 1) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
continue goDay
|
||||||
|
}
|
||||||
|
continue goHour
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let si = sIdx; si <= sDate.length - 1; si++) {
|
||||||
|
const ss = sDate[si] < 10 ? '0' + sDate[si] : String(sDate[si])
|
||||||
|
if (MM !== 0 && DD !== 0) {
|
||||||
|
resultArr.push(`${YY}-${mmStr}-${finalDD} ${hh}:${mm}:${ss}`)
|
||||||
|
nums++
|
||||||
|
}
|
||||||
|
if (nums === 5) break goYear
|
||||||
|
if (si === sDate.length - 1) {
|
||||||
|
resetSecond()
|
||||||
|
if (mi === mDate.length - 1) {
|
||||||
|
resetMin()
|
||||||
|
if (hi === hDate.length - 1) {
|
||||||
|
resetHour()
|
||||||
|
if (Di === DDate.length - 1) {
|
||||||
|
resetDay()
|
||||||
|
if (Mi === MDate.length - 1) { resetMonth(); continue goYear }
|
||||||
|
continue goMonth
|
||||||
|
}
|
||||||
|
continue goDay
|
||||||
|
}
|
||||||
|
continue goHour
|
||||||
|
}
|
||||||
|
continue goMin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultArr.length === 0) {
|
||||||
|
resultList.value = ['没有达到条件的结果!']
|
||||||
|
} else {
|
||||||
|
resultList.value = resultArr
|
||||||
|
if (resultArr.length !== 5) {
|
||||||
|
resultList.value.push(`最近100年内只有上面${resultArr.length}条结果!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isShow.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndex(arr: number[], value: number): number {
|
||||||
|
if (value <= arr[0] || value > arr[arr.length - 1]) return 0
|
||||||
|
for (let i = 0; i < arr.length - 1; i++) {
|
||||||
|
if (value > arr[i] && value <= arr[i + 1]) return i + 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYearArr(rule: string, year: number) {
|
||||||
|
dateArr.value[5] = getOrderArr(year, year + 100)
|
||||||
|
if (rule) {
|
||||||
|
if (rule.includes('-')) dateArr.value[5] = getCycleArr(rule, year + 100, false)
|
||||||
|
else if (rule.includes('/')) dateArr.value[5] = getAverageArr(rule, year + 100)
|
||||||
|
else if (rule !== '*') dateArr.value[5] = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthArr(rule: string) {
|
||||||
|
dateArr.value[4] = getOrderArr(1, 12)
|
||||||
|
if (rule.includes('-')) dateArr.value[4] = getCycleArr(rule, 12, false)
|
||||||
|
else if (rule.includes('/')) dateArr.value[4] = getAverageArr(rule, 12)
|
||||||
|
else if (rule !== '*') dateArr.value[4] = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekArr(rule: string) {
|
||||||
|
if (dayRule.value === '' && dayRuleSup.value === '') {
|
||||||
|
if (rule.includes('-')) {
|
||||||
|
dayRule.value = 'weekDay'
|
||||||
|
dayRuleSup.value = getCycleArr(rule, 7, false)
|
||||||
|
} else if (rule.includes('#')) {
|
||||||
|
dayRule.value = 'assWeek'
|
||||||
|
const matchRule = rule.match(/[0-9]{1}/g)
|
||||||
|
dayRuleSup.value = [Number(matchRule![1]), Number(matchRule![0])]
|
||||||
|
dateArr.value[3] = [1]
|
||||||
|
if ((dayRuleSup.value as number[])[1] === 7) (dayRuleSup.value as number[])[1] = 0
|
||||||
|
} else if (rule.includes('L')) {
|
||||||
|
dayRule.value = 'lastWeek'
|
||||||
|
dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)![0])
|
||||||
|
dateArr.value[3] = [31]
|
||||||
|
if (dayRuleSup.value === 7) dayRuleSup.value = 0
|
||||||
|
} else if (rule !== '*' && rule !== '?') {
|
||||||
|
dayRule.value = 'weekDay'
|
||||||
|
dayRuleSup.value = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayArr(rule: string) {
|
||||||
|
dateArr.value[3] = getOrderArr(1, 31)
|
||||||
|
dayRule.value = ''
|
||||||
|
dayRuleSup.value = ''
|
||||||
|
if (rule.includes('-')) {
|
||||||
|
dateArr.value[3] = getCycleArr(rule, 31, false)
|
||||||
|
dayRuleSup.value = 'null'
|
||||||
|
} else if (rule.includes('/')) {
|
||||||
|
dateArr.value[3] = getAverageArr(rule, 31)
|
||||||
|
dayRuleSup.value = 'null'
|
||||||
|
} else if (rule.includes('W')) {
|
||||||
|
dayRule.value = 'workDay'
|
||||||
|
dayRuleSup.value = Number(rule.match(/[0-9]{1,2}/g)![0])
|
||||||
|
dateArr.value[3] = [dayRuleSup.value as number]
|
||||||
|
} else if (rule.includes('L')) {
|
||||||
|
dayRule.value = 'lastDay'
|
||||||
|
dayRuleSup.value = 'null'
|
||||||
|
dateArr.value[3] = [31]
|
||||||
|
} else if (rule !== '*' && rule !== '?') {
|
||||||
|
dateArr.value[3] = getAssignArr(rule)
|
||||||
|
dayRuleSup.value = 'null'
|
||||||
|
} else if (rule === '*') {
|
||||||
|
dayRuleSup.value = 'null'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHourArr(rule: string) {
|
||||||
|
dateArr.value[2] = getOrderArr(0, 23)
|
||||||
|
if (rule.includes('-')) dateArr.value[2] = getCycleArr(rule, 24, true)
|
||||||
|
else if (rule.includes('/')) dateArr.value[2] = getAverageArr(rule, 23)
|
||||||
|
else if (rule !== '*') dateArr.value[2] = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMinArr(rule: string) {
|
||||||
|
dateArr.value[1] = getOrderArr(0, 59)
|
||||||
|
if (rule.includes('-')) dateArr.value[1] = getCycleArr(rule, 60, true)
|
||||||
|
else if (rule.includes('/')) dateArr.value[1] = getAverageArr(rule, 59)
|
||||||
|
else if (rule !== '*') dateArr.value[1] = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecondArr(rule: string) {
|
||||||
|
dateArr.value[0] = getOrderArr(0, 59)
|
||||||
|
if (rule.includes('-')) dateArr.value[0] = getCycleArr(rule, 60, true)
|
||||||
|
else if (rule.includes('/')) dateArr.value[0] = getAverageArr(rule, 59)
|
||||||
|
else if (rule !== '*') dateArr.value[0] = getAssignArr(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderArr(min: number, max: number): number[] {
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let i = min; i <= max; i++) arr.push(i)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssignArr(rule: string): number[] {
|
||||||
|
const arr = rule.split(',').map(Number)
|
||||||
|
arr.sort((a, b) => a - b)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAverageArr(rule: string, limit: number): number[] {
|
||||||
|
const arr: number[] = []
|
||||||
|
const agArr = rule.split('/')
|
||||||
|
let min = Number(agArr[0])
|
||||||
|
const step = Number(agArr[1])
|
||||||
|
while (min <= limit) {
|
||||||
|
arr.push(min)
|
||||||
|
min += step
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCycleArr(rule: string, limit: number, status: boolean): number[] {
|
||||||
|
const arr: number[] = []
|
||||||
|
const cycleArr = rule.split('-')
|
||||||
|
let min = Number(cycleArr[0])
|
||||||
|
const max = Number(cycleArr[1])
|
||||||
|
const adjustedMax = min > max ? max + limit : max
|
||||||
|
for (let i = min; i <= adjustedMax; i++) {
|
||||||
|
let add = 0
|
||||||
|
if (!status && i % limit === 0) add = limit
|
||||||
|
arr.push(Math.round(i % limit + add))
|
||||||
|
}
|
||||||
|
arr.sort((a, b) => a - b)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: Date, type?: string): string | number {
|
||||||
|
const time = typeof value === 'number' ? new Date(value) : value
|
||||||
|
const Y = time.getFullYear()
|
||||||
|
const M = time.getMonth() + 1
|
||||||
|
const D = time.getDate()
|
||||||
|
const h = time.getHours()
|
||||||
|
const m = time.getMinutes()
|
||||||
|
const s = time.getSeconds()
|
||||||
|
const week = time.getDay()
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return `${Y}-${M < 10 ? '0' + M : M}-${D < 10 ? '0' + D : D} ${h < 10 ? '0' + h : h}:${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`
|
||||||
|
} else if (type === 'week') {
|
||||||
|
return week + 1
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDate(value: string): boolean {
|
||||||
|
const time = new Date(value)
|
||||||
|
const format = formatDate(time)
|
||||||
|
return value === format
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.ex, expressionChange)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
expressionChange()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.popup-result {
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 24px;
|
||||||
|
margin: 25px auto;
|
||||||
|
padding: 15px 10px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result .title {
|
||||||
|
position: absolute;
|
||||||
|
top: -28px;
|
||||||
|
left: 50%;
|
||||||
|
width: 140px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: -70px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 30px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result-scroll {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 24px;
|
||||||
|
height: 10em;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-result-scroll li {
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="top-right-btn">
|
||||||
|
<el-row :gutter="10">
|
||||||
|
<el-tooltip content="隐藏搜索" effect="dark" placement="top" v-if="showSearch">
|
||||||
|
<el-button circle @click="toggleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="显示搜索" effect="dark" placement="top" v-else>
|
||||||
|
<el-button circle @click="toggleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="刷新" effect="dark" placement="top">
|
||||||
|
<el-button circle @click="refresh">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="显隐列" effect="dark" placement="top" v-if="columns && columns.length > 0">
|
||||||
|
<el-button circle @click="showColumn">
|
||||||
|
<el-icon><Menu /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-row>
|
||||||
|
<el-dialog v-model="open" title="显示/隐藏" append-to-body>
|
||||||
|
<el-transfer
|
||||||
|
v-model="value"
|
||||||
|
:titles="['显示', '隐藏']"
|
||||||
|
:data="transferData"
|
||||||
|
@change="dataChange"
|
||||||
|
/>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Search, Refresh, Menu } from '@element-plus/icons-vue'
|
||||||
|
import type { ColumnOption } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSearch?: boolean
|
||||||
|
columns?: ColumnOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showSearch: true,
|
||||||
|
columns: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:showSearch': [value: boolean]
|
||||||
|
queryTable: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const value = ref<number[]>([])
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
const transferData = computed(() => {
|
||||||
|
return (props.columns || []).map((item, index) => ({
|
||||||
|
key: index,
|
||||||
|
label: item.label
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleSearch() {
|
||||||
|
emit('update:showSearch', !props.showSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
emit('queryTable')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataChange(data: number[]) {
|
||||||
|
for (const item in props.columns) {
|
||||||
|
const key = props.columns[item].key
|
||||||
|
props.columns[item].visible = !data.includes(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showColumn() {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.columns) {
|
||||||
|
for (const item in props.columns) {
|
||||||
|
if (props.columns[item].visible === false) {
|
||||||
|
value.value.push(parseInt(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.top-right-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-transfer__button) {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 12px;
|
||||||
|
display: block;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-transfer__button:first-child) {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<section class="app-main">
|
||||||
|
<router-view v-slot="{ Component, route: currentRoute }">
|
||||||
|
<transition name="fade-transform" mode="out-in">
|
||||||
|
<keep-alive :include="cachedViews">
|
||||||
|
<component :is="Component" :key="currentRoute.path" />
|
||||||
|
</keep-alive>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
|
const cachedViews = computed(() => permissionStore.cachedViews)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-main {
|
||||||
|
/* 50 = navbar */
|
||||||
|
min-height: calc(100vh - 50px);
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-header + .app-main {
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasTagsView {
|
||||||
|
.app-main {
|
||||||
|
/* 84 = navbar + tags-view = 50 + 34 */
|
||||||
|
min-height: calc(100vh - 84px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-header + .app-main {
|
||||||
|
padding-top: 84px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// Fix CSS style bug when opening el-dialog
|
||||||
|
.el-popup-parent--hidden {
|
||||||
|
.fixed-header {
|
||||||
|
padding-right: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<el-breadcrumb class="app-breadcrumb" separator="/">
|
||||||
|
<transition-group name="breadcrumb">
|
||||||
|
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||||
|
<span v-if="item.redirect === 'noRedirect' || index === levelList.length - 1" class="no-redirect">{{ item.meta?.title }}</span>
|
||||||
|
<a v-else @click.prevent="handleLink(item)">{{ item.meta?.title }}</a>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</transition-group>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter, type RouteRecordNormalized } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
redirect?: string
|
||||||
|
path: string
|
||||||
|
meta?: {
|
||||||
|
title?: string
|
||||||
|
breadcrumb?: boolean
|
||||||
|
}
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelList = ref<BreadcrumbItem[] | null>(null)
|
||||||
|
|
||||||
|
function getBreadcrumb() {
|
||||||
|
// Only show routes with meta.title
|
||||||
|
const matched = route.matched.filter(item => item.meta && item.meta.title)
|
||||||
|
const first = matched[0]
|
||||||
|
|
||||||
|
if (!isDashboard(first)) {
|
||||||
|
matched.unshift({ path: '/index', meta: { title: '首页' } } as unknown as RouteRecordNormalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
levelList.value = matched.filter(
|
||||||
|
item => item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||||
|
) as unknown as BreadcrumbItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDashboard(routeItem: RouteRecordNormalized | undefined): boolean {
|
||||||
|
if (!routeItem) return false
|
||||||
|
const name = routeItem.name
|
||||||
|
if (!name) return false
|
||||||
|
return String(name).trim() === 'Index'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLink(item: BreadcrumbItem) {
|
||||||
|
const { redirect, path } = item
|
||||||
|
if (redirect) {
|
||||||
|
router.push(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(newPath) => {
|
||||||
|
// If navigating to redirect page, don't update breadcrumbs
|
||||||
|
if (newPath.startsWith('/redirect/')) return
|
||||||
|
getBreadcrumb()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getBreadcrumb()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-breadcrumb.el-breadcrumb {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 50px;
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
.no-redirect {
|
||||||
|
color: #97a8be;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary, #409eff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-enter-active,
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-enter-from,
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="navbar">
|
||||||
|
<div class="hamburger-container" @click="toggleSideBar">
|
||||||
|
<el-icon :size="20">
|
||||||
|
<Fold v-if="appStore.sidebarCollapsed" />
|
||||||
|
<Expand v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Breadcrumb v-if="!topNav" class="breadcrumb-container" />
|
||||||
|
|
||||||
|
<div class="right-menu">
|
||||||
|
<template v-if="appStore.device !== 'mobile'">
|
||||||
|
<HeaderSearch class="right-menu-item" />
|
||||||
|
|
||||||
|
<Screenfull class="right-menu-item hover-effect" />
|
||||||
|
|
||||||
|
<el-tooltip content="布局大小" effect="dark" placement="bottom">
|
||||||
|
<SizeSelect class="right-menu-item hover-effect" />
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<el-tooltip content="主题切换" effect="dark" placement="bottom">
|
||||||
|
<div class="right-menu-item hover-effect" @click="themeStore.toggleTheme">
|
||||||
|
<el-icon :size="18">
|
||||||
|
<Sunny v-if="themeStore.isDark" />
|
||||||
|
<Moon v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click" @command="handleCommand">
|
||||||
|
<div class="avatar-wrapper">
|
||||||
|
<img :src="userStore.userInfo?.avatar || defaultAvatar" class="user-avatar" />
|
||||||
|
<el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<router-link to="/user/profile">
|
||||||
|
<el-dropdown-item>个人中心</el-dropdown-item>
|
||||||
|
</router-link>
|
||||||
|
<el-dropdown-item @click="setting = true">
|
||||||
|
<span>布局设置</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided command="logout">
|
||||||
|
<span>退出登录</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Fold, Expand, ArrowDown, Sunny, Moon } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import Breadcrumb from '@/components/layout/Breadcrumb/index.vue'
|
||||||
|
import HeaderSearch from '@/components/ui/HeaderSearch/index.vue'
|
||||||
|
import Screenfull from '@/components/ui/Screenfull/index.vue'
|
||||||
|
import SizeSelect from '@/components/ui/SizeSelect/index.vue'
|
||||||
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
|
import { useAppStore } from '@/stores/modules/app'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const themeStore = useTheme()
|
||||||
|
|
||||||
|
const defaultAvatar = 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png'
|
||||||
|
|
||||||
|
// Layout settings visibility
|
||||||
|
const setting = computed({
|
||||||
|
get() {
|
||||||
|
// Access settings store if available
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
set(_val: boolean) {
|
||||||
|
// TODO: integrate with settings store
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Top navigation mode
|
||||||
|
const topNav = computed(() => false)
|
||||||
|
|
||||||
|
function toggleSideBar() {
|
||||||
|
appStore.toggleSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(command: string) {
|
||||||
|
if (command === 'logout') {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
} catch {
|
||||||
|
// User cancelled
|
||||||
|
}
|
||||||
|
} else if (command === 'profile') {
|
||||||
|
router.push('/user/profile')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.navbar {
|
||||||
|
height: 50px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
|
||||||
|
.hamburger-container {
|
||||||
|
line-height: 46px;
|
||||||
|
height: 100%;
|
||||||
|
float: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-container {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-menu {
|
||||||
|
float: right;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 50px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-menu-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #5a5e66;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
|
||||||
|
&.hover-effect {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
margin-right: 30px;
|
||||||
|
|
||||||
|
.avatar-wrapper {
|
||||||
|
margin-top: 5px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: -20px;
|
||||||
|
top: 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<el-scrollbar ref="scrollContainerRef" :vertical="false" class="scroll-container" @wheel.prevent="handleScroll">
|
||||||
|
<slot />
|
||||||
|
</el-scrollbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { ElScrollbar } from 'element-plus'
|
||||||
|
|
||||||
|
const TAG_AND_TAG_SPACING = 4
|
||||||
|
|
||||||
|
const scrollContainerRef = ref<InstanceType<typeof ElScrollbar> | null>(null)
|
||||||
|
|
||||||
|
const scrollWrapper = computed(() => {
|
||||||
|
return scrollContainerRef.value?.$refs.wrapRef as HTMLElement | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleScroll(e: WheelEvent) {
|
||||||
|
const eventDelta = (e as any).wheelDelta || -e.deltaY * 40
|
||||||
|
const wrapper = scrollWrapper.value
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.scrollLeft = wrapper.scrollLeft + eventDelta / 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitScroll() {
|
||||||
|
// Emit scroll event to parent
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToTarget(currentTag: HTMLElement) {
|
||||||
|
const container = scrollContainerRef.value?.$el
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const wrapper = scrollWrapper.value
|
||||||
|
if (!wrapper) return
|
||||||
|
|
||||||
|
const containerWidth = container.offsetWidth
|
||||||
|
|
||||||
|
// Find all tag elements in the scroll container
|
||||||
|
const tagList = Array.from(container.querySelectorAll('.tags-view-item')) as HTMLElement[]
|
||||||
|
|
||||||
|
let firstTag: HTMLElement | null = null
|
||||||
|
let lastTag: HTMLElement | null = null
|
||||||
|
|
||||||
|
if (tagList.length > 0) {
|
||||||
|
firstTag = tagList[0]
|
||||||
|
lastTag = tagList[tagList.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTag === currentTag) {
|
||||||
|
wrapper.scrollLeft = 0
|
||||||
|
} else if (lastTag === currentTag) {
|
||||||
|
wrapper.scrollLeft = wrapper.scrollWidth - containerWidth
|
||||||
|
} else {
|
||||||
|
// Find prevTag and nextTag
|
||||||
|
const currentIndex = tagList.findIndex(item => item === currentTag)
|
||||||
|
const prevTag = tagList[currentIndex - 1]
|
||||||
|
const nextTag = tagList[currentIndex + 1]
|
||||||
|
|
||||||
|
if (!prevTag || !nextTag) return
|
||||||
|
|
||||||
|
// The tag's offsetLeft after nextTag
|
||||||
|
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + TAG_AND_TAG_SPACING
|
||||||
|
|
||||||
|
// The tag's offsetLeft before prevTag
|
||||||
|
const beforePrevTagOffsetLeft = prevTag.offsetLeft - TAG_AND_TAG_SPACING
|
||||||
|
|
||||||
|
if (afterNextTagOffsetLeft > wrapper.scrollLeft + containerWidth) {
|
||||||
|
wrapper.scrollLeft = afterNextTagOffsetLeft - containerWidth
|
||||||
|
} else if (beforePrevTagOffsetLeft < wrapper.scrollLeft) {
|
||||||
|
wrapper.scrollLeft = beforePrevTagOffsetLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
moveToTarget
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.scroll-container {
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__bar) {
|
||||||
|
bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-scrollbar__wrap) {
|
||||||
|
height: 49px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,389 @@
|
|||||||
|
<template>
|
||||||
|
<div id="tags-view-container" class="tags-view-container">
|
||||||
|
<ScrollPane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
|
||||||
|
<router-link
|
||||||
|
v-for="tag in visitedViews"
|
||||||
|
:key="tag.path"
|
||||||
|
:class="isActive(tag) ? 'active' : ''"
|
||||||
|
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
|
||||||
|
class="tags-view-item"
|
||||||
|
:style="activeStyle(tag)"
|
||||||
|
@click.middle.prevent="!isAffix(tag) && closeSelectedTag(tag)"
|
||||||
|
@contextmenu.prevent="openMenu(tag, $event)"
|
||||||
|
ref="tagRefs"
|
||||||
|
custom
|
||||||
|
v-slot="{ navigate }"
|
||||||
|
>
|
||||||
|
<span class="tag-link" @click="navigate">
|
||||||
|
{{ tag.title || tag.meta?.title || tag.path }}
|
||||||
|
<el-icon v-if="!isAffix(tag)" class="close-icon" @click.prevent.stop="closeSelectedTag(tag)">
|
||||||
|
<Close />
|
||||||
|
</el-icon>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</ScrollPane>
|
||||||
|
|
||||||
|
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||||
|
<li @click="refreshSelectedTag(selectedTag)">
|
||||||
|
<el-icon><RefreshRight /></el-icon> 刷新页面
|
||||||
|
</li>
|
||||||
|
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||||
|
<el-icon><Close /></el-icon> 关闭当前
|
||||||
|
</li>
|
||||||
|
<li @click="closeOthersTags">
|
||||||
|
<el-icon><CircleClose /></el-icon> 关闭其他
|
||||||
|
</li>
|
||||||
|
<li v-if="!isFirstView()" @click="closeLeftTags">
|
||||||
|
<el-icon><Back /></el-icon> 关闭左侧
|
||||||
|
</li>
|
||||||
|
<li v-if="!isLastView()" @click="closeRightTags">
|
||||||
|
<el-icon><Right /></el-icon> 关闭右侧
|
||||||
|
</li>
|
||||||
|
<li @click="closeAllTags(selectedTag)">
|
||||||
|
<el-icon><CircleClose /></el-icon> 全部关闭
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Close, RefreshRight, CircleClose, Back, Right } from '@element-plus/icons-vue'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import ScrollPane from './ScrollPane.vue'
|
||||||
|
import { useTagsViewStore } from '@/stores/modules/tagsView'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
import type { TagView } from '@/types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const tagsViewStore = useTagsViewStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
|
const scrollPaneRef = ref<InstanceType<typeof ScrollPane> | null>(null)
|
||||||
|
const tagRefs = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const top = ref(0)
|
||||||
|
const left = ref(0)
|
||||||
|
const selectedTag = ref<TagView>({})
|
||||||
|
const affixTags = ref<TagView[]>([])
|
||||||
|
|
||||||
|
const visitedViews = computed(() => tagsViewStore.visitedViews)
|
||||||
|
const routes = computed(() => permissionStore.routes)
|
||||||
|
|
||||||
|
// Theme from settings - can be integrated with theme composable
|
||||||
|
const theme = computed(() => '#409eff')
|
||||||
|
|
||||||
|
function isActive(tag: TagView): boolean {
|
||||||
|
return tag.path === route.path
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStyle(tag: TagView): Record<string, string> {
|
||||||
|
if (!isActive(tag)) return {}
|
||||||
|
return {
|
||||||
|
'background-color': theme.value,
|
||||||
|
'border-color': theme.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAffix(tag: TagView): boolean {
|
||||||
|
return tag.meta?.affix ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFirstView(): boolean {
|
||||||
|
try {
|
||||||
|
return selectedTag.value.fullPath === visitedViews.value[1]?.fullPath || selectedTag.value.fullPath === '/index'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLastView(): boolean {
|
||||||
|
try {
|
||||||
|
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1]?.fullPath
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAffixTags(routes: any[], basePath = '/'): TagView[] {
|
||||||
|
const tags: TagView[] = []
|
||||||
|
|
||||||
|
routes.forEach(route => {
|
||||||
|
if (route.meta?.affix) {
|
||||||
|
const tagPath = path.resolve(basePath, route.path)
|
||||||
|
tags.push({
|
||||||
|
fullPath: tagPath,
|
||||||
|
path: tagPath,
|
||||||
|
name: route.name,
|
||||||
|
title: route.meta?.title,
|
||||||
|
meta: { ...route.meta }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (route.children) {
|
||||||
|
const tempTags = filterAffixTags(route.children, route.path)
|
||||||
|
if (tempTags.length >= 1) {
|
||||||
|
tags.push(...tempTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTags() {
|
||||||
|
const tags = filterAffixTags(routes.value)
|
||||||
|
affixTags.value = tags
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.name) {
|
||||||
|
tagsViewStore.addVisitedViewOnly(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTags() {
|
||||||
|
const { name } = route
|
||||||
|
if (name) {
|
||||||
|
tagsViewStore.addView(route as unknown as TagView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToCurrentTag() {
|
||||||
|
nextTick(() => {
|
||||||
|
const tags = tagRefs.value
|
||||||
|
if (!tags || !scrollPaneRef.value) return
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!tag) continue
|
||||||
|
// Get the route path from the href or data attribute
|
||||||
|
const link = tag.querySelector('.tag-link') as HTMLElement
|
||||||
|
if (link && link.getAttribute('href') === route.path) {
|
||||||
|
scrollPaneRef.value.moveToTarget(tag as HTMLElement)
|
||||||
|
// Update visited view if query is different
|
||||||
|
if (tag.getAttribute('href') !== route.fullPath) {
|
||||||
|
tagsViewStore.updateVisitedView(route as unknown as TagView)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectedTag(view: TagView) {
|
||||||
|
// Use router navigation to refresh
|
||||||
|
router.push({ path: '/redirect' + view.fullPath, query: view.query })
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSelectedTag(view: TagView) {
|
||||||
|
tagsViewStore.delView(view).then(({ visitedViews }) => {
|
||||||
|
if (isActive(view)) {
|
||||||
|
toLastView(visitedViews, view)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRightTags() {
|
||||||
|
tagsViewStore.delViewsFrom(selectedTag.value).then(visitedViews => {
|
||||||
|
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
|
||||||
|
toLastView(visitedViews)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLeftTags() {
|
||||||
|
tagsViewStore.delViewsTo(selectedTag.value).then(visitedViews => {
|
||||||
|
if (!visitedViews.find(i => i.fullPath === route.fullPath)) {
|
||||||
|
toLastView(visitedViews)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOthersTags() {
|
||||||
|
router.push(selectedTag.value).catch(() => {})
|
||||||
|
tagsViewStore.delView(selectedTag.value).then(() => {
|
||||||
|
moveToCurrentTag()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllTags(view: TagView) {
|
||||||
|
tagsViewStore.delAllViews().then(({ visitedViews }) => {
|
||||||
|
if (affixTags.value.some(tag => tag.path === route.path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toLastView(visitedViews, view)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLastView(visitedViewsList: TagView[], _view?: TagView) {
|
||||||
|
const latestView = visitedViewsList.slice(-1)[0]
|
||||||
|
if (latestView) {
|
||||||
|
router.push(latestView.fullPath)
|
||||||
|
} else {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu(tag: TagView, e: MouseEvent) {
|
||||||
|
const menuMinWidth = 105
|
||||||
|
const offsetLeft = (e.currentTarget as HTMLElement)?.closest('.tags-view-container')?.getBoundingClientRect().left || 0
|
||||||
|
const offsetWidth = (e.currentTarget as HTMLElement)?.closest('.tags-view-container')?.offsetWidth || 0
|
||||||
|
const maxLeft = offsetWidth - menuMinWidth
|
||||||
|
const leftPos = e.clientX - offsetLeft + 15
|
||||||
|
|
||||||
|
if (leftPos > maxLeft) {
|
||||||
|
left.value = maxLeft
|
||||||
|
} else {
|
||||||
|
left.value = leftPos
|
||||||
|
}
|
||||||
|
|
||||||
|
top.value = e.clientY
|
||||||
|
visible.value = true
|
||||||
|
selectedTag.value = tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
addTags()
|
||||||
|
moveToCurrentTag()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(visible, (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.body.addEventListener('click', closeMenu)
|
||||||
|
} else {
|
||||||
|
document.body.removeEventListener('click', closeMenu)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initTags()
|
||||||
|
addTags()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tags-view-container {
|
||||||
|
height: 34px;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #d8dce5;
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
.tags-view-wrapper {
|
||||||
|
.tags-view-item {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
border: 1px solid #d8dce5;
|
||||||
|
color: #495060;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
a,
|
||||||
|
.tag-link {
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 26px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #409eff;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
background: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextmenu {
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 3000;
|
||||||
|
position: absolute;
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// Reset close icon styles
|
||||||
|
.tags-view-wrapper {
|
||||||
|
.tags-view-item {
|
||||||
|
.close-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
transform-origin: 100% 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #b4bccc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<template v-for="(item, index) in options" :key="item.value">
|
||||||
|
<template v-if="values.includes(String(item.value))">
|
||||||
|
<span
|
||||||
|
v-if="item.raw?.listClass === 'default' || item.raw?.listClass === ''"
|
||||||
|
:index="index"
|
||||||
|
:class="item.raw?.cssClass"
|
||||||
|
>{{ item.label }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-else
|
||||||
|
:disable-transitions="true"
|
||||||
|
:index="index"
|
||||||
|
:type="getTagType(item.raw?.listClass)"
|
||||||
|
:class="item.raw?.cssClass"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { DictDataOption } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: DictDataOption[] | null
|
||||||
|
value?: number | string | (number | string)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
options: null,
|
||||||
|
value: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = computed(() => {
|
||||||
|
if (props.value !== null && typeof props.value !== 'undefined') {
|
||||||
|
return Array.isArray(props.value) ? props.value.map(String) : [String(props.value)]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
function getTagType(listClass: string | undefined): string {
|
||||||
|
if (listClass === 'primary') return ''
|
||||||
|
return listClass || ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.el-tag + .el-tag {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,292 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-upload
|
||||||
|
:action="uploadUrl"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:on-success="handleUploadSuccess"
|
||||||
|
:on-error="handleUploadError"
|
||||||
|
name="file"
|
||||||
|
:show-file-list="false"
|
||||||
|
:headers="headers"
|
||||||
|
class="upload-hidden"
|
||||||
|
ref="uploadRef"
|
||||||
|
v-if="type === 'url'"
|
||||||
|
>
|
||||||
|
</el-upload>
|
||||||
|
<div ref="editorRef" class="editor" :style="styles"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import Quill from 'quill'
|
||||||
|
import 'quill/dist/quill.core.css'
|
||||||
|
import 'quill/dist/quill.snow.css'
|
||||||
|
import 'quill/dist/quill.bubble.css'
|
||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { ElUpload } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content?: string
|
||||||
|
height?: number
|
||||||
|
minHeight?: number
|
||||||
|
readOnly?: boolean
|
||||||
|
fileSize?: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
content: '',
|
||||||
|
height: undefined,
|
||||||
|
minHeight: undefined,
|
||||||
|
readOnly: false,
|
||||||
|
fileSize: 5,
|
||||||
|
type: 'url'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:content': [value: string]
|
||||||
|
'on-change': [data: { html: string; text: string; quill: Quill }]
|
||||||
|
'on-text-change': [delta: any, oldDelta: any, source: string]
|
||||||
|
'on-selection-change': [range: any, oldRange: any, source: string]
|
||||||
|
'on-editor-change': [eventName: string, ...args: any[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const uploadRef = ref<InstanceType<typeof ElUpload> | null>(null)
|
||||||
|
const editorRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const uploadUrl = `${import.meta.env.VITE_APP_BASE_API}/common/upload`
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${getToken()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let quillInstance: Quill | null = null
|
||||||
|
const currentValue = ref('')
|
||||||
|
|
||||||
|
const styles = computed(() => {
|
||||||
|
const style: Record<string, string> = {}
|
||||||
|
if (props.minHeight) {
|
||||||
|
style.minHeight = `${props.minHeight}px`
|
||||||
|
}
|
||||||
|
if (props.height) {
|
||||||
|
style.height = `${props.height}px`
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
theme: 'snow',
|
||||||
|
bounds: document.body,
|
||||||
|
debug: 'warn' as const,
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
['blockquote', 'code-block'],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
[{ indent: '-1' }, { indent: '+1' }],
|
||||||
|
[{ size: ['small', false, 'large', 'huge'] }],
|
||||||
|
[{ header: [1, 2, 3, 4, 5, 6, false] }],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ align: [] }],
|
||||||
|
['clean'],
|
||||||
|
['link', 'image', 'video']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
placeholder: '请输入内容',
|
||||||
|
readOnly: props.readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
|
||||||
|
quillInstance = new Quill(editorRef.value, options)
|
||||||
|
|
||||||
|
if (props.type === 'url') {
|
||||||
|
const toolbar = quillInstance.getModule('toolbar')
|
||||||
|
if (toolbar) {
|
||||||
|
toolbar.addHandler('image', (value: boolean) => {
|
||||||
|
if (value && uploadRef.value) {
|
||||||
|
const input = uploadRef.value.$el.querySelector('input[type="file"]')
|
||||||
|
if (input) {
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
} else if (quillInstance) {
|
||||||
|
quillInstance.format('image', false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quillInstance.pasteHTML(currentValue.value)
|
||||||
|
|
||||||
|
quillInstance.on('text-change', (_delta, _oldDelta, source) => {
|
||||||
|
if (!editorRef.value) return
|
||||||
|
const html = editorRef.value.children[0]?.innerHTML || ''
|
||||||
|
const text = quillInstance!.getText()
|
||||||
|
currentValue.value = html
|
||||||
|
emit('update:content', html)
|
||||||
|
emit('on-change', { html, text, quill: quillInstance! })
|
||||||
|
})
|
||||||
|
|
||||||
|
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
||||||
|
emit('on-text-change', delta, oldDelta, source)
|
||||||
|
})
|
||||||
|
|
||||||
|
quillInstance.on('selection-change', (range, oldRange, source) => {
|
||||||
|
emit('on-selection-change', range, oldRange, source)
|
||||||
|
})
|
||||||
|
|
||||||
|
quillInstance.on('editor-change', (eventName, ...args) => {
|
||||||
|
emit('on-editor-change', eventName, ...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeforeUpload(file: File): boolean {
|
||||||
|
if (props.fileSize) {
|
||||||
|
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||||
|
if (!isLt) {
|
||||||
|
ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadSuccess(res: { code: number; fileName: string }) {
|
||||||
|
if (res.code === 200 && quillInstance) {
|
||||||
|
const length = quillInstance.getSelection()?.index || 0
|
||||||
|
const imageUrl = `${import.meta.env.VITE_APP_BASE_API}${res.fileName}`
|
||||||
|
quillInstance.insertEmbed(length, 'image', imageUrl)
|
||||||
|
quillInstance.setSelection(length + 1)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('图片插入失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadError() {
|
||||||
|
ElMessage.error('图片插入失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.content,
|
||||||
|
(val) => {
|
||||||
|
const newVal = val === null ? '' : val || ''
|
||||||
|
if (newVal !== currentValue.value && quillInstance) {
|
||||||
|
currentValue.value = newVal
|
||||||
|
quillInstance.pasteHTML(currentValue.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
quillInstance = null
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ quill: quillInstance })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor,
|
||||||
|
.ql-toolbar {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
line-height: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill-img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip[data-mode='link']::before {
|
||||||
|
content: '请输入链接地址:';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
|
||||||
|
border-right: 0px;
|
||||||
|
content: '保存';
|
||||||
|
padding-right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-tooltip[data-mode='video']::before {
|
||||||
|
content: '请输入视频地址:';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
|
||||||
|
content: '14px';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
|
||||||
|
content: '10px';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
|
||||||
|
content: '18px';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
|
||||||
|
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
|
||||||
|
content: '32px';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
|
||||||
|
content: '文本';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
|
||||||
|
content: '标题1';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
|
||||||
|
content: '标题2';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
|
||||||
|
content: '标题3';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
|
||||||
|
content: '标题4';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
|
||||||
|
content: '标题5';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
|
||||||
|
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
|
||||||
|
content: '标题6';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
|
||||||
|
content: '标准字体';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
|
||||||
|
content: '衬线字体';
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
|
||||||
|
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
|
||||||
|
content: '等宽字体';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<div class="upload-file">
|
||||||
|
<el-upload
|
||||||
|
:action="uploadFileUrl"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:file-list="fileList"
|
||||||
|
:limit="limit"
|
||||||
|
:on-error="handleUploadError"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
:on-success="handleUploadSuccess"
|
||||||
|
:show-file-list="false"
|
||||||
|
:headers="headers"
|
||||||
|
class="upload-file-uploader"
|
||||||
|
ref="uploadRef"
|
||||||
|
>
|
||||||
|
<el-button type="primary" size="small">选取文件</el-button>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip" v-if="showTip">
|
||||||
|
请上传
|
||||||
|
<template v-if="fileSize"> 大小不超过 <b class="danger-text">{{ fileSize }}MB</b> </template>
|
||||||
|
<template v-if="fileType.length"> 格式为 <b class="danger-text">{{ fileType.join('/') }}</b> </template>
|
||||||
|
的文件
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
|
||||||
|
<li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="file in fileList">
|
||||||
|
<el-link :href="`${baseUrl}${file.url}`" :underline="false" target="_blank">
|
||||||
|
<el-icon><Document /></el-icon> {{ getFileName(file.name) }}
|
||||||
|
</el-link>
|
||||||
|
<div class="ele-upload-list__item-content-action">
|
||||||
|
<el-link :underline="false" @click="handleDelete(fileList.indexOf(file))" type="danger">删除</el-link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Document } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
import type { UploadFileItem } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | UploadFileItem | UploadFileItem[]
|
||||||
|
limit?: number
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string[]
|
||||||
|
isShowTip?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
limit: 5,
|
||||||
|
fileSize: 5,
|
||||||
|
fileType: () => ['doc', 'xls', 'ppt', 'txt', 'pdf'],
|
||||||
|
isShowTip: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_APP_BASE_API || ''
|
||||||
|
const uploadFileUrl = `${import.meta.env.VITE_APP_BASE_API}/common/upload`
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${getToken()}`
|
||||||
|
}
|
||||||
|
const fileList = ref<UploadFileItem[]>([])
|
||||||
|
const uploadRef = ref()
|
||||||
|
|
||||||
|
const showTip = computed(() => props.isShowTip && (props.fileType.length > 0 || props.fileSize > 0))
|
||||||
|
|
||||||
|
function handleBeforeUpload(file: File): boolean {
|
||||||
|
// Check file type
|
||||||
|
if (props.fileType.length > 0) {
|
||||||
|
let fileExtension = ''
|
||||||
|
const lastDotIndex = file.name.lastIndexOf('.')
|
||||||
|
if (lastDotIndex > -1) {
|
||||||
|
fileExtension = file.name.slice(lastDotIndex + 1)
|
||||||
|
}
|
||||||
|
const isTypeOk = props.fileType.some(type => {
|
||||||
|
if (file.type.indexOf(type) > -1) return true
|
||||||
|
if (fileExtension && fileExtension.indexOf(type) > -1) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (!isTypeOk) {
|
||||||
|
ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check file size
|
||||||
|
if (props.fileSize) {
|
||||||
|
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||||
|
if (!isLt) {
|
||||||
|
ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExceed() {
|
||||||
|
ElMessage.error(`上传文件数量不能超过 ${props.limit} 个!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadError() {
|
||||||
|
ElMessage.error('上传失败, 请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadSuccess(res: { fileName: string }) {
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
fileList.value.push({ name: res.fileName, url: res.fileName })
|
||||||
|
emit('update:modelValue', listToString(fileList.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(index: number) {
|
||||||
|
fileList.value.splice(index, 1)
|
||||||
|
emit('update:modelValue', listToString(fileList.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(name: string): string {
|
||||||
|
const lastSlashIndex = name.lastIndexOf('/')
|
||||||
|
if (lastSlashIndex > -1) {
|
||||||
|
return name.slice(lastSlashIndex + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
return name.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToString(list: UploadFileItem[], separator = ','): string {
|
||||||
|
const urls = list.map(item => item.url)
|
||||||
|
return urls.join(separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
let temp = 1
|
||||||
|
const list = Array.isArray(val) ? val : String(val).split(',')
|
||||||
|
fileList.value = list.map(item => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return { name: item, url: item, uid: new Date().getTime() + temp++ }
|
||||||
|
}
|
||||||
|
return { ...item, uid: (item as UploadFileItem).uid || new Date().getTime() + temp++ } as UploadFileItem
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fileList.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.upload-file-uploader {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list .el-upload-list__item {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
line-height: 2;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list .ele-upload-list__item-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ele-upload-list__item-content-action .el-link {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="{ show: showSearch }" class="header-search">
|
||||||
|
<svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
|
||||||
|
<el-select
|
||||||
|
ref="headerSearchSelectRef"
|
||||||
|
v-model="search"
|
||||||
|
:remote-method="querySearch"
|
||||||
|
filterable
|
||||||
|
default-first-option
|
||||||
|
remote
|
||||||
|
placeholder="Search"
|
||||||
|
class="header-search-select"
|
||||||
|
@change="change"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.item.path"
|
||||||
|
:value="option.item"
|
||||||
|
:label="option.item.title.join(' > ')"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import path from 'path-browserify'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import type { ElSelect } from 'element-plus'
|
||||||
|
import { usePermissionStore } from '@/stores/modules/permission'
|
||||||
|
import type { SearchOption } from '@/types'
|
||||||
|
|
||||||
|
interface FuseResultItem {
|
||||||
|
path: string
|
||||||
|
title: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
|
const headerSearchSelectRef = ref<InstanceType<typeof ElSelect> | null>(null)
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const options = ref<Fuse.FuseResult<FuseResultItem>[]>([])
|
||||||
|
const searchPool = ref<FuseResultItem[]>([])
|
||||||
|
const showSearch = ref(false)
|
||||||
|
|
||||||
|
let fuse: Fuse<FuseResultItem> | undefined
|
||||||
|
|
||||||
|
const routes = computed(() => permissionStore.routes)
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
showSearch.value = !showSearch.value
|
||||||
|
if (showSearch.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
headerSearchSelectRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
headerSearchSelectRef.value?.blur()
|
||||||
|
options.value = []
|
||||||
|
showSearch.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(val: FuseResultItem) {
|
||||||
|
if (isHttp(val.path)) {
|
||||||
|
const pindex = val.path.indexOf('http')
|
||||||
|
window.open(val.path.substring(pindex), '_blank')
|
||||||
|
} else {
|
||||||
|
router.push(val.path)
|
||||||
|
}
|
||||||
|
search.value = ''
|
||||||
|
options.value = []
|
||||||
|
nextTick(() => {
|
||||||
|
showSearch.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFuse(list: FuseResultItem[]) {
|
||||||
|
fuse = new Fuse(list, {
|
||||||
|
shouldSort: true,
|
||||||
|
threshold: 0.4,
|
||||||
|
location: 0,
|
||||||
|
distance: 100,
|
||||||
|
maxPatternLength: 32,
|
||||||
|
minMatchCharLength: 1,
|
||||||
|
keys: [
|
||||||
|
{ name: 'title', weight: 0.7 },
|
||||||
|
{ name: 'path', weight: 0.3 }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRoutes(
|
||||||
|
routes: any[],
|
||||||
|
basePath = '/',
|
||||||
|
prefixTitle: string[] = []
|
||||||
|
): FuseResultItem[] {
|
||||||
|
const res: FuseResultItem[] = []
|
||||||
|
|
||||||
|
for (const routerItem of routes) {
|
||||||
|
// Skip hidden routes
|
||||||
|
if (routerItem.hidden) continue
|
||||||
|
|
||||||
|
const data: FuseResultItem = {
|
||||||
|
path: !isHttp(routerItem.path) ? path.resolve(basePath, routerItem.path) : routerItem.path,
|
||||||
|
title: [...prefixTitle]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routerItem.meta?.title) {
|
||||||
|
data.title = [...data.title, routerItem.meta.title]
|
||||||
|
|
||||||
|
if (routerItem.redirect !== 'noRedirect') {
|
||||||
|
res.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive child routes
|
||||||
|
if (routerItem.children) {
|
||||||
|
const tempRoutes = generateRoutes(routerItem.children, data.path, data.title)
|
||||||
|
if (tempRoutes.length >= 1) {
|
||||||
|
res.push(...tempRoutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
function querySearch(query: string) {
|
||||||
|
if (query !== '' && fuse) {
|
||||||
|
options.value = fuse.search(query)
|
||||||
|
} else {
|
||||||
|
options.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttp(url: string): boolean {
|
||||||
|
return url.indexOf('http://') !== -1 || url.indexOf('https://') !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
routes,
|
||||||
|
() => {
|
||||||
|
searchPool.value = generateRoutes(routes.value)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchPool, (list) => {
|
||||||
|
initFuse(list)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showSearch, (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.body.addEventListener('click', close)
|
||||||
|
} else {
|
||||||
|
document.body.removeEventListener('click', close)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.header-search {
|
||||||
|
font-size: 0 !important;
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search-select {
|
||||||
|
font-size: 18px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
border-radius: 0;
|
||||||
|
border: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-bottom: 1px solid #d9d9d9;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
.header-search-select {
|
||||||
|
width: 210px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div class="icon-body">
|
||||||
|
<el-input v-model="name" clearable placeholder="请输入图标名称" @clear="filterIcons" @input="filterIcons">
|
||||||
|
<template #suffix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="icon-list">
|
||||||
|
<div v-for="(item, index) in iconList" :key="index" @click="selectedIcon(item)">
|
||||||
|
<svg-icon :icon-class="item" style="height: 30px; width: 16px;" />
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
import icons from './requireIcons'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selected: [name: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const iconList = ref<string[]>([...icons])
|
||||||
|
|
||||||
|
function filterIcons() {
|
||||||
|
iconList.value = [...icons]
|
||||||
|
if (name.value) {
|
||||||
|
iconList.value = iconList.value.filter(item => item.includes(name.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIcon(name: string) {
|
||||||
|
emit('selected', name)
|
||||||
|
document.body.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
name.value = ''
|
||||||
|
iconList.value = [...icons]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style rel="stylesheet/scss" lang="scss" scoped>
|
||||||
|
.icon-body {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.icon-list {
|
||||||
|
height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
div {
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
margin-bottom: -5px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 33%;
|
||||||
|
float: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
fill: currentColor;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
// Icon list for IconSelect component
|
||||||
|
// These icons should match the SVG files in src/assets/icons/svg/
|
||||||
|
// Update this list when adding new icons
|
||||||
|
|
||||||
|
const icons: string[] = [
|
||||||
|
'search',
|
||||||
|
'user',
|
||||||
|
'home',
|
||||||
|
'setting',
|
||||||
|
'menu',
|
||||||
|
'dashboard',
|
||||||
|
'system',
|
||||||
|
'monitor',
|
||||||
|
'tool',
|
||||||
|
'log',
|
||||||
|
'link',
|
||||||
|
'example',
|
||||||
|
'component',
|
||||||
|
'form',
|
||||||
|
'table',
|
||||||
|
'chart',
|
||||||
|
'lock',
|
||||||
|
'unlock',
|
||||||
|
'eye',
|
||||||
|
'eye-open',
|
||||||
|
'qq',
|
||||||
|
'wechat',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'download',
|
||||||
|
'upload',
|
||||||
|
'print',
|
||||||
|
'zip',
|
||||||
|
'excel',
|
||||||
|
'pdf',
|
||||||
|
'word',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
'add',
|
||||||
|
'save',
|
||||||
|
'close',
|
||||||
|
'refresh',
|
||||||
|
'check',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
'info',
|
||||||
|
'success',
|
||||||
|
'question',
|
||||||
|
'star',
|
||||||
|
'location',
|
||||||
|
'calendar',
|
||||||
|
'time',
|
||||||
|
'document',
|
||||||
|
'folder',
|
||||||
|
'image',
|
||||||
|
'video',
|
||||||
|
'music',
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
|
'stop',
|
||||||
|
'forward',
|
||||||
|
'back',
|
||||||
|
'top',
|
||||||
|
'right',
|
||||||
|
'left',
|
||||||
|
'down',
|
||||||
|
'arrow-up',
|
||||||
|
'arrow-down',
|
||||||
|
'arrow-left',
|
||||||
|
'arrow-right'
|
||||||
|
]
|
||||||
|
|
||||||
|
export default icons
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div class="component-upload-image">
|
||||||
|
<el-upload
|
||||||
|
:action="uploadImgUrl"
|
||||||
|
list-type="picture-card"
|
||||||
|
:on-success="handleUploadSuccess"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:limit="limit"
|
||||||
|
:on-error="handleUploadError"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
name="file"
|
||||||
|
:show-file-list="true"
|
||||||
|
:headers="headers"
|
||||||
|
:file-list="fileList"
|
||||||
|
:on-preview="handlePictureCardPreview"
|
||||||
|
:class="{ hide: fileList.length >= limit }"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip" v-if="showTip">
|
||||||
|
请上传
|
||||||
|
<template v-if="fileSize"> 大小不超过 <b class="danger-text">{{ fileSize }}MB</b> </template>
|
||||||
|
<template v-if="fileType.length"> 格式为 <b class="danger-text">{{ fileType.join('/') }}</b> </template>
|
||||||
|
的文件
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="预览" width="800">
|
||||||
|
<img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" />
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElLoading } from 'element-plus'
|
||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
import type { UploadFileItem } from '@/types'
|
||||||
|
import type { UploadFile } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | UploadFileItem | UploadFileItem[]
|
||||||
|
limit?: number
|
||||||
|
fileSize?: number
|
||||||
|
fileType?: string[]
|
||||||
|
isShowTip?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
limit: 5,
|
||||||
|
fileSize: 5,
|
||||||
|
fileType: () => ['png', 'jpg', 'jpeg'],
|
||||||
|
isShowTip: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogImageUrl = ref('')
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const baseUrl = import.meta.env.VITE_APP_BASE_API || ''
|
||||||
|
const uploadImgUrl = `${import.meta.env.VITE_APP_BASE_API}/common/upload`
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Bearer ${getToken()}`
|
||||||
|
}
|
||||||
|
const fileList = ref<UploadFileItem[]>([])
|
||||||
|
let loading: ReturnType<typeof ElLoading.service> | null = null
|
||||||
|
|
||||||
|
const showTip = computed(() => props.isShowTip && (props.fileType.length > 0 || props.fileSize > 0))
|
||||||
|
|
||||||
|
function handleRemove(file: UploadFile) {
|
||||||
|
const findex = fileList.value.findIndex(f => f.name === file.name)
|
||||||
|
if (findex > -1) {
|
||||||
|
fileList.value.splice(findex, 1)
|
||||||
|
emit('update:modelValue', listToString(fileList.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadSuccess(res: { fileName: string }) {
|
||||||
|
fileList.value.push({ name: res.fileName, url: res.fileName })
|
||||||
|
emit('update:modelValue', listToString(fileList.value))
|
||||||
|
if (loading) {
|
||||||
|
loading.close()
|
||||||
|
loading = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeforeUpload(file: File): boolean {
|
||||||
|
let isImg = false
|
||||||
|
if (props.fileType.length) {
|
||||||
|
let fileExtension = ''
|
||||||
|
const lastDotIndex = file.name.lastIndexOf('.')
|
||||||
|
if (lastDotIndex > -1) {
|
||||||
|
fileExtension = file.name.slice(lastDotIndex + 1)
|
||||||
|
}
|
||||||
|
isImg = props.fileType.some(type => {
|
||||||
|
if (file.type.indexOf(type) > -1) return true
|
||||||
|
if (fileExtension && fileExtension.indexOf(type) > -1) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
isImg = file.type.indexOf('image') > -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isImg) {
|
||||||
|
ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}图片格式文件!`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.fileSize) {
|
||||||
|
const isLt = file.size / 1024 / 1024 < props.fileSize
|
||||||
|
if (!isLt) {
|
||||||
|
ElMessage.error(`上传头像图片大小不能超过 ${props.fileSize} MB!`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '上传中',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExceed() {
|
||||||
|
ElMessage.error(`上传文件数量不能超过 ${props.limit} 个!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadError() {
|
||||||
|
ElMessage.error('上传失败')
|
||||||
|
if (loading) {
|
||||||
|
loading.close()
|
||||||
|
loading = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePictureCardPreview(file: UploadFile) {
|
||||||
|
dialogImageUrl.value = file.url || ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToString(list: UploadFileItem[], separator = ','): string {
|
||||||
|
const urls = list.map(item => item.url.replace(baseUrl, ''))
|
||||||
|
return urls.join(separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
const list = Array.isArray(val) ? val : String(val).split(',')
|
||||||
|
fileList.value = list.map(item => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const fullUrl = item.indexOf(baseUrl) === -1 ? baseUrl + item : item
|
||||||
|
return { name: fullUrl, url: fullUrl }
|
||||||
|
}
|
||||||
|
return item as UploadFileItem
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fileList.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
// Hide upload button when limit reached
|
||||||
|
:deep(.hide .el-upload--picture-card) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove animation effects
|
||||||
|
:deep(.el-list-enter-active),
|
||||||
|
:deep(.el-list-leave-active) {
|
||||||
|
transition: all 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-list-enter),
|
||||||
|
:deep(.el-list-leave-active) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="{ hidden: hidden }" class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
:background="background"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:layout="layout"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:pager-count="pagerCount"
|
||||||
|
:total="total"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { scrollTo } from '@/utils/scroll-to'
|
||||||
|
import type { PaginationProps, PaginationEvent } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
total: number
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
pageSizes?: number[]
|
||||||
|
pagerCount?: number
|
||||||
|
layout?: string
|
||||||
|
background?: boolean
|
||||||
|
autoScroll?: boolean
|
||||||
|
hidden?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
pageSizes: () => [10, 20, 30, 50],
|
||||||
|
pagerCount: () => (typeof document !== 'undefined' && document.body.clientWidth < 992 ? 5 : 7),
|
||||||
|
layout: 'total, sizes, prev, pager, next, jumper',
|
||||||
|
background: true,
|
||||||
|
autoScroll: true,
|
||||||
|
hidden: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:page': [value: number]
|
||||||
|
'update:limit': [value: number]
|
||||||
|
pagination: [event: PaginationEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentPage = computed({
|
||||||
|
get() {
|
||||||
|
return props.page
|
||||||
|
},
|
||||||
|
set(val: number) {
|
||||||
|
emit('update:page', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageSize = computed({
|
||||||
|
get() {
|
||||||
|
return props.limit
|
||||||
|
},
|
||||||
|
set(val: number) {
|
||||||
|
emit('update:limit', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSizeChange(val: number) {
|
||||||
|
emit('pagination', { page: currentPage.value, limit: val })
|
||||||
|
if (props.autoScroll) {
|
||||||
|
scrollTo(0, 800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCurrentChange(val: number) {
|
||||||
|
emit('pagination', { page: val, limit: pageSize.value })
|
||||||
|
if (props.autoScroll) {
|
||||||
|
scrollTo(0, 800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-container {
|
||||||
|
background: #fff;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-icon :size="18" class="screenfull-icon" @click="click">
|
||||||
|
<FullScreen v-if="!isFullscreen" />
|
||||||
|
<Close v-else />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { FullScreen, Close } from '@element-plus/icons-vue'
|
||||||
|
import screenfull from 'screenfull'
|
||||||
|
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
function click() {
|
||||||
|
if (!screenfull.isEnabled) {
|
||||||
|
ElMessage.warning('你的浏览器不支持全屏')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
screenfull.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
function change() {
|
||||||
|
isFullscreen.value = screenfull.isFullscreen
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (screenfull.isEnabled) {
|
||||||
|
screenfull.on('change', change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (screenfull.isEnabled) {
|
||||||
|
screenfull.off('change', change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.screenfull-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #5a5e66;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<el-dropdown trigger="click" @command="handleSetSize">
|
||||||
|
<div class="size-select-wrapper">
|
||||||
|
<el-icon :size="18"><Resize /></el-icon>
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item
|
||||||
|
v-for="item of sizeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:disabled="appStore.size === item.value"
|
||||||
|
:command="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Resize } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAppStore } from '@/stores/modules/app'
|
||||||
|
import { useTagsViewStore } from '@/stores/modules/tagsView'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const tagsViewStore = useTagsViewStore()
|
||||||
|
|
||||||
|
const sizeOptions = ref([
|
||||||
|
{ label: 'Default', value: 'default' as const },
|
||||||
|
{ label: 'Large', value: 'large' as const },
|
||||||
|
{ label: 'Small', value: 'small' as const }
|
||||||
|
])
|
||||||
|
|
||||||
|
function handleSetSize(size: 'default' | 'large' | 'small') {
|
||||||
|
appStore.setSize(size)
|
||||||
|
refreshView()
|
||||||
|
ElMessage.success('Switch Size Success')
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshView() {
|
||||||
|
// Clear cached views to force re-render
|
||||||
|
tagsViewStore.cachedViews = []
|
||||||
|
|
||||||
|
const { fullPath } = route
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace({ path: '/redirect' + fullPath })
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.size-select-wrapper {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
import type { TagView } from '@/types'
|
||||||
|
|
||||||
|
export const useTagsViewStore = defineStore('tagsView', () => {
|
||||||
|
const visitedViews = ref<TagView[]>([])
|
||||||
|
const cachedViews = ref<string[]>([])
|
||||||
|
|
||||||
|
function addVisitedView(view: TagView) {
|
||||||
|
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||||
|
if (index >= 0) {
|
||||||
|
visitedViews.value[index] = { ...visitedViews.value[index], ...view }
|
||||||
|
} else {
|
||||||
|
visitedViews.value.push({ ...view })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCachedView(view: TagView) {
|
||||||
|
const viewName = view.name
|
||||||
|
if (!viewName) return
|
||||||
|
if (cachedViews.value.includes(viewName)) return
|
||||||
|
if (view.meta?.noCache) return
|
||||||
|
cachedViews.value.push(viewName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addView(view: TagView) {
|
||||||
|
addVisitedView(view)
|
||||||
|
addCachedView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVisitedViewOnly(view: TagView) {
|
||||||
|
addVisitedView(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
function delVisitedView(view: TagView): Promise<TagView[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||||
|
if (index > -1) {
|
||||||
|
visitedViews.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
resolve([...visitedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delCachedView(view: TagView): Promise<string[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = cachedViews.value.indexOf(view.name as string)
|
||||||
|
if (index > -1) {
|
||||||
|
cachedViews.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
resolve([...cachedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delView(view: TagView): Promise<{ visitedViews: TagView[]; cachedViews: string[] }> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
await delVisitedView(view)
|
||||||
|
await delCachedView(view)
|
||||||
|
resolve({
|
||||||
|
visitedViews: [...visitedViews.value],
|
||||||
|
cachedViews: [...cachedViews.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delVisitedViewsFrom(view: TagView): Promise<TagView[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||||
|
if (index > -1) {
|
||||||
|
visitedViews.value.splice(index, visitedViews.value.length - index)
|
||||||
|
}
|
||||||
|
resolve([...visitedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delCachedViewsFrom(view: TagView): Promise<string[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = cachedViews.value.indexOf(view.name as string)
|
||||||
|
if (index > -1) {
|
||||||
|
cachedViews.value.splice(index, cachedViews.value.length - index)
|
||||||
|
}
|
||||||
|
resolve([...cachedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delViewsFrom(view: TagView): Promise<{ visitedViews: TagView[]; cachedViews: string[] }> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
await delVisitedViewsFrom(view)
|
||||||
|
await delCachedViewsFrom(view)
|
||||||
|
resolve({
|
||||||
|
visitedViews: [...visitedViews.value],
|
||||||
|
cachedViews: [...cachedViews.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delVisitedViewsTo(view: TagView): Promise<TagView[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||||
|
if (index > -1) {
|
||||||
|
for (let i = index; i >= 0; i--) {
|
||||||
|
if (visitedViews.value[i].meta?.affix) continue
|
||||||
|
visitedViews.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve([...visitedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delCachedViewsTo(view: TagView): Promise<string[]> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const index = cachedViews.value.indexOf(view.name as string)
|
||||||
|
if (index > -1) {
|
||||||
|
cachedViews.value.splice(0, index + 1)
|
||||||
|
}
|
||||||
|
resolve([...cachedViews.value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delViewsTo(view: TagView): Promise<{ visitedViews: TagView[]; cachedViews: string[] }> {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
await delVisitedViewsTo(view)
|
||||||
|
await delCachedViewsTo(view)
|
||||||
|
resolve({
|
||||||
|
visitedViews: [...visitedViews.value],
|
||||||
|
cachedViews: [...cachedViews.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function delAllViews(): Promise<{ visitedViews: TagView[]; cachedViews: string[] }> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
// Keep affix tags
|
||||||
|
const affixTags = visitedViews.value.filter(tag => tag.meta?.affix)
|
||||||
|
visitedViews.value = affixTags
|
||||||
|
cachedViews.value = []
|
||||||
|
resolve({
|
||||||
|
visitedViews: [...visitedViews.value],
|
||||||
|
cachedViews: [...cachedViews.value]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisitedView(view: TagView) {
|
||||||
|
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||||
|
if (index >= 0) {
|
||||||
|
visitedViews.value[index] = { ...visitedViews.value[index], ...view }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTagsView() {
|
||||||
|
visitedViews.value = []
|
||||||
|
cachedViews.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
visitedViews,
|
||||||
|
cachedViews,
|
||||||
|
addVisitedView,
|
||||||
|
addCachedView,
|
||||||
|
addView,
|
||||||
|
addVisitedViewOnly,
|
||||||
|
delVisitedView,
|
||||||
|
delCachedView,
|
||||||
|
delView,
|
||||||
|
delVisitedViewsFrom,
|
||||||
|
delCachedViewsFrom,
|
||||||
|
delViewsFrom,
|
||||||
|
delVisitedViewsTo,
|
||||||
|
delCachedViewsTo,
|
||||||
|
delViewsTo,
|
||||||
|
delAllViews,
|
||||||
|
updateVisitedView,
|
||||||
|
resetTagsView
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -1,5 +1,157 @@
|
|||||||
export interface RouteMeta {
|
/**
|
||||||
|
* 路由配置接口
|
||||||
|
*/
|
||||||
|
export interface RouteConfig {
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
|
component?: any
|
||||||
|
redirect?: string
|
||||||
|
hidden?: boolean
|
||||||
|
children?: RouteConfig[]
|
||||||
|
meta?: {
|
||||||
|
title?: string
|
||||||
|
icon?: string
|
||||||
|
affix?: boolean
|
||||||
|
noCache?: boolean
|
||||||
|
activeMenu?: string
|
||||||
|
permissions?: string[]
|
||||||
|
roles?: string[]
|
||||||
|
breadcrumb?: boolean
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由标签项
|
||||||
|
*/
|
||||||
|
export interface TagView {
|
||||||
|
fullPath: string
|
||||||
|
path: string
|
||||||
|
name?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
query?: Record<string, any>
|
||||||
|
params?: Record<string, any>
|
||||||
|
meta?: {
|
||||||
|
title?: string
|
||||||
|
affix?: boolean
|
||||||
|
noCache?: boolean
|
||||||
icon?: string
|
icon?: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息接口
|
||||||
|
*/
|
||||||
|
export interface UserInfo {
|
||||||
|
userId: number
|
||||||
|
userName: string
|
||||||
|
nickName?: string
|
||||||
|
avatar?: string
|
||||||
|
email?: string
|
||||||
|
phonenumber?: string
|
||||||
|
sex?: string
|
||||||
|
roles: string[]
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 响应接口
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页响应接口
|
||||||
|
*/
|
||||||
|
export interface PageResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
total: number
|
||||||
|
rows: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询参数
|
||||||
|
*/
|
||||||
|
export interface PageQuery {
|
||||||
|
pageNum: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典数据项
|
||||||
|
*/
|
||||||
|
export interface DictDataOption {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
raw: {
|
||||||
|
listClass: string
|
||||||
|
cssClass: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上传项
|
||||||
|
*/
|
||||||
|
export interface UploadFileItem {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
uid?: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crontab 表达式各字段
|
||||||
|
*/
|
||||||
|
export interface CrontabValueObj {
|
||||||
|
second: string
|
||||||
|
min: string
|
||||||
|
hour: string
|
||||||
|
day: string
|
||||||
|
month: string
|
||||||
|
week: string
|
||||||
|
year: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧工具栏列配置
|
||||||
|
*/
|
||||||
|
export interface ColumnOption {
|
||||||
|
key: number
|
||||||
|
label: string
|
||||||
|
visible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页组件 Props
|
||||||
|
*/
|
||||||
|
export interface PaginationProps {
|
||||||
|
total: number
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
pageSizes?: number[]
|
||||||
|
pagerCount?: number
|
||||||
|
layout?: string
|
||||||
|
background?: boolean
|
||||||
|
autoScroll?: boolean
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页组件事件
|
||||||
|
*/
|
||||||
|
export interface PaginationEvent {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索选项
|
||||||
|
*/
|
||||||
|
export interface SearchOption {
|
||||||
|
path: string
|
||||||
|
title: string[]
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Smooth scroll to a specified position
|
||||||
|
*/
|
||||||
|
function easeInOutQuad(t: number, b: number, c: number, d: number): number {
|
||||||
|
t /= d / 2
|
||||||
|
if (t < 1) return (c / 2) * t * t + b
|
||||||
|
t--
|
||||||
|
return (-c / 2) * (t * (t - 2) - 1) + b
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimationFrameRequester {
|
||||||
|
(callback: FrameRequestCallback): number
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestAnimFrame: AnimationFrameRequester =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window.requestAnimationFrame ||
|
||||||
|
(window as any).webkitRequestAnimationFrame ||
|
||||||
|
(window as any).mozRequestAnimationFrame ||
|
||||||
|
function (callback) {
|
||||||
|
window.setTimeout(callback, 1000 / 60)
|
||||||
|
})
|
||||||
|
: function (callback) {
|
||||||
|
setTimeout(callback, 1000 / 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the scrollbar to a specific position
|
||||||
|
*/
|
||||||
|
function move(amountToScroll: number, start: number, increment: number, startTime: number = Date.now()): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const body = window.document.body
|
||||||
|
const element = body.scrollTop ? body : window.document.documentElement
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const time = now - startTime
|
||||||
|
|
||||||
|
if (time < 800) {
|
||||||
|
const position = easeInOutQuad(time, start, amountToScroll, 800)
|
||||||
|
element.scrollTop = position
|
||||||
|
requestAnimFrame(function () {
|
||||||
|
move(amountToScroll, start, increment, now)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
element.scrollTop = start + amountToScroll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to a specified position
|
||||||
|
* @param element - The target element or position
|
||||||
|
* @param duration - Scroll duration in ms
|
||||||
|
*/
|
||||||
|
export function scrollTo(element: number | Element, duration?: number): void
|
||||||
|
export function scrollTo(y: number, duration?: number): void
|
||||||
|
export function scrollTo(element: number | Element, duration: number = 500): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
if (typeof element === 'number') {
|
||||||
|
const start = window.document.documentElement.scrollTop || window.pageYOffset || window.document.body.scrollTop || 0
|
||||||
|
const amountToScroll = element - start
|
||||||
|
move(amountToScroll, start, element)
|
||||||
|
} else {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue