feat: migrate core components to Vue3 + Composition API

feature/20260628/refactor-frontend-modernization
Lxy 3 weeks ago
parent 90ac382237
commit b77957c16e

@ -0,0 +1,161 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
允许的通配符[, - * ? / L W]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
不指定
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
周期从
<el-input-number v-model="cycle01" :min="1" :max="30" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="31" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
<el-input-number v-model="average01" :min="1" :max="30" /> 号开始
<el-input-number v-model="average02" :min="1" :max="31 - average01 || 1" /> 日执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="5">
每月
<el-input-number v-model="workday" :min="1" :max="31" /> 号最近的那个工作日
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="6">
本月最后一天
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="7">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in 31" :key="item" :value="item">{{ item }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
cron: Record<string, string>
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const radioValue = ref(1)
const workday = ref(1)
const cycle01 = ref(1)
const cycle02 = ref(2)
const average01 = ref(1)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 1, 30)
const c2 = props.check(cycle02.value, c1 + 1, 31)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 1, 30)
const a2 = props.check(average02.value, 1, 31 - a1 || 1)
return `${a1}/${a2}`
})
const workdayCheck = computed(() => props.check(workday.value, 1, 31))
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
if (radioValue.value !== 2 && props.cron.week !== '?') {
emit('update', 'week', '?', 'day')
}
switch (radioValue.value) {
case 1:
emit('update', 'day', '*')
break
case 2:
emit('update', 'day', '?')
break
case 3:
emit('update', 'day', cycleTotal.value)
break
case 4:
emit('update', 'day', averageTotal.value)
break
case 5:
emit('update', 'day', `${workdayCheck.value}W`)
break
case 6:
emit('update', 'day', 'L')
break
case 7:
emit('update', 'day', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 3) {
emit('update', 'day', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 4) {
emit('update', 'day', averageTotal.value)
}
}
function workdayChange() {
if (radioValue.value === 5) {
emit('update', 'day', `${workdayCheck.value}W`)
}
}
function checkboxChange() {
if (radioValue.value === 7) {
emit('update', 'day', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(workdayCheck, workdayChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, workday, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -0,0 +1,118 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
小时允许的通配符[, - * /]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="22" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="23" /> 小时
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
<el-input-number v-model="average01" :min="0" :max="22" /> 小时开始
<el-input-number v-model="average02" :min="1" :max="23 - average01 || 1" /> 小时执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in 24" :key="item" :value="item - 1">{{ item - 1 }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const radioValue = ref(1)
const cycle01 = ref(0)
const cycle02 = ref(1)
const average01 = ref(0)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 0, 22)
const c2 = props.check(cycle02.value, c1 + 1, 23)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 0, 22)
const a2 = props.check(average02.value, 1, 23 - a1 || 1)
return `${a1}/${a2}`
})
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
switch (radioValue.value) {
case 1:
emit('update', 'hour', '*')
break
case 2:
emit('update', 'hour', cycleTotal.value)
break
case 3:
emit('update', 'hour', averageTotal.value)
break
case 4:
emit('update', 'hour', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 2) {
emit('update', 'hour', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 3) {
emit('update', 'hour', averageTotal.value)
}
}
function checkboxChange() {
if (radioValue.value === 4) {
emit('update', 'hour', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -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,118 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
分钟允许的通配符[, - * /]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="58" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="59" /> 分钟
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
<el-input-number v-model="average01" :min="0" :max="58" /> 分钟开始
<el-input-number v-model="average02" :min="1" :max="59 - average01 || 1" /> 分钟执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in 60" :key="item" :value="item - 1">{{ item - 1 }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const radioValue = ref(1)
const cycle01 = ref(1)
const cycle02 = ref(2)
const average01 = ref(0)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 0, 58)
const c2 = props.check(cycle02.value, c1 + 1, 59)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 0, 58)
const a2 = props.check(average02.value, 1, 59 - a1 || 1)
return `${a1}/${a2}`
})
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
switch (radioValue.value) {
case 1:
emit('update', 'min', '*', 'min')
break
case 2:
emit('update', 'min', cycleTotal.value, 'min')
break
case 3:
emit('update', 'min', averageTotal.value, 'min')
break
case 4:
emit('update', 'min', checkboxString.value, 'min')
break
}
}
function cycleChange() {
if (radioValue.value === 2) {
emit('update', 'min', cycleTotal.value, 'min')
}
}
function averageChange() {
if (radioValue.value === 3) {
emit('update', 'min', averageTotal.value, 'min')
}
}
function checkboxChange() {
if (radioValue.value === 4) {
emit('update', 'min', checkboxString.value, 'min')
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -0,0 +1,118 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
允许的通配符[, - * /]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
周期从
<el-input-number v-model="cycle01" :min="1" :max="11" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="12" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
<el-input-number v-model="average01" :min="1" :max="11" /> 月开始
<el-input-number v-model="average02" :min="1" :max="12 - average01 || 1" /> 月执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in 12" :key="item" :value="item">{{ item }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const radioValue = ref(1)
const cycle01 = ref(1)
const cycle02 = ref(2)
const average01 = ref(1)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 1, 11)
const c2 = props.check(cycle02.value, c1 + 1, 12)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 1, 11)
const a2 = props.check(average02.value, 1, 12 - a1 || 1)
return `${a1}/${a2}`
})
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
switch (radioValue.value) {
case 1:
emit('update', 'month', '*')
break
case 2:
emit('update', 'month', cycleTotal.value)
break
case 3:
emit('update', 'month', averageTotal.value)
break
case 4:
emit('update', 'month', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 2) {
emit('update', 'month', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 3) {
emit('update', 'month', averageTotal.value)
}
}
function checkboxChange() {
if (radioValue.value === 4) {
emit('update', 'month', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -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,118 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
允许的通配符[, - * /]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="58" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="59" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
<el-input-number v-model="average01" :min="0" :max="58" /> 秒开始
<el-input-number v-model="average02" :min="1" :max="59 - average01 || 1" /> 秒执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in 60" :key="item" :value="item - 1">{{ item - 1 }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const radioValue = ref(1)
const cycle01 = ref(1)
const cycle02 = ref(2)
const average01 = ref(0)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 0, 58)
const c2 = props.check(cycle02.value, c1 + 1, 59)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 0, 58)
const a2 = props.check(average02.value, 1, 59 - a1 || 1)
return `${a1}/${a2}`
})
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
switch (radioValue.value) {
case 1:
emit('update', 'second', '*', 'second')
break
case 2:
emit('update', 'second', cycleTotal.value)
break
case 3:
emit('update', 'second', averageTotal.value)
break
case 4:
emit('update', 'second', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 2) {
emit('update', 'second', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 3) {
emit('update', 'second', averageTotal.value)
}
}
function checkboxChange() {
if (radioValue.value === 4) {
emit('update', 'second', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -0,0 +1,183 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :value="1">
允许的通配符[, - * ? / L #]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="2">
不指定
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="3">
周期从星期
<el-select clearable v-model="cycle01">
<el-option
v-for="item in weekList"
:key="item.key"
:label="item.value"
:value="item.key"
:disabled="item.key === 1"
>{{ item.value }}</el-option>
</el-select>
-
<el-select clearable v-model="cycle02">
<el-option
v-for="item in weekList"
:key="item.key"
:label="item.value"
:value="item.key"
:disabled="item.key < cycle01 && item.key !== 1"
>{{ item.value }}</el-option>
</el-select>
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="4">
<el-input-number v-model="average01" :min="1" :max="4" /> 周的星期
<el-select clearable v-model="average02">
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key">{{ item.value }}</el-option>
</el-select>
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="5">
本月最后一个星期
<el-select clearable v-model="weekday">
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="item.key">{{ item.value }}</el-option>
</el-select>
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :value="6">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple style="width:100%">
<el-option v-for="item in weekList" :key="item.key" :label="item.value" :value="String(item.key)">{{ item.value }}</el-option>
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
cron: Record<string, string>
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const weekList = [
{ key: 2, value: '星期一' },
{ key: 3, value: '星期二' },
{ key: 4, value: '星期三' },
{ key: 5, value: '星期四' },
{ key: 6, value: '星期五' },
{ key: 7, value: '星期六' },
{ key: 1, value: '星期日' }
]
const radioValue = ref(2)
const weekday = ref(2)
const cycle01 = ref(2)
const cycle02 = ref(3)
const average01 = ref(1)
const average02 = ref(2)
const checkboxList = ref<string[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, 1, 7)
const c2 = props.check(cycle02.value, 1, 7)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, 1, 4)
const a2 = props.check(average02.value, 1, 7)
return `${a2}#${a1}`
})
const weekdayCheck = computed(() => props.check(weekday.value, 1, 7))
const checkboxString = computed(() => {
const str = checkboxList.value.join()
return str === '' ? '*' : str
})
function radioChange() {
if (radioValue.value !== 2 && props.cron.day !== '?') {
emit('update', 'day', '?', 'week')
}
switch (radioValue.value) {
case 1:
emit('update', 'week', '*')
break
case 2:
emit('update', 'week', '?')
break
case 3:
emit('update', 'week', cycleTotal.value)
break
case 4:
emit('update', 'week', averageTotal.value)
break
case 5:
emit('update', 'week', `${weekdayCheck.value}L`)
break
case 6:
emit('update', 'week', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 3) {
emit('update', 'week', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 4) {
emit('update', 'week', averageTotal.value)
}
}
function weekdayChange() {
if (radioValue.value === 5) {
emit('update', 'week', `${weekdayCheck.value}L`)
}
}
function checkboxChange() {
if (radioValue.value === 6) {
emit('update', 'week', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(weekdayCheck, weekdayChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, weekday, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -0,0 +1,125 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio :value="1" v-model="radioValue">
不填允许的通配符[, - * /]
</el-radio>
</el-form-item>
<el-form-item>
<el-radio :value="2" v-model="radioValue">
每年
</el-radio>
</el-form-item>
<el-form-item>
<el-radio :value="3" v-model="radioValue">
周期从
<el-input-number v-model="cycle01" :min="fullYear" :max="2098" /> -
<el-input-number v-model="cycle02" :min="cycle01 + 1" :max="2099" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio :value="4" v-model="radioValue">
<el-input-number v-model="average01" :min="fullYear" :max="2098" /> 年开始
<el-input-number v-model="average02" :min="1" :max="2099 - average01 || fullYear" /> 年执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio :value="5" v-model="radioValue">
指定
<el-select clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 9" :key="item" :value="item - 1 + fullYear" :label="item - 1 + fullYear" />
</el-select>
</el-radio>
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
interface Props {
check: (value: number, min: number, max: number) => number
}
const props = defineProps<Props>()
const emit = defineEmits<{
update: [name: string, value: string, from?: string]
}>()
const fullYear = ref(new Date().getFullYear())
const radioValue = ref(1)
const cycle01 = ref(fullYear.value)
const cycle02 = ref(fullYear.value + 1)
const average01 = ref(fullYear.value)
const average02 = ref(1)
const checkboxList = ref<number[]>([])
const cycleTotal = computed(() => {
const c1 = props.check(cycle01.value, fullYear.value, 2098)
const c2 = props.check(cycle02.value, c1 + 1, 2099)
return `${c1}-${c2}`
})
const averageTotal = computed(() => {
const a1 = props.check(average01.value, fullYear.value, 2098)
const a2 = props.check(average02.value, 1, 2099 - a1 || fullYear.value)
return `${a1}/${a2}`
})
const checkboxString = computed(() => checkboxList.value.join())
function radioChange() {
switch (radioValue.value) {
case 1:
emit('update', 'year', '')
break
case 2:
emit('update', 'year', '*')
break
case 3:
emit('update', 'year', cycleTotal.value)
break
case 4:
emit('update', 'year', averageTotal.value)
break
case 5:
emit('update', 'year', checkboxString.value)
break
}
}
function cycleChange() {
if (radioValue.value === 3) {
emit('update', 'year', cycleTotal.value)
}
}
function averageChange() {
if (radioValue.value === 4) {
emit('update', 'year', averageTotal.value)
}
}
function checkboxChange() {
if (radioValue.value === 5) {
emit('update', 'year', checkboxString.value)
}
}
watch(radioValue, radioChange)
watch(cycleTotal, cycleChange)
watch(averageTotal, averageChange)
watch(checkboxString, checkboxChange)
function setRadioValue(val: number) {
radioValue.value = val
}
defineExpose({ radioValue, checkboxList, cycle01, cycle02, average01, average02, setRadioValue })
</script>

@ -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
query?: Record<string, any>
params?: Record<string, any>
meta?: {
title?: string
affix?: boolean
noCache?: boolean
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
}
/**
*
*/
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…
Cancel
Save