Initial commit: One Pipe System
完整的管理系统,包含账户管理、卡片管理、套餐管理、财务管理等功能模块。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
237
src/components/core/banners/ArtBasicBanner.vue
Normal file
237
src/components/core/banners/ArtBasicBanner.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div
|
||||
class="basic-banner art-custom-card"
|
||||
:class="{ 'has-decoration': showDecoration }"
|
||||
:style="{ backgroundColor: backgroundColor, height: height }"
|
||||
>
|
||||
<div v-if="showMeteors && isDark" class="basic-banner__meteors">
|
||||
<span
|
||||
v-for="(meteor, index) in meteors"
|
||||
:key="index"
|
||||
class="meteor"
|
||||
:style="{
|
||||
top: '-60px',
|
||||
left: `${meteor.x}%`,
|
||||
animationDuration: `${meteor.speed}s`,
|
||||
animationDelay: `${meteor.delay}s`
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="basic-banner__content">
|
||||
<p class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
|
||||
<p class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{ subtitle }}</p>
|
||||
<div
|
||||
v-if="showButton"
|
||||
class="basic-banner__button"
|
||||
:style="{ backgroundColor: buttonColor, color: buttonTextColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</div>
|
||||
<slot></slot>
|
||||
<img
|
||||
v-if="backgroundImage"
|
||||
class="basic-banner__background-image"
|
||||
:src="backgroundImage"
|
||||
:style="{ width: imgWidth, bottom: imgBottom }"
|
||||
alt="背景图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
interface Props {
|
||||
height?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
buttonText?: string
|
||||
buttonColor?: string
|
||||
buttonTextColor?: string
|
||||
titleColor?: string
|
||||
subtitleColor?: string
|
||||
backgroundColor?: string
|
||||
backgroundImage?: string
|
||||
imgWidth?: string
|
||||
imgBottom?: string
|
||||
showButton?: boolean
|
||||
showDecoration?: boolean
|
||||
showMeteors?: boolean // 新增 props 控制流星显示
|
||||
meteorCount?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '11rem',
|
||||
buttonText: '查看',
|
||||
buttonColor: '#fff',
|
||||
buttonTextColor: '#333',
|
||||
backgroundColor: 'var(--el-color-primary-light-2)',
|
||||
titleColor: 'white',
|
||||
subtitleColor: 'white',
|
||||
backgroundImage: '',
|
||||
showButton: true,
|
||||
imgWidth: '12rem',
|
||||
imgBottom: '-3rem',
|
||||
showDecoration: true,
|
||||
showMeteors: false,
|
||||
meteorCount: 10
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
// 生成指定数量的流星,分散x坐标,混合快慢速度
|
||||
const meteors = computed(() => {
|
||||
const segmentWidth = 100 / props.meteorCount // Divide container into segments
|
||||
return Array.from({ length: props.meteorCount }, (_, index) => {
|
||||
const segmentStart = index * segmentWidth
|
||||
const x = segmentStart + Math.random() * segmentWidth // Random x within segment
|
||||
const isSlow = Math.random() > 0.5 // Roughly 50% chance for slow meteors
|
||||
return {
|
||||
x,
|
||||
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2, // Slow: 5-8s, Fast: 2-4s
|
||||
delay: Math.random() * 5 // 0-5s delay
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 11rem;
|
||||
padding: 0 2rem;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__button {
|
||||
display: inline-block;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: var(--el-component-custom-height);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&__background-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3rem;
|
||||
z-index: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
// 背景装饰
|
||||
&.has-decoration::after {
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
bottom: -20%;
|
||||
width: 60%;
|
||||
height: 140%;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 30%;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
// 流星容器
|
||||
&__meteors {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
.meteor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 60px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(255 255 255 / 40%),
|
||||
rgb(255 255 255 / 10%),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transform-origin: top left;
|
||||
animation-name: meteor-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes meteor-fall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -60px) rotate(-45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(400px, 340px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-phone) {
|
||||
.basic-banner__background-image {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
140
src/components/core/banners/ArtCardBanner.vue
Normal file
140
src/components/core/banners/ArtCardBanner.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="card-banner art-custom-card">
|
||||
<div class="banner-content">
|
||||
<div class="banner-icon">
|
||||
<img :src="props.icon" :alt="props.title" />
|
||||
</div>
|
||||
<div class="banner-text">
|
||||
<p class="banner-title">{{ props.title }}</p>
|
||||
<p class="banner-description">{{ props.description }}</p>
|
||||
</div>
|
||||
<div class="banner-buttons">
|
||||
<div
|
||||
v-if="showCancel"
|
||||
class="banner-button cancel-button"
|
||||
:style="{ backgroundColor: cancelButtonColor, color: cancelButtonTextColor }"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelButtonText }}
|
||||
</div>
|
||||
<div
|
||||
class="banner-button"
|
||||
:style="{ backgroundColor: buttonColor, color: buttonTextColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import defaultIcon from '@imgs/3d/icon1.webp'
|
||||
|
||||
interface CardBannerProps {
|
||||
icon?: string
|
||||
title: string
|
||||
description: string
|
||||
buttonText?: string
|
||||
buttonColor?: string
|
||||
buttonTextColor?: string
|
||||
showCancel?: boolean
|
||||
cancelButtonText?: string
|
||||
cancelButtonColor?: string
|
||||
cancelButtonTextColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CardBannerProps>(), {
|
||||
icon: defaultIcon,
|
||||
title: '',
|
||||
description: '',
|
||||
buttonText: '重试',
|
||||
buttonColor: 'var(--main-color)',
|
||||
buttonTextColor: '#fff',
|
||||
showCancel: false,
|
||||
cancelButtonText: '取消',
|
||||
cancelButtonColor: '#f5f5f5',
|
||||
cancelButtonTextColor: '#666'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-banner {
|
||||
padding: 3rem 0 4rem;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 180px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
.banner-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
.banner-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.banner-button {
|
||||
display: inline-block;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: var(--el-component-custom-height);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.cancel-button {
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
src/components/core/base/ArtBackToTop.vue
Normal file
70
src/components/core/base/ArtBackToTop.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-tooltip
|
||||
effect="dark"
|
||||
:content="`按下 ^ 键也能回到顶部哦 ${EmojiText[200]}`"
|
||||
placement="left-start"
|
||||
>
|
||||
<div class="back-to-top" v-show="showButton" @click="scrollToTop">
|
||||
<div class="back-to-top-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>顶部</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { ref, watch } from 'vue'
|
||||
const { scrollToTop } = useCommon()
|
||||
|
||||
const { y } = useWindowScroll()
|
||||
const showButton = ref(false)
|
||||
const scrollThreshold = 2000 // 设置阈值
|
||||
|
||||
// 监听滚动位置
|
||||
watch(y, (newY: number) => {
|
||||
showButton.value = newY > scrollThreshold
|
||||
})
|
||||
|
||||
// 监听键盘 ^ 键,回到顶部
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
scrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener('keydown', handleKeyDown)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
right: 40px;
|
||||
bottom: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: var(--art-gray-700);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
255
src/components/core/base/ArtIconSelector.vue
Normal file
255
src/components/core/base/ArtIconSelector.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="icon-selector">
|
||||
<div
|
||||
class="select"
|
||||
@click="handleClick"
|
||||
:style="{ width: props.width }"
|
||||
:class="[size, { 'is-disabled': disabled }, { 'has-icon': selectValue }]"
|
||||
>
|
||||
<div class="icon">
|
||||
<i
|
||||
:class="`iconfont-sys ${selectValue}`"
|
||||
v-show="props.iconType === IconTypeEnum.CLASS_NAME"
|
||||
></i>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
v-html="selectValue"
|
||||
v-show="props.iconType === IconTypeEnum.UNICODE"
|
||||
></i>
|
||||
</div>
|
||||
<div class="text"> {{ props.text }} </div>
|
||||
<div class="arrow">
|
||||
<i class="iconfont-sys arrow-icon"></i>
|
||||
<i class="iconfont-sys clear-icon" @click.stop="clearIcon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog title="选择图标" width="40%" v-model="visible" align-center>
|
||||
<el-scrollbar height="400px">
|
||||
<ul class="icons-list" v-show="activeName === 'icons'">
|
||||
<li v-for="icon in iconsList" :key="icon.className" @click="selectorIcon(icon)">
|
||||
<i
|
||||
:class="`iconfont-sys ${icon.className}`"
|
||||
v-show="iconType === IconTypeEnum.CLASS_NAME"
|
||||
></i>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
v-html="icon.unicode"
|
||||
v-show="iconType === IconTypeEnum.UNICODE"
|
||||
></i>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="visible = false">确 定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconTypeEnum } from '@/enums/appEnum'
|
||||
import { extractIconClasses } from '@/utils/constants'
|
||||
|
||||
const emits = defineEmits(['getIcon'])
|
||||
|
||||
const iconsList = extractIconClasses()
|
||||
|
||||
const props = defineProps({
|
||||
iconType: {
|
||||
type: String as PropType<IconTypeEnum>,
|
||||
default: IconTypeEnum.CLASS_NAME
|
||||
},
|
||||
defaultIcon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '图标选择器'
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '200px'
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'large' | 'default' | 'small'>,
|
||||
default: 'default'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const selectValue = ref(props.defaultIcon)
|
||||
|
||||
watch(
|
||||
() => props.defaultIcon,
|
||||
(newVal) => {
|
||||
selectValue.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const activeName = ref('icons')
|
||||
const visible = ref(false)
|
||||
|
||||
const selectorIcon = (icon: any) => {
|
||||
if (props.iconType === IconTypeEnum.CLASS_NAME) {
|
||||
selectValue.value = icon.className
|
||||
} else {
|
||||
selectValue.value = icon.unicode
|
||||
}
|
||||
visible.value = false
|
||||
emits('getIcon', selectValue.value)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const clearIcon = () => {
|
||||
selectValue.value = ''
|
||||
emits('getIcon', selectValue.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-selector {
|
||||
width: 100%;
|
||||
|
||||
.select {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: calc(var(--custom-radius) / 3 + 2px) !important;
|
||||
transition: border 0.3s;
|
||||
|
||||
@media (width <= 500px) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:hover:not(.is-disabled).has-icon {
|
||||
.arrow-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--art-text-gray-400);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
color: var(--art-gray-700);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
width: 50%;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-600);
|
||||
|
||||
@include ellipsis();
|
||||
|
||||
@media (width <= 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(100% - 2px);
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: var(--el-disabled-bg-color);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
|
||||
.icon,
|
||||
.text,
|
||||
.arrow {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icons-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
border-top: 1px solid var(--art-border-color);
|
||||
border-left: 1px solid var(--art-border-color);
|
||||
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1 / 1;
|
||||
color: var(--art-gray-600);
|
||||
text-align: center;
|
||||
border-right: 1px solid var(--art-border-color);
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background: var(--art-gray-100);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 22px;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
src/components/core/base/ArtLogo.vue
Normal file
30
src/components/core/base/ArtLogo.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- System logo component -->
|
||||
<template>
|
||||
<div class="art-logo">
|
||||
<img :style="logoStyle" src="@imgs/common/logo.png" alt="logo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: Number,
|
||||
default: 36
|
||||
}
|
||||
})
|
||||
|
||||
const logoStyle = computed(() => ({ width: `${props.width}px` }))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.art-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
170
src/components/core/cards/ArtBarChartCard.vue
Normal file
170
src/components/core/cards/ArtBarChartCard.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="bar-chart-card art-custom-card" :style="{ height: `${height}rem` }">
|
||||
<div class="card-body">
|
||||
<div class="chart-header">
|
||||
<div class="metric">
|
||||
<p class="value">{{ value }}</p>
|
||||
<p class="label">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="percentage"
|
||||
:class="{ 'is-increase': percentage > 0, 'is-mini-chart': isMiniChart }"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div class="date" v-if="date" :class="{ 'is-mini-chart': isMiniChart }">{{ date }}</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="chart-container"
|
||||
:class="{ 'is-mini-chart': isMiniChart }"
|
||||
:style="{ height: `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
label: string
|
||||
percentage: number
|
||||
date?: string
|
||||
height?: number
|
||||
color?: string
|
||||
chartData: number[]
|
||||
barWidth?: string
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: 0,
|
||||
label: '',
|
||||
percentage: 0,
|
||||
date: '',
|
||||
height: 11,
|
||||
color: '',
|
||||
chartData: () => [],
|
||||
barWidth: '26%',
|
||||
isMiniChart: false
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 15,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-chart-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #303133;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
transition: 0.3s;
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
.value {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #f56c6c;
|
||||
|
||||
&.is-increase {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.is-mini-chart {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: calc(100% - 22px);
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
|
||||
&.is-mini-chart {
|
||||
position: absolute;
|
||||
inset: 25px 20px auto auto;
|
||||
width: 40%;
|
||||
height: 60px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
139
src/components/core/cards/ArtDataListCard.vue
Normal file
139
src/components/core/cards/ArtDataListCard.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="basic-list-card">
|
||||
<div class="art-card art-custom-card">
|
||||
<div class="card-header">
|
||||
<p class="card-title">{{ title }}</p>
|
||||
<p class="card-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<el-scrollbar :style="{ height: maxHeight }">
|
||||
<div v-for="(item, index) in list" :key="index" class="list-item">
|
||||
<div class="item-icon" :class="item.class" v-if="item.icon">
|
||||
<i class="iconfont-sys" v-html="item.icon"></i>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-status">{{ item.status }}</div>
|
||||
</div>
|
||||
<div class="item-time">{{ item.time }}</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<el-button class="more-btn" v-if="showMoreButton" v-ripple @click="handleMore"
|
||||
>查看更多</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const itemHeight = 66
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
list: Activity[]
|
||||
title: string
|
||||
subtitle?: string
|
||||
maxCount?: number
|
||||
showMoreButton?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
maxCount: 5,
|
||||
showMoreButton: false
|
||||
}
|
||||
)
|
||||
|
||||
const maxHeight = computed(() => {
|
||||
return `${itemHeight * (props.maxCount || 5)}px`
|
||||
})
|
||||
|
||||
interface Activity {
|
||||
title: string
|
||||
status: string
|
||||
time: string
|
||||
class: string
|
||||
icon: string
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
|
||||
const handleMore = () => {
|
||||
emit('more')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-list-card {
|
||||
.art-card {
|
||||
padding: 30px;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 15px;
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
|
||||
.item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-right: 12px;
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
|
||||
.item-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.item-time {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 100%;
|
||||
margin-top: 25px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
200
src/components/core/cards/ArtDonutChartCard.vue
Normal file
200
src/components/core/cards/ArtDonutChartCard.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="donut-chart-card art-custom-card" :style="{ height: `${height}rem` }">
|
||||
<div class="card-body">
|
||||
<div class="card-content">
|
||||
<div class="data-section">
|
||||
<p class="title">{{ title }}</p>
|
||||
<div>
|
||||
<p class="value">{{ formatNumber(value) }}</p>
|
||||
<div class="percentage" :class="{ 'is-increase': percentage > 0 }">
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}% 较去年
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
<span class="legend-item current">{{ currentYear }}</span>
|
||||
<span class="legend-item previous">{{ previousYear }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-section">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
title: string
|
||||
percentage: number
|
||||
currentYear?: string
|
||||
previousYear?: string
|
||||
height?: number
|
||||
color?: string
|
||||
radius?: [string, string]
|
||||
data: [number, number]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: 0,
|
||||
title: '',
|
||||
percentage: 0,
|
||||
currentYear: '2022',
|
||||
previousYear: '2021',
|
||||
height: 9,
|
||||
color: '',
|
||||
radius: () => ['70%', '90%'],
|
||||
data: () => [0, 0]
|
||||
})
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.data[0],
|
||||
name: props.currentYear,
|
||||
itemStyle: { color: computedColor }
|
||||
},
|
||||
{
|
||||
value: props.data[1],
|
||||
name: props.previousYear,
|
||||
itemStyle: { color: '#e6e8f7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.donut-chart-card {
|
||||
overflow: hidden;
|
||||
color: #303133;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
transition: 0.3s;
|
||||
|
||||
.card-body {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
max-width: 200px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
margin-top: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.percentage {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #f56c6c;
|
||||
|
||||
&.is-increase {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
.legend-item {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&.current::before {
|
||||
background-color: var(--main-color);
|
||||
}
|
||||
|
||||
&.previous::before {
|
||||
background-color: #e6e8f7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
143
src/components/core/cards/ArtImageCard.vue
Normal file
143
src/components/core/cards/ArtImageCard.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<!-- 图片卡片 -->
|
||||
<template>
|
||||
<div class="image-card">
|
||||
<el-card :body-style="{ padding: '0px' }" shadow="hover" class="art-custom-card">
|
||||
<!-- 图片区域 -->
|
||||
<div class="image-wrapper">
|
||||
<el-image :src="props.imageUrl" fit="cover" loading="lazy">
|
||||
<template #placeholder>
|
||||
<div class="image-placeholder">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<!-- 阅读时间标签 -->
|
||||
<div class="read-time" v-if="props.readTime"> {{ props.readTime }} 阅读 </div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content">
|
||||
<!-- 分类标签 -->
|
||||
<div class="category" v-if="props.category">
|
||||
{{ props.category }}
|
||||
</div>
|
||||
<!-- 标题 -->
|
||||
<p class="title">{{ props.title }}</p>
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats">
|
||||
<span class="views">
|
||||
<el-icon><View /></el-icon>
|
||||
{{ props.views }}
|
||||
</span>
|
||||
<span class="comments" v-if="props.comments !== undefined">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
{{ props.comments }}
|
||||
</span>
|
||||
<span class="date">{{ props.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
imageUrl: string
|
||||
title: string
|
||||
category?: string
|
||||
readTime?: string
|
||||
views: number
|
||||
comments?: number
|
||||
date: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-card {
|
||||
width: 100%;
|
||||
|
||||
.art-custom-card {
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/10; // 图片宽高比 16:10
|
||||
overflow: hidden;
|
||||
|
||||
.el-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.read-time {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 15px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: var(--art-gray-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
|
||||
.category {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--art-gray-200);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--art-text-gray-600);
|
||||
|
||||
.views,
|
||||
.comments {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
193
src/components/core/cards/ArtLineChartCard.vue
Normal file
193
src/components/core/cards/ArtLineChartCard.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="line-chart-card art-custom-card" :style="{ height: `${height}rem` }">
|
||||
<div class="card-body">
|
||||
<div class="chart-header">
|
||||
<div class="metric">
|
||||
<p class="value">{{ value }}</p>
|
||||
<p class="label">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="percentage"
|
||||
:class="{ 'is-increase': percentage > 0, 'is-mini-chart': isMiniChart }"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div class="date" v-if="date" :class="{ 'is-mini-chart': isMiniChart }">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="chart-container"
|
||||
:class="{ 'is-mini-chart': isMiniChart }"
|
||||
:style="{ height: `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
import { EChartsOption } from 'echarts'
|
||||
const { chartRef, isDark, initChart } = useChart()
|
||||
|
||||
interface Props {
|
||||
value: number
|
||||
label: string
|
||||
percentage: number
|
||||
date?: string
|
||||
height?: number
|
||||
color?: string
|
||||
showAreaColor?: boolean
|
||||
chartData: number[]
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: 0,
|
||||
label: '',
|
||||
percentage: 0,
|
||||
date: '',
|
||||
height: 11,
|
||||
color: '',
|
||||
showAreaColor: false,
|
||||
chartData: () => [],
|
||||
isMiniChart: false
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-chart-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 20px 20px 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
.value {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--art-text-gray-900);
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #f56c6c;
|
||||
|
||||
&.is-increase {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.is-mini-chart {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--art-text-gray-600);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
|
||||
&.is-mini-chart {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
right: 40px;
|
||||
left: auto;
|
||||
width: 40%;
|
||||
height: 60px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
140
src/components/core/cards/ArtProgressCard.vue
Normal file
140
src/components/core/cards/ArtProgressCard.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="progress-card art-custom-card">
|
||||
<div class="progress-info" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
|
||||
<div class="left">
|
||||
<i
|
||||
v-if="icon"
|
||||
class="iconfont-sys"
|
||||
v-html="icon"
|
||||
:style="{
|
||||
color: iconColor,
|
||||
backgroundColor: iconBgColor,
|
||||
fontSize: iconSize + 'px'
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
<div class="right">
|
||||
<CountTo
|
||||
:key="percentage"
|
||||
class="percentage"
|
||||
:style="{ textAlign: icon ? 'right' : 'left' }"
|
||||
:endVal="percentage"
|
||||
:duration="2000"
|
||||
suffix="%"
|
||||
/>
|
||||
<p class="title">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="currentPercentage"
|
||||
:stroke-width="strokeWidth"
|
||||
:show-text="false"
|
||||
:color="color"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { CountTo } from 'vue3-count-to'
|
||||
|
||||
interface Props {
|
||||
percentage: number
|
||||
title: string
|
||||
color?: string
|
||||
icon?: string
|
||||
iconColor?: string
|
||||
iconBgColor?: string
|
||||
iconSize?: number
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
strokeWidth: 5,
|
||||
color: '#67C23A'
|
||||
})
|
||||
|
||||
const animationDuration = 500
|
||||
const currentPercentage = ref(0)
|
||||
|
||||
const animateProgress = () => {
|
||||
const startTime = Date.now()
|
||||
const startValue = currentPercentage.value
|
||||
const endValue = props.percentage
|
||||
|
||||
const animate = () => {
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / animationDuration, 1)
|
||||
|
||||
currentPercentage.value = startValue + (endValue - startValue) * progress
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateProgress()
|
||||
})
|
||||
|
||||
// 当 percentage 属性变化时重新执行动画
|
||||
watch(
|
||||
() => props.percentage,
|
||||
() => {
|
||||
animateProgress()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 8rem;
|
||||
padding: 0 20px;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 4px);
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-block-end: 15px;
|
||||
|
||||
.left {
|
||||
i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background-color: var(--art-gray-300);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
.percentage {
|
||||
display: block;
|
||||
margin-block-end: 4px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-progress-bar__outer) {
|
||||
background-color: rgb(240 240 240);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
106
src/components/core/cards/ArtStatsCard.vue
Normal file
106
src/components/core/cards/ArtStatsCard.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<!-- 统计卡片 -->
|
||||
<template>
|
||||
<div class="stats-card art-custom-card" :style="{ backgroundColor: backgroundColor }">
|
||||
<div class="stats-card__icon" :style="{ backgroundColor: iconBgColor }">
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
v-html="icon"
|
||||
:style="{ color: iconColor, fontSize: iconSize + 'px' }"
|
||||
></i>
|
||||
</div>
|
||||
<div class="stats-card__content">
|
||||
<p class="stats-card__title" :style="{ color: textColor }" v-if="title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<CountTo v-if="count" class="stats-card__count" :endVal="count" :duration="1000"></CountTo>
|
||||
<p class="stats-card__description" :style="{ color: textColor }" v-if="description">{{
|
||||
description
|
||||
}}</p>
|
||||
</div>
|
||||
<div class="stats-card__arrow" v-if="showArrow">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CountTo } from 'vue3-count-to'
|
||||
|
||||
interface StatsCardProps {
|
||||
icon: string
|
||||
title?: string
|
||||
count?: number
|
||||
description: string
|
||||
iconColor?: string
|
||||
iconBgColor?: string
|
||||
iconSize?: number
|
||||
textColor?: string
|
||||
backgroundColor?: string
|
||||
showArrow?: boolean
|
||||
}
|
||||
|
||||
defineProps<StatsCardProps>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.stats-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 8rem;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) + 4px) !important;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin-right: 16px;
|
||||
border-radius: 50%;
|
||||
|
||||
i {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-600);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
i {
|
||||
font-size: 18px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/core/cards/ArtTimelineListCard.vue
Normal file
117
src/components/core/cards/ArtTimelineListCard.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="timeline-list-card">
|
||||
<div class="art-card art-custom-card">
|
||||
<div class="card-header">
|
||||
<p class="card-title">{{ title }}</p>
|
||||
<p class="card-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<el-scrollbar :style="{ height: maxHeight }">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="item in list"
|
||||
:key="item.time"
|
||||
:timestamp="item.time"
|
||||
:placement="TIMELINE_PLACEMENT"
|
||||
:color="item.status"
|
||||
:center="true"
|
||||
>
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-content">
|
||||
<span class="timeline-text">{{ item.content }}</span>
|
||||
<span v-if="item.code" class="timeline-code"> #{{ item.code }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 常量配置
|
||||
const ITEM_HEIGHT = 65
|
||||
const TIMELINE_PLACEMENT = 'top'
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
|
||||
interface TimelineItem {
|
||||
time: string
|
||||
status: string
|
||||
content: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
// Props 定义和验证
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
list: TimelineItem[]
|
||||
title: string
|
||||
subtitle?: string
|
||||
maxCount?: number
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
subtitle: '',
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
}
|
||||
)
|
||||
|
||||
// 计算最大高度
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timeline-list-card {
|
||||
.art-card {
|
||||
padding: 30px;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: var(--custom-radius);
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 15px;
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-timeline-item__tail) {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
:deep(.el-timeline-item__node--normal) {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.timeline-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline-code {
|
||||
font-size: 0.9em;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
101
src/components/core/charts/ArtBarChart.vue
Normal file
101
src/components/core/charts/ArtBarChart.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="bar-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChart } from '@/composables/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { EChartsOption } from 'echarts'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
interface Props {
|
||||
data?: number[]
|
||||
xAxisData?: string[]
|
||||
color?: string
|
||||
height?: string
|
||||
barWidth?: string | number
|
||||
showAxisLabel?: boolean
|
||||
showAxisLine?: boolean
|
||||
showSplitLine?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
color: '',
|
||||
height: useChartOps().chartHeight,
|
||||
barWidth: '40%',
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor =
|
||||
props.color ||
|
||||
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary')
|
||||
}
|
||||
])
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 15,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.data,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
color: computedColor
|
||||
},
|
||||
barWidth: props.barWidth
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
176
src/components/core/charts/ArtDualBarCompareChart.vue
Normal file
176
src/components/core/charts/ArtDualBarCompareChart.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="chart-container">
|
||||
<!-- 左侧 Y 轴 -->
|
||||
<div class="y-axis">
|
||||
<div v-for="(value, index) in yAxisLabels" :key="index" class="y-axis-label">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bars-container">
|
||||
<!-- 柱状图组 -->
|
||||
<div v-for="(item, index) in props.topData" :key="index" class="bar-group">
|
||||
<div class="bars-wrapper">
|
||||
<!-- 上半部分柱子 -->
|
||||
<div
|
||||
class="bar bar-top"
|
||||
:style="{
|
||||
height: `${getBarHeight(item, maxValue)}%`,
|
||||
background: props.topColor || defaultTopColor
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- 下半部分柱子 -->
|
||||
<div
|
||||
class="bar bar-bottom"
|
||||
:style="{
|
||||
height: `${getBarHeight(Math.abs(props.bottomData[index]), maxValue)}%`,
|
||||
background: props.bottomColor || defaultBottomColor
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- X轴标签 -->
|
||||
<div class="x-axis-label">{{ props.xAxisData[index] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
topData: number[]
|
||||
bottomData: number[]
|
||||
xAxisData: string[]
|
||||
topColor?: string
|
||||
bottomColor?: string
|
||||
height?: string
|
||||
barWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
topData: () => [2.5, 3, 2.8, 2.3, 2],
|
||||
bottomData: () => [-2, -2.2, -1.8, -3, -2.5],
|
||||
xAxisData: () => [],
|
||||
height: '300px',
|
||||
barWidth: 20
|
||||
})
|
||||
|
||||
// 默认渐变色
|
||||
const defaultTopColor = 'var(--el-color-primary-light-1)'
|
||||
const defaultBottomColor = 'rgb(var(--art-secondary), 1)'
|
||||
|
||||
// 计算最大值用于缩放
|
||||
const maxValue = computed(() => {
|
||||
const allValues = [...props.topData, ...props.bottomData.map(Math.abs)]
|
||||
return Math.max(...allValues)
|
||||
})
|
||||
|
||||
// 生成Y轴刻度
|
||||
const yAxisLabels = computed(() => {
|
||||
const max = Math.ceil(maxValue.value)
|
||||
return [max, max / 2, 0, max / 2, max]
|
||||
})
|
||||
|
||||
// 计算柱子高度的百分比
|
||||
const getBarHeight = (value: number, max: number) => {
|
||||
return (value / max) * 40 // 40% 作为最大高度,留出空间给其他元素
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: v-bind('props.height');
|
||||
padding: 20px 0;
|
||||
|
||||
.y-axis {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 40px;
|
||||
padding-right: 10px;
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.bars-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 20px 0;
|
||||
margin-left: -10px;
|
||||
|
||||
.bar-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
|
||||
.bars-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
width: v-bind('props.barWidth + "px"');
|
||||
border-radius: v-bind('props.barWidth / 2 + "px"');
|
||||
transition: height 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: center;
|
||||
|
||||
&-top {
|
||||
bottom: calc(50% + 2px);
|
||||
transform: scaleY(0);
|
||||
animation: growUp 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
&-bottom {
|
||||
top: calc(50% + 2px);
|
||||
transform: scaleY(0);
|
||||
animation: growDown 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.x-axis-label {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes growUp {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes growDown {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
97
src/components/core/charts/ArtHBarChart.vue
Normal file
97
src/components/core/charts/ArtHBarChart.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="h-bar-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
interface Props {
|
||||
data?: number[]
|
||||
xAxisData?: string[]
|
||||
color?: string
|
||||
height?: string
|
||||
barWidth?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
color: '',
|
||||
height: useChartOps().chartHeight,
|
||||
barWidth: '36%'
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor =
|
||||
props.color ||
|
||||
new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary-light-3')
|
||||
}
|
||||
])
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 10,
|
||||
right: 10,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(),
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
splitLine: getSplitLineStyle()
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle()
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.data,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 4
|
||||
},
|
||||
barWidth: props.barWidth
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
101
src/components/core/charts/ArtKLineChart.vue
Normal file
101
src/components/core/charts/ArtKLineChart.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="k-line-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
interface KLineDataItem {
|
||||
time: string
|
||||
open: number
|
||||
close: number
|
||||
high: number
|
||||
low: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data?: KLineDataItem[]
|
||||
height?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [
|
||||
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
|
||||
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
|
||||
{ time: '2024-01-03', open: 21, close: 25, high: 26, low: 21 }
|
||||
],
|
||||
height: useChartOps().chartHeight
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
left: 60
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params: any) => {
|
||||
const item = params[0]
|
||||
const data = item.data
|
||||
return `
|
||||
时间:${data[0]}<br/>
|
||||
开盘:${data[1]}<br/>
|
||||
收盘:${data[2]}<br/>
|
||||
最低:${data[3]}<br/>
|
||||
最高:${data[4]}<br/>
|
||||
`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
axisTick: getAxisTickStyle(),
|
||||
data: props.data.map((item) => item.time),
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle()
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle(),
|
||||
splitLine: getSplitLineStyle()
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'candlestick',
|
||||
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
|
||||
itemStyle: {
|
||||
color: '#4C87F3', // 上涨颜色
|
||||
color0: '#8BD8FC', // 下跌颜色
|
||||
borderColor: '#4C87F3', // 上涨边框颜色
|
||||
borderColor0: '#8BD8FC' // 下跌边框颜色
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
117
src/components/core/charts/ArtLineChart.vue
Normal file
117
src/components/core/charts/ArtLineChart.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="line-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
interface Props {
|
||||
data: number[]
|
||||
xAxisData?: string[]
|
||||
height?: string
|
||||
color?: string
|
||||
lineWidth?: number
|
||||
showAreaColor?: boolean
|
||||
showAxisLabel?: boolean
|
||||
showAxisLine?: boolean
|
||||
showSplitLine?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: useChartOps().chartHeight,
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
color: '',
|
||||
lineWidth: 3,
|
||||
showAreaColor: false,
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 15,
|
||||
right: 15,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.data,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: props.lineWidth,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-chart {
|
||||
width: calc(100% + 10px);
|
||||
}
|
||||
</style>
|
||||
213
src/components/core/charts/ArtMapChart.vue
Normal file
213
src/components/core/charts/ArtMapChart.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="map-container" style="height: calc(100vh - 120px)">
|
||||
<div id="china-map" ref="chinaMapRef" class="china-map"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import chinaMapJson from '@/mock/json/chinaMap.json'
|
||||
|
||||
// 响应式引用与主题
|
||||
const chinaMapRef = ref<HTMLElement | null>(null)
|
||||
const chartInstance = ref<echarts.ECharts | null>(null)
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
// 定义 emit
|
||||
const emit = defineEmits<{
|
||||
(e: 'onRenderComplete'): void
|
||||
}>()
|
||||
|
||||
// 根据 geoJson 数据准备地图数据(这里数据值随机,仅供示例)
|
||||
const prepareMapData = (geoJson: any) => {
|
||||
return geoJson.features.map((feature: any) => ({
|
||||
name: feature.properties.name,
|
||||
value: Math.round(Math.random() * 1000),
|
||||
adcode: feature.properties.adcode,
|
||||
level: feature.properties.level,
|
||||
selected: false
|
||||
}))
|
||||
}
|
||||
|
||||
// 构造 ECharts 配置项
|
||||
const createChartOption = (mapData: any[]) => ({
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: ({ data }: any) => {
|
||||
const { name, adcode, level } = data || {}
|
||||
return `
|
||||
<div>
|
||||
<p>名称: <span>${name || '未知区域'}</span></p>
|
||||
<p>代码: <span>${adcode || '暂无'}</span></p>
|
||||
<p>级别: <span>${level || '暂无'}</span></p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
geo: {
|
||||
map: 'china',
|
||||
zoom: 1,
|
||||
show: true,
|
||||
roam: false,
|
||||
layoutSize: '100%',
|
||||
emphasis: { label: { show: false } },
|
||||
itemStyle: {
|
||||
borderColor: isDark.value ? 'rgba(255,255,255,0.6)' : 'rgba(147,235,248,1)',
|
||||
borderWidth: 2,
|
||||
shadowColor: isDark.value ? 'rgba(0,0,0,0.8)' : 'rgba(128,217,248,1)',
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 15,
|
||||
shadowBlur: 15
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'china',
|
||||
aspectScale: 0.75,
|
||||
zoom: 1,
|
||||
label: {
|
||||
show: true,
|
||||
color: '#fff',
|
||||
fontSize: 10
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: 'rgba(147,235,248,0.8)',
|
||||
borderWidth: 2,
|
||||
areaColor: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(147,235,248,0.3)' },
|
||||
{ offset: 1, color: 'rgba(32,120,207,0.9)' }
|
||||
]
|
||||
},
|
||||
shadowColor: 'rgba(32,120,207,1)',
|
||||
shadowOffsetY: 15,
|
||||
shadowBlur: 20
|
||||
},
|
||||
emphasis: {
|
||||
label: { show: true, color: '#fff' },
|
||||
itemStyle: {
|
||||
areaColor: 'rgba(82,180,255,0.9)',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}
|
||||
},
|
||||
// 增强光照与3D效果
|
||||
light: {
|
||||
main: { intensity: 1.5, shadow: true, alpha: 40, beta: 45 },
|
||||
ambient: { intensity: 0.3 }
|
||||
},
|
||||
viewControl: {
|
||||
distance: 120,
|
||||
alpha: 30,
|
||||
beta: 5,
|
||||
center: [104, 36],
|
||||
pitch: 10
|
||||
},
|
||||
// 配置区域选中样式
|
||||
select: {
|
||||
label: { show: true, color: '#fff' },
|
||||
itemStyle: {
|
||||
areaColor: '#4FAEFB',
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
data: mapData
|
||||
},
|
||||
// 散点标记配置(例如:城市标记)
|
||||
{
|
||||
name: '城市',
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'geo',
|
||||
symbol: 'pin',
|
||||
symbolSize: 15,
|
||||
label: { show: false },
|
||||
itemStyle: {
|
||||
color: '#F99020',
|
||||
shadowBlur: 10,
|
||||
shadowColor: '#333'
|
||||
},
|
||||
data: [
|
||||
{ name: '北京', value: [116.405285, 39.904989, 100] },
|
||||
{ name: '上海', value: [121.472644, 31.231706, 100] },
|
||||
{ name: '深圳', value: [114.085947, 22.547, 100] }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 初始化并渲染地图
|
||||
const initMap = async () => {
|
||||
if (!chinaMapRef.value) return
|
||||
|
||||
chartInstance.value = echarts.init(chinaMapRef.value)
|
||||
chartInstance.value.showLoading()
|
||||
|
||||
try {
|
||||
echarts.registerMap('china', chinaMapJson as any)
|
||||
const mapData = prepareMapData(chinaMapJson)
|
||||
const option = createChartOption(mapData)
|
||||
chartInstance.value.setOption(option)
|
||||
chartInstance.value.hideLoading()
|
||||
|
||||
// 触发渲染完成事件
|
||||
emit('onRenderComplete')
|
||||
|
||||
// 点击事件:选中地图区域
|
||||
chartInstance.value.on('click', (params: any) => {
|
||||
console.log(`选中区域: ${params.name}`, params)
|
||||
chartInstance.value?.dispatchAction({
|
||||
type: 'select',
|
||||
seriesIndex: 0,
|
||||
dataIndex: params.dataIndex
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载地图数据失败:', error)
|
||||
chartInstance.value?.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口 resize 时调整图表大小
|
||||
const resizeChart = () => chartInstance.value?.resize()
|
||||
|
||||
onMounted(() => {
|
||||
initMap().then(() => setTimeout(resizeChart, 100))
|
||||
window.addEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstance.value?.dispose()
|
||||
chartInstance.value = null
|
||||
window.removeEventListener('resize', resizeChart)
|
||||
})
|
||||
|
||||
// 监听主题变化,重新初始化地图
|
||||
watch(isDark, (newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
chartInstance.value?.dispose()
|
||||
chartInstance.value = null
|
||||
nextTick(initMap)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.map-container {
|
||||
width: 100%;
|
||||
|
||||
.china-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/components/core/charts/ArtRadarChart.vue
Normal file
103
src/components/core/charts/ArtRadarChart.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="radar-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
const { chartRef, initChart, isDark } = useChart()
|
||||
|
||||
interface RadarDataItem {
|
||||
name: string
|
||||
value: number[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
indicator?: Array<{ name: string; max: number }>
|
||||
data?: RadarDataItem[]
|
||||
height?: string
|
||||
colors?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
indicator: () => [
|
||||
{ name: '销售', max: 100 },
|
||||
{ name: '管理', max: 100 },
|
||||
{ name: '技术', max: 100 },
|
||||
{ name: '客服', max: 100 },
|
||||
{ name: '开发', max: 100 }
|
||||
],
|
||||
data: () => [
|
||||
{
|
||||
name: '预算分配',
|
||||
value: [80, 70, 90, 85, 75]
|
||||
},
|
||||
{
|
||||
name: '实际开销',
|
||||
value: [70, 75, 85, 80, 70]
|
||||
}
|
||||
],
|
||||
height: useChartOps().chartHeight,
|
||||
colors: () => ['#67C23A', '#409EFF']
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
data: props.data.map((item) => item.name),
|
||||
bottom: '0',
|
||||
textStyle: {
|
||||
color: isDark.value ? '#fff' : '#333'
|
||||
}
|
||||
},
|
||||
radar: {
|
||||
indicator: props.indicator,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
},
|
||||
axisName: {
|
||||
color: '#999'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
data: props.data.map((item, index) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.colors[index]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.colors[index]
|
||||
},
|
||||
areaStyle: {
|
||||
color: props.colors[index],
|
||||
opacity: 0.2
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
96
src/components/core/charts/ArtRingChart.vue
Normal file
96
src/components/core/charts/ArtRingChart.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="ring-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
const { chartRef, initChart, isDark } = useChart()
|
||||
|
||||
interface Props {
|
||||
data?: Array<{ value: number; name: string }>
|
||||
height?: string
|
||||
color?: string[]
|
||||
radius?: string[]
|
||||
borderRadius?: number
|
||||
centerText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [
|
||||
{ value: 35, name: '分类1' },
|
||||
{ value: 25, name: '分类2' },
|
||||
{ value: 20, name: '分类3' },
|
||||
{ value: 20, name: '分类4' }
|
||||
],
|
||||
height: useChartOps().chartHeight,
|
||||
color: () => [],
|
||||
radius: () => ['50%', '80%'],
|
||||
borderRadius: 10,
|
||||
centerText: ''
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const opt: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {d}%'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '数据占比',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: props.borderRadius
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
formatter: '{b}\n{d}%',
|
||||
position: 'outside',
|
||||
color: '#999'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 15,
|
||||
length2: 25,
|
||||
smooth: true
|
||||
},
|
||||
data: props.data,
|
||||
color: props.color
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (props.centerText) {
|
||||
opt.title = {
|
||||
text: props.centerText,
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: '#ADB0BC'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
85
src/components/core/charts/ArtScatterChart.vue
Normal file
85
src/components/core/charts/ArtScatterChart.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<!-- 散点图 -->
|
||||
<template>
|
||||
<div ref="chartRef" class="scatter-chart" :style="{ height: props.height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useChart, useChartOps } from '@/composables/useChart'
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle
|
||||
} = useChart()
|
||||
|
||||
interface Props {
|
||||
data?: { value: number[] }[]
|
||||
color?: string
|
||||
height?: string
|
||||
symbolSize?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
||||
color: '',
|
||||
height: useChartOps().chartHeight,
|
||||
symbolSize: 14
|
||||
})
|
||||
|
||||
const options: () => EChartsOption = () => {
|
||||
const computedColor = props.color || getCssVar('--main-color')
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 10,
|
||||
right: 10,
|
||||
bottom: 0,
|
||||
left: 3,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: function (params: any) {
|
||||
return `(${params.value[0]}, ${params.value[1]})`
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle(),
|
||||
splitLine: getSplitLineStyle()
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(),
|
||||
axisLine: getAxisLineStyle(),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle()
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.data,
|
||||
type: 'scatter',
|
||||
itemStyle: {
|
||||
color: computedColor
|
||||
},
|
||||
symbolSize: props.symbolSize
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
initChart(options())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initChart(options())
|
||||
})
|
||||
</script>
|
||||
34
src/components/core/forms/ArtButtonMore.vue
Normal file
34
src/components/core/forms/ArtButtonMore.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="btn-more">
|
||||
<el-dropdown>
|
||||
<ArtButtonTable type="more" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="item in list" :key="item.key" @click="handleClick(item)">
|
||||
{{ item.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface ButtonMoreItem {
|
||||
key: string | number
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
list: ButtonMoreItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', item: ButtonMoreItem): void
|
||||
}>()
|
||||
|
||||
const handleClick = (item: ButtonMoreItem) => {
|
||||
emit('click', item)
|
||||
}
|
||||
</script>
|
||||
79
src/components/core/forms/ArtButtonTable.vue
Normal file
79
src/components/core/forms/ArtButtonTable.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- 表格按钮,支持文字和图标 -->
|
||||
<template>
|
||||
<div :class="['btn-text', buttonColor]" @click="handleClick">
|
||||
<i v-if="iconContent" class="iconfont-sys" v-html="iconContent" :style="iconStyle"></i>
|
||||
<span v-if="props.text">{{ props.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BgColorEnum } from '@/enums/appEnum'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
text?: string
|
||||
type?: 'add' | 'edit' | 'delete' | 'more'
|
||||
icon?: string // 自定义图标
|
||||
iconClass?: BgColorEnum // 自定义按钮背景色、文字颜色
|
||||
iconColor?: string // 外部传入的图标文字颜色
|
||||
iconBgColor?: string // 外部传入的图标背景色
|
||||
}>(),
|
||||
{}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
// 默认按钮配置:type 对应的图标和默认颜色
|
||||
const defaultButtons = [
|
||||
{ type: 'add', icon: '', color: BgColorEnum.PRIMARY },
|
||||
{ type: 'edit', icon: '', color: BgColorEnum.SECONDARY },
|
||||
{ type: 'delete', icon: '', color: BgColorEnum.ERROR },
|
||||
{ type: 'more', icon: '', color: '' }
|
||||
] as const
|
||||
|
||||
// 计算最终使用的图标:优先使用外部传入的 icon,否则根据 type 获取默认图标
|
||||
const iconContent = computed(() => {
|
||||
return props.icon || defaultButtons.find((btn) => btn.type === props.type)?.icon || ''
|
||||
})
|
||||
|
||||
// 计算按钮的背景色:优先使用外部传入的 iconClass,否则根据 type 选默认颜色
|
||||
const buttonColor = computed(() => {
|
||||
return props.iconClass || defaultButtons.find((btn) => btn.type === props.type)?.color || ''
|
||||
})
|
||||
|
||||
// 计算图标的颜色与背景色,支持外部传入
|
||||
const iconStyle = computed(() => {
|
||||
return {
|
||||
...(props.iconColor ? { color: props.iconColor } : {}),
|
||||
...(props.iconBgColor ? { backgroundColor: props.iconBgColor } : {})
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn-text {
|
||||
display: inline-block;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
margin-right: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 34px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
302
src/components/core/forms/ArtDragVerify.vue
Normal file
302
src/components/core/forms/ArtDragVerify.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dragVerify"
|
||||
class="drag_verify"
|
||||
:style="dragVerifyStyle"
|
||||
@mousemove="dragMoving"
|
||||
@mouseup="dragFinish"
|
||||
@mouseleave="dragFinish"
|
||||
@touchmove="dragMoving"
|
||||
@touchend="dragFinish"
|
||||
>
|
||||
<div
|
||||
class="dv_progress_bar"
|
||||
:class="{ goFirst2: isOk }"
|
||||
ref="progressBar"
|
||||
:style="progressBarStyle"
|
||||
>
|
||||
</div>
|
||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||
{{ message }}
|
||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dv_handler dv_handler_bg"
|
||||
:class="{ goFirst: isOk }"
|
||||
@mousedown="dragStart"
|
||||
@touchstart="dragStart"
|
||||
ref="handler"
|
||||
:style="handlerStyle"
|
||||
>
|
||||
<i class="iconfont-sys" v-html="value ? successIcon : handlerIcon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
|
||||
interface PropsType {
|
||||
value: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
text?: string
|
||||
successText?: string
|
||||
background?: string
|
||||
progressBarBg?: string
|
||||
completedBg?: string
|
||||
circle?: boolean
|
||||
radius?: string
|
||||
handlerIcon?: string
|
||||
successIcon?: string
|
||||
handlerBg?: string
|
||||
textSize?: string
|
||||
textColor?: string
|
||||
}
|
||||
const props = withDefaults(defineProps<PropsType>(), {
|
||||
value: false,
|
||||
width: 260,
|
||||
height: 38,
|
||||
text: '按住滑块拖动',
|
||||
successText: 'success',
|
||||
background: '#eee',
|
||||
progressBarBg: '#1385FF',
|
||||
completedBg: '#57D187',
|
||||
circle: false,
|
||||
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
||||
handlerIcon: '',
|
||||
successIcon: '',
|
||||
handlerBg: '#fff',
|
||||
textSize: '13px',
|
||||
textColor: '#333'
|
||||
})
|
||||
interface stateType {
|
||||
isMoving: boolean
|
||||
x: number
|
||||
isOk: boolean
|
||||
}
|
||||
const state = reactive(<stateType>{
|
||||
isMoving: false,
|
||||
x: 0,
|
||||
isOk: false
|
||||
})
|
||||
const { isOk } = toRefs(state)
|
||||
const dragVerify = ref()
|
||||
const messageRef = ref()
|
||||
const handler = ref()
|
||||
const progressBar = ref()
|
||||
|
||||
// 禁止横向滑动
|
||||
let startX: number, startY: number, moveX: number, moveY: number
|
||||
|
||||
const onTouchStart = (e: any) => {
|
||||
startX = e.targetTouches[0].pageX
|
||||
startY = e.targetTouches[0].pageY
|
||||
}
|
||||
|
||||
const onTouchMove = (e: any) => {
|
||||
moveX = e.targetTouches[0].pageX
|
||||
moveY = e.targetTouches[0].pageY
|
||||
|
||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
|
||||
onMounted(() => {
|
||||
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
||||
dragVerify.value?.style.setProperty('--width', Math.floor(props.width / 2) + 'px')
|
||||
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(props.width / 2) + 'px')
|
||||
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
})
|
||||
const handlerStyle = {
|
||||
left: '0',
|
||||
width: props.height + 'px',
|
||||
height: props.height + 'px',
|
||||
background: props.handlerBg
|
||||
}
|
||||
const dragVerifyStyle = {
|
||||
width: props.width + 'px',
|
||||
height: props.height + 'px',
|
||||
lineHeight: props.height + 'px',
|
||||
background: props.background,
|
||||
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
||||
}
|
||||
const progressBarStyle = {
|
||||
background: props.progressBarBg,
|
||||
height: props.height + 'px',
|
||||
borderRadius: props.circle
|
||||
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
||||
: props.radius
|
||||
}
|
||||
const textStyle = {
|
||||
height: props.height + 'px',
|
||||
width: props.width + 'px',
|
||||
fontSize: props.textSize
|
||||
}
|
||||
const message = computed(() => {
|
||||
return props.value ? props.successText : props.text
|
||||
})
|
||||
|
||||
const dragStart = (e: any) => {
|
||||
if (!props.value) {
|
||||
state.isMoving = true
|
||||
handler.value.style.transition = 'none'
|
||||
state.x =
|
||||
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
|
||||
}
|
||||
emit('handlerMove')
|
||||
}
|
||||
const dragMoving = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
||||
if (_x > 0 && _x <= props.width - props.height) {
|
||||
handler.value.style.left = _x + 'px'
|
||||
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
||||
} else if (_x > props.width - props.height) {
|
||||
handler.value.style.left = props.width - props.height + 'px'
|
||||
progressBar.value.style.width = props.width - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
}
|
||||
}
|
||||
const dragFinish = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
||||
if (_x < props.width - props.height) {
|
||||
state.isOk = true
|
||||
handler.value.style.left = '0'
|
||||
handler.value.style.transition = 'all 0.2s'
|
||||
progressBar.value.style.width = '0'
|
||||
state.isOk = false
|
||||
} else {
|
||||
handler.value.style.transition = 'none'
|
||||
handler.value.style.left = props.width - props.height + 'px'
|
||||
progressBar.value.style.width = props.width - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
state.isMoving = false
|
||||
}
|
||||
}
|
||||
const passVerify = () => {
|
||||
emit('update:value', true)
|
||||
state.isMoving = false
|
||||
progressBar.value.style.background = props.completedBg
|
||||
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
|
||||
messageRef.value.style.animation = 'slidetounlock2 3s infinite'
|
||||
messageRef.value.style.color = '#fff'
|
||||
emit('passCallback')
|
||||
}
|
||||
const reset = () => {
|
||||
handler.value.style.left = '0'
|
||||
progressBar.value.style.width = '0'
|
||||
handler.value.children[0].innerHTML = props.handlerIcon
|
||||
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
|
||||
messageRef.value.style.animation = 'slidetounlock 3s infinite'
|
||||
messageRef.value.style.color = props.background
|
||||
emit('update:value', false)
|
||||
state.isOk = false
|
||||
state.isMoving = false
|
||||
state.x = 0
|
||||
}
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.drag_verify {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
|
||||
.dv_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: move;
|
||||
|
||||
i {
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.el-icon-circle-check {
|
||||
margin-top: 9px;
|
||||
color: #6c6;
|
||||
}
|
||||
}
|
||||
|
||||
.dv_progress_bar {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.dv_text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--textColor) 0%,
|
||||
var(--textColor) 40%,
|
||||
#fff 50%,
|
||||
var(--textColor) 60%,
|
||||
var(--textColor) 100%
|
||||
);
|
||||
background-clip: text;
|
||||
animation: slidetounlock 3s infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-size-adjust: none;
|
||||
|
||||
* {
|
||||
-webkit-text-fill-color: var(--textColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goFirst {
|
||||
left: 0 !important;
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.goFirst2 {
|
||||
width: 0 !important;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes slidetounlock {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slidetounlock2 {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/components/core/forms/ArtExcelExport.vue
Normal file
126
src/components/core/forms/ArtExcelExport.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<el-button :type="type" :loading="isExporting" v-ripple @click="handleExport">
|
||||
<slot>导出 Excel</slot>
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ref } from 'vue'
|
||||
import type { ButtonType } from 'element-plus'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
|
||||
interface ExportData {
|
||||
[key: string]: string | number | boolean | null
|
||||
}
|
||||
|
||||
interface ExportOptions {
|
||||
data: ExportData[]
|
||||
filename?: string
|
||||
sheetName?: string
|
||||
type?: ButtonType
|
||||
autoIndex?: boolean
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: 'export',
|
||||
sheetName: 'Sheet1',
|
||||
type: 'primary',
|
||||
autoIndex: false,
|
||||
headers: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'export-success': []
|
||||
'export-error': [error: Error]
|
||||
}>()
|
||||
|
||||
const isExporting = ref(false)
|
||||
|
||||
const processData = (data: ExportData[]): ExportData[] => {
|
||||
const processedData = [...data]
|
||||
|
||||
if (props.autoIndex) {
|
||||
return processedData.map((item, index) => ({
|
||||
序号: index + 1,
|
||||
...Object.entries(item).reduce(
|
||||
(acc: Record<string, any>, [key, value]) => {
|
||||
acc[props.headers[key] || key] = value
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
if (Object.keys(props.headers).length > 0) {
|
||||
return processedData.map((item) =>
|
||||
Object.entries(item).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[props.headers[key] || key]: value
|
||||
}),
|
||||
{}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return processedData
|
||||
}
|
||||
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const processedData = processData(data)
|
||||
const workbook = XLSX.utils.book_new()
|
||||
const worksheet = XLSX.utils.json_to_sheet(processedData)
|
||||
|
||||
const maxWidth = 50
|
||||
const minWidth = 10
|
||||
const columnWidths = Object.keys(processedData[0] || {}).map((key) => {
|
||||
const keyLength = key.length
|
||||
return { wch: Math.min(Math.max(keyLength, minWidth), maxWidth) }
|
||||
})
|
||||
worksheet['!cols'] = columnWidths
|
||||
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
||||
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'array'
|
||||
})
|
||||
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
FileSaver.saveAs(blob, `${filename}.xlsx`)
|
||||
} catch (error) {
|
||||
throw new Error(`Excel 导出失败: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return
|
||||
|
||||
isExporting.value = true
|
||||
try {
|
||||
if (!props.data?.length) {
|
||||
throw new Error('没有可导出的数据')
|
||||
}
|
||||
|
||||
await exportToExcel(props.data, props.filename, props.sheetName)
|
||||
emit('export-success')
|
||||
} catch (error) {
|
||||
emit('export-error', error as Error)
|
||||
console.error('Excel 导出错误:', error)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}, 1000)
|
||||
</script>
|
||||
65
src/components/core/forms/ArtExcelImport.vue
Normal file
65
src/components/core/forms/ArtExcelImport.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="excel-uploader">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<el-button type="primary" v-ripple>
|
||||
<slot name="import-text">导入 Excel</slot>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<any[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||
resolve(results)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
'import-success': [data: any[]]
|
||||
'import-error': [error: Error]
|
||||
}>()
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return
|
||||
const results = await importExcel(uploadFile.raw)
|
||||
emit('import-success', results)
|
||||
} catch (error) {
|
||||
emit('import-error', error as Error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.excel-uploader {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
386
src/components/core/forms/ArtWangEditor.vue
Normal file
386
src/components/core/forms/ArtWangEditor.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<!-- 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
style="height: 700px; overflow-y: hidden"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { IDomEditor } from '@wangeditor/editor'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef()
|
||||
let mode = ref('default')
|
||||
const userStore = useUserStore()
|
||||
// token
|
||||
let { accessToken } = userStore
|
||||
// 图片上传地址
|
||||
let server = `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
||||
|
||||
const toolbarConfig = {
|
||||
// 重新配置工具栏,显示哪些菜单,以及菜单的排序、分组。
|
||||
// toolbarKeys: [],
|
||||
// 可以在当前 toolbarKeys 的基础上继续插入新菜单,如自定义扩展的菜单。
|
||||
// insertKeys: {
|
||||
// index: 5, // 插入的位置,基于当前的 toolbarKeys
|
||||
// keys: ['menu-key1', 'menu-key2']
|
||||
// }
|
||||
// 排除某些菜单
|
||||
excludeKeys: ['fontFamily'] //'group-video', 'fontSize', 'lineHeight'
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: '请输入内容...',
|
||||
MENU_CONF: {
|
||||
// 上传图片
|
||||
uploadImage: {
|
||||
fieldName: 'file',
|
||||
maxFileSize: 3 * 1024 * 1024, // 大小限制
|
||||
maxNumberOfFiles: 10, // 最多可上传几个文件,默认为 100
|
||||
allowedFileTypes: ['image/*'], // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||
// 注意 ${import.meta.env.VITE_BASE_URL} 写你自己的后端服务地址
|
||||
server,
|
||||
// 传递token
|
||||
headers: { Authorization: accessToken },
|
||||
// 单个文件上传成功之后
|
||||
onSuccess() {
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
},
|
||||
// 上传错误,或者触发 timeout 超时
|
||||
onError(file: File, err: any, res: any) {
|
||||
console.log(`上传出错`, err, res)
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
// 注意:返回格式需要按照指定格式返回,才能显示图片
|
||||
// 上传成功的返回格式:
|
||||
// "errno": 0, // 注意:值是数字,不能是字符串
|
||||
// "data": {
|
||||
// "url": "xxx", // 图片 src ,必须
|
||||
// "alt": "yyy", // 图片描述文字,非必须
|
||||
// "href": "zzz" // 图片的链接,非必须
|
||||
// }
|
||||
// }
|
||||
// 上传失败的返回格式:
|
||||
// {
|
||||
// "errno": 1, // 只要不等于 0 就行
|
||||
// "message": "失败信息"
|
||||
// }
|
||||
}
|
||||
// 代码语言
|
||||
// codeLangs: [
|
||||
// { text: 'CSS', value: 'css' },
|
||||
// { text: 'HTML', value: 'html' },
|
||||
// { text: 'XML', value: 'xml' },
|
||||
// ]
|
||||
}
|
||||
}
|
||||
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor // 记录 editor 实例
|
||||
// editorRef.value.setHtml('内容')
|
||||
// getToolbar(editor)
|
||||
|
||||
editor.on('fullScreen', () => {
|
||||
console.log('fullScreen')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
overrideIcon()
|
||||
})
|
||||
|
||||
// 替换默认图标
|
||||
const overrideIcon = () => {
|
||||
setTimeout(() => {
|
||||
const icon0 = document.querySelector(`button[data-menu-key="bold"]`)
|
||||
if (icon0) icon0.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon1 = document.querySelector(`button[data-menu-key="blockquote"]`)
|
||||
if (icon1) icon1.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon2 = document.querySelector(`button[data-menu-key="underline"]`)
|
||||
if (icon2) icon2.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon3 = document.querySelector(`button[data-menu-key="italic"]`)
|
||||
if (icon3) icon3.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon4 = document.querySelector(`button[data-menu-key="group-more-style"]`)
|
||||
if (icon4) icon4.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon5 = document.querySelector(`button[data-menu-key="color"]`)
|
||||
if (icon5) icon5.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon6 = document.querySelector(`button[data-menu-key="bgColor"]`)
|
||||
if (icon6) icon6.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon7 = document.querySelector(`button[data-menu-key="bulletedList"]`)
|
||||
if (icon7) icon7.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon8 = document.querySelector(`button[data-menu-key="numberedList"]`)
|
||||
if (icon8) icon8.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon9 = document.querySelector(`button[data-menu-key="todo"]`)
|
||||
if (icon9) icon9.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon10 = document.querySelector(`button[data-menu-key="group-justify"]`)
|
||||
if (icon10) icon10.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon11 = document.querySelector(`button[data-menu-key="group-indent"]`)
|
||||
if (icon11) icon11.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon12 = document.querySelector(`button[data-menu-key="emotion"]`)
|
||||
if (icon12) icon12.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon13 = document.querySelector(`button[data-menu-key="insertLink"]`)
|
||||
if (icon13) icon13.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon14 = document.querySelector(`button[data-menu-key="group-image"]`)
|
||||
if (icon14) icon14.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon15 = document.querySelector(`button[data-menu-key="insertTable"]`)
|
||||
if (icon15) icon15.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon16 = document.querySelector(`button[data-menu-key="codeBlock"]`)
|
||||
if (icon16) icon16.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon17 = document.querySelector(`button[data-menu-key="divider"]`)
|
||||
if (icon17) icon17.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon18 = document.querySelector(`button[data-menu-key="undo"]`)
|
||||
if (icon18) icon18.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon19 = document.querySelector(`button[data-menu-key="redo"]`)
|
||||
if (icon19) icon19.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon20 = document.querySelector(`button[data-menu-key="fullScreen"]`)
|
||||
if (icon20) icon20.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
|
||||
const icon21 = document.querySelector(`button[data-menu-key="tableFullWidth"]`)
|
||||
if (icon21) icon21.innerHTML = "<i class='iconfont-sys'></i>"
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// 获取工具栏
|
||||
// const getToolbar = (editor: IDomEditor) => {
|
||||
// setTimeout(() => {
|
||||
// const toolbar = DomEditor.getToolbar(editor)
|
||||
// console.log(toolbar?.getConfig().toolbarKeys) // 当前菜单排序和分组
|
||||
// }, 300)
|
||||
// }
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
$box-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-wrapper {
|
||||
z-index: 5000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid rgba(var(--art-gray-300-rgb), 0.8);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.iconfont-sys {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
|
||||
/* 表格选中时候的边框颜色 */
|
||||
--w-e-textarea-selected-border-color: #ddd;
|
||||
|
||||
/* 表格头背景颜色 */
|
||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-100);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.25);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
src/components/core/forms/art-search-bar/index.vue
Normal file
201
src/components/core/forms/art-search-bar/index.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<section class="search-bar art-custom-card">
|
||||
<el-form :model="filter" :label-position="props.labelPosition">
|
||||
<el-row class="search-form-row" :gutter="props.gutter">
|
||||
<el-col
|
||||
v-for="item in useFormItemArr"
|
||||
:key="item.prop"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="item.elColSpan || props.elColSpan"
|
||||
:lg="item.elColSpan || props.elColSpan"
|
||||
:xl="item.elColSpan || props.elColSpan"
|
||||
>
|
||||
<el-form-item :label="`${item.label}`" :prop="item.prop" :label-width="props.labelWidth">
|
||||
<component
|
||||
:is="getComponent(item.type)"
|
||||
v-model:value="filter[item.prop]"
|
||||
:item="item"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col
|
||||
:xs="24"
|
||||
:sm="24"
|
||||
:md="props.elColSpan"
|
||||
:lg="props.elColSpan"
|
||||
:xl="props.elColSpan"
|
||||
class="action-column"
|
||||
>
|
||||
<div
|
||||
class="action-buttons-wrapper"
|
||||
:style="{
|
||||
'justify-content': isMobile
|
||||
? 'flex-end'
|
||||
: props.items.length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}"
|
||||
>
|
||||
<div class="form-buttons">
|
||||
<el-button class="reset-button" @click="$emit('reset')" v-ripple>{{
|
||||
$t('table.searchBar.reset')
|
||||
}}</el-button>
|
||||
<el-button type="primary" class="search-button" @click="$emit('search')" v-ripple>{{
|
||||
$t('table.searchBar.search')
|
||||
}}</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!props.isExpand && props.showExpand"
|
||||
class="filter-toggle"
|
||||
@click="isShow = !isShow"
|
||||
>
|
||||
<span>{{
|
||||
isShow ? $t('table.searchBar.collapse') : $t('table.searchBar.expand')
|
||||
}}</span>
|
||||
<div class="icon-wrapper">
|
||||
<el-icon>
|
||||
<ArrowUpBold v-if="isShow" />
|
||||
<ArrowDownBold v-else />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import ArtSearchInput from './widget/art-search-input/index.vue'
|
||||
import ArtSearchSelect from './widget/art-search-select/index.vue'
|
||||
import ArtSearchRadio from './widget/art-search-radio/index.vue'
|
||||
import ArtSearchDate from './widget/art-search-date/index.vue'
|
||||
import { SearchComponentType, SearchFormItem } from '@/types'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
type FilterVo = string | number | undefined | null | unknown[]
|
||||
|
||||
interface PropsVO {
|
||||
filter: Record<string, FilterVo> // 查询参数
|
||||
items: SearchFormItem[] // 表单数据
|
||||
elColSpan?: number // 每列的宽度(基于 24 格布局)
|
||||
gutter?: number // 表单控件间隙
|
||||
isExpand?: boolean // 展开/收起
|
||||
labelPosition?: 'left' | 'right' // 表单域标签的位置
|
||||
labelWidth?: string // 文字宽度
|
||||
showExpand?: boolean // 是否需要展示,收起
|
||||
buttonLeftLimit?: number // 按钮靠左对齐限制(表单项小于等于该值时)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PropsVO>(), {
|
||||
elColSpan: 6,
|
||||
gutter: 12,
|
||||
isExpand: false,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
showExpand: true,
|
||||
buttonLeftLimit: 2
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:filter', filter: Record<string, FilterVo>): void
|
||||
(e: 'reset'): void
|
||||
(e: 'search'): void
|
||||
}>()
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
const useFormItemArr = computed(() => {
|
||||
const isshowLess = !props.isExpand && !isShow.value
|
||||
if (isshowLess) {
|
||||
const num = Math.floor(24 / props.elColSpan) - 1
|
||||
return props.items.slice(0, num)
|
||||
} else {
|
||||
return props.items
|
||||
}
|
||||
})
|
||||
|
||||
const filter = computed({
|
||||
get: () => props.filter,
|
||||
set: (val) => emit('update:filter', val)
|
||||
})
|
||||
|
||||
const getComponent = (type: SearchComponentType): any => {
|
||||
const componentsMap: Record<string, any> = {
|
||||
input: ArtSearchInput,
|
||||
select: ArtSearchSelect,
|
||||
radio: ArtSearchRadio,
|
||||
datetime: ArtSearchDate,
|
||||
date: ArtSearchDate,
|
||||
daterange: ArtSearchDate,
|
||||
datetimerange: ArtSearchDate,
|
||||
month: ArtSearchDate,
|
||||
monthrange: ArtSearchDate,
|
||||
year: ArtSearchDate,
|
||||
yearrange: ArtSearchDate,
|
||||
week: ArtSearchDate
|
||||
}
|
||||
return componentsMap[type]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-bar {
|
||||
padding: 20px 20px 0;
|
||||
touch-action: none !important;
|
||||
background-color: var(--art-main-bg-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.search-form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
.action-buttons-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
line-height: 32px;
|
||||
color: var(--main-color);
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
# ArtSearchDate 日期选择器组件
|
||||
|
||||
一个功能丰富的日期选择器组件,支持多种日期选择类型。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **单日期选择** - 选择单个日期
|
||||
- ✅ **日期时间选择** - 选择日期和时间
|
||||
- ✅ **日期范围选择** - 选择日期范围
|
||||
- ✅ **日期时间范围选择** - 选择日期时间范围
|
||||
- ✅ **月份选择** - 选择月份
|
||||
- ✅ **月份范围选择** - 选择月份范围
|
||||
- ✅ **年份选择** - 选择年份
|
||||
- ✅ **年份范围选择** - 选择年份范围
|
||||
- ✅ **周选择** - 选择周
|
||||
- ✅ **快捷选项** - 内置常用快捷选项
|
||||
- ✅ **自定义配置** - 支持全面的自定义配置
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 1. 单日期选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
placeholder: '请选择日期'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 日期时间选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'datetime',
|
||||
label: '日期时间',
|
||||
type: 'datetime',
|
||||
config: {
|
||||
type: 'datetime',
|
||||
placeholder: '请选择日期时间'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. 日期范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'daterange',
|
||||
label: '日期范围',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期',
|
||||
rangeSeparator: '至'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 4. 日期时间范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'datetimerange',
|
||||
label: '日期时间范围',
|
||||
type: 'datetimerange',
|
||||
config: {
|
||||
type: 'datetimerange',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 5. 月份选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'month',
|
||||
label: '月份',
|
||||
type: 'month',
|
||||
config: {
|
||||
type: 'month',
|
||||
placeholder: '请选择月份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 6. 月份范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'monthrange',
|
||||
label: '月份范围',
|
||||
type: 'monthrange',
|
||||
config: {
|
||||
type: 'monthrange',
|
||||
startPlaceholder: '开始月份',
|
||||
endPlaceholder: '结束月份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 7. 年份选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'year',
|
||||
label: '年份',
|
||||
type: 'year',
|
||||
config: {
|
||||
type: 'year',
|
||||
placeholder: '请选择年份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 8. 年份范围选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'yearrange',
|
||||
label: '年份范围',
|
||||
type: 'yearrange',
|
||||
config: {
|
||||
type: 'yearrange',
|
||||
startPlaceholder: '开始年份',
|
||||
endPlaceholder: '结束年份'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 9. 周选择
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'week',
|
||||
label: '周',
|
||||
type: 'week',
|
||||
config: {
|
||||
type: 'week',
|
||||
placeholder: '请选择周'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 高级配置
|
||||
|
||||
### 自定义快捷选项
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'daterange',
|
||||
label: '日期范围',
|
||||
type: 'daterange',
|
||||
config: {
|
||||
type: 'daterange',
|
||||
shortcuts: [
|
||||
{
|
||||
text: '今天',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
return [today, today]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近一周',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 6)
|
||||
return [start, end]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近一个月',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setMonth(start.getMonth() - 1)
|
||||
return [start, end]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 禁用特定日期
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
disabledDate: (time: Date) => {
|
||||
// 禁用今天之前的日期
|
||||
return time.getTime() < Date.now() - 8.64e7
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 自定义格式
|
||||
|
||||
```typescript
|
||||
const searchItems: SearchFormItem[] = [
|
||||
{
|
||||
prop: 'date',
|
||||
label: '日期',
|
||||
type: 'date',
|
||||
config: {
|
||||
type: 'date',
|
||||
format: 'DD/MM/YYYY',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: '请选择日期 (DD/MM/YYYY)'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
| 参数 | 说明 | 类型 | 默认值 |
|
||||
| ---------------- | -------------- | --------------------------------- | ---------------- |
|
||||
| type | 日期选择器类型 | `DatePickerType` | `'date'` |
|
||||
| format | 显示格式 | `string` | 根据类型自动设置 |
|
||||
| valueFormat | 值格式 | `string` | 根据类型自动设置 |
|
||||
| placeholder | 占位符 | `string` | 自动生成 |
|
||||
| startPlaceholder | 开始日期占位符 | `string` | 根据类型自动设置 |
|
||||
| endPlaceholder | 结束日期占位符 | `string` | 根据类型自动设置 |
|
||||
| rangeSeparator | 范围分隔符 | `string` | `'至'` |
|
||||
| clearable | 是否可清空 | `boolean` | `true` |
|
||||
| disabled | 是否禁用 | `boolean` | `false` |
|
||||
| readonly | 是否只读 | `boolean` | `false` |
|
||||
| size | 尺寸 | `'large' \| 'default' \| 'small'` | `'default'` |
|
||||
| shortcuts | 快捷选项 | `Array` | 范围类型自动添加 |
|
||||
| disabledDate | 禁用日期函数 | `(time: Date) => boolean` | - |
|
||||
|
||||
## 内置快捷选项
|
||||
|
||||
对于日期范围和日期时间范围选择,组件会自动添加以下快捷选项:
|
||||
|
||||
- 今天
|
||||
- 昨天
|
||||
- 最近7天
|
||||
- 最近30天
|
||||
- 本月
|
||||
- 上月
|
||||
|
||||
## 返回值类型
|
||||
|
||||
根据不同的选择器类型,组件会返回不同格式的值:
|
||||
|
||||
- **单日期/时间类型**: `string | Date | null`
|
||||
- **范围类型**: `[string, string] | [Date, Date] | null`
|
||||
- **周类型**: `string` (格式: YYYY-MM-DD,表示该周的第一天)
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<el-date-picker
|
||||
v-model="modelValue"
|
||||
v-bind="config"
|
||||
@change="handleChange"
|
||||
:style="{ width: '100%' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 组件名称
|
||||
defineOptions({
|
||||
name: 'ArtSearchDate'
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 日期选择器类型
|
||||
type DatePickerType =
|
||||
| 'date' // 单日期
|
||||
| 'datetime' // 日期时间
|
||||
| 'daterange' // 日期范围
|
||||
| 'datetimerange' // 日期时间范围
|
||||
| 'month' // 月份
|
||||
| 'monthrange' // 月份范围
|
||||
| 'year' // 年份
|
||||
| 'yearrange' // 年份范围
|
||||
| 'week' // 周
|
||||
|
||||
// 定义组件值类型 - 根据不同类型返回不同的值
|
||||
export type ValueVO = string | Date | null | undefined | [Date, Date] | [string, string] | number
|
||||
|
||||
// 定义组件props
|
||||
interface Props {
|
||||
value: ValueVO
|
||||
item: SearchFormItem & {
|
||||
config?: {
|
||||
type?: DatePickerType
|
||||
format?: string
|
||||
valueFormat?: string
|
||||
placeholder?: string
|
||||
startPlaceholder?: string
|
||||
endPlaceholder?: string
|
||||
rangeSeparator?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
shortcuts?: Array<{
|
||||
text: string
|
||||
value: Date | (() => Date | [Date, Date])
|
||||
}>
|
||||
disabledDate?: (time: Date) => boolean
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: ValueVO]
|
||||
}>()
|
||||
|
||||
// 计算属性: 处理v-model双向绑定
|
||||
const modelValue = computed({
|
||||
get: () => props.value as any,
|
||||
set: (newValue: ValueVO) => emit('update:value', newValue)
|
||||
})
|
||||
|
||||
// 获取默认配置
|
||||
const getDefaultConfig = (type: DatePickerType) => {
|
||||
const baseConfig = {
|
||||
clearable: true,
|
||||
size: 'default' as const
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'date':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'date' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'datetime':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'datetime' as const,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'daterange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'daterange' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期'
|
||||
}
|
||||
|
||||
case 'datetimerange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'datetimerange' as const,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始时间',
|
||||
endPlaceholder: '结束时间'
|
||||
}
|
||||
|
||||
case 'month':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'month' as const,
|
||||
format: 'YYYY-MM',
|
||||
valueFormat: 'YYYY-MM',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'monthrange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'monthrange' as const,
|
||||
format: 'YYYY-MM',
|
||||
valueFormat: 'YYYY-MM',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始月份',
|
||||
endPlaceholder: '结束月份'
|
||||
}
|
||||
|
||||
case 'year':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'year' as const,
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
case 'yearrange':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'yearrange' as const,
|
||||
format: 'YYYY',
|
||||
valueFormat: 'YYYY',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始年份',
|
||||
endPlaceholder: '结束年份'
|
||||
}
|
||||
|
||||
case 'week':
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'week' as const,
|
||||
format: 'YYYY 第 ww 周',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
...baseConfig,
|
||||
type: 'date' as const,
|
||||
format: 'YYYY-MM-DD',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${props.item.label}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 常用快捷选项
|
||||
const getCommonShortcuts = (type: DatePickerType) => {
|
||||
if (!['daterange', 'datetimerange'].includes(type)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: '今天',
|
||||
value: () => {
|
||||
const today = new Date()
|
||||
return [today, today] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '昨天',
|
||||
value: () => {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return [yesterday, yesterday] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近7天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 6)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '最近30天',
|
||||
value: () => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - 29)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
value: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '上月',
|
||||
value: () => {
|
||||
const now = new Date()
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), 0)
|
||||
return [start, end] as [Date, Date]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = computed(() => {
|
||||
const dateType = (props.item.config?.type || 'date') as DatePickerType
|
||||
const defaultConfig = getDefaultConfig(dateType)
|
||||
const userConfig = props.item.config || {}
|
||||
|
||||
// 如果用户没有提供快捷选项且类型是范围选择,则添加默认快捷选项
|
||||
const shouldAddShortcuts =
|
||||
!userConfig.shortcuts && ['daterange', 'datetimerange'].includes(dateType)
|
||||
const shortcuts = shouldAddShortcuts ? getCommonShortcuts(dateType) : userConfig.shortcuts
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...userConfig,
|
||||
...(shortcuts && { shortcuts })
|
||||
}
|
||||
})
|
||||
|
||||
// 日期值变化处理函数
|
||||
const handleChange = (val: ValueVO): void => {
|
||||
if (props.item.onChange) {
|
||||
props.item.onChange({
|
||||
prop: props.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<el-input v-model="value" v-bind="config" @change="(val) => changeValue(val)" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export type ValueVO = unknown
|
||||
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 输入框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新输入框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
placeholder: `${t('table.searchBar.searchInputPlaceholder')}${prop.item.label}`,
|
||||
...(prop.item.config || {}) // 合并自定义配置
|
||||
})
|
||||
|
||||
// 输入框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
// 如果存在onChange回调则执行
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<el-radio-group v-model="value" v-bind="config" @change="(val) => changeValue(val)">
|
||||
<el-radio v-for="v in options" :key="v.value" :value="v.value">{{ v.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
|
||||
export type ValueVO = unknown
|
||||
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 单选框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新单选框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
...(prop.item.config || {}) // 合并自定义配置
|
||||
})
|
||||
|
||||
// 计算属性:处理选项数据
|
||||
const options = computed(() => {
|
||||
if (prop.item.options) {
|
||||
// 判断options是数组还是函数
|
||||
if (Array.isArray(prop.item.options)) {
|
||||
return prop.item.options
|
||||
} else {
|
||||
return prop.item.options()
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 单选框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
// 如果存在onChange回调则执行
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<el-select v-model="value" v-bind="config" @change="(val) => changeValue(val)">
|
||||
<el-option
|
||||
v-for="v in options"
|
||||
:key="v.value"
|
||||
:label="v.label"
|
||||
:value="v.value"
|
||||
:disabled="v.disabled || false"
|
||||
>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchFormItem } from '@/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义组件值类型
|
||||
export type ValueVO = unknown
|
||||
|
||||
// 定义组件props
|
||||
const prop = defineProps<{
|
||||
value: ValueVO // 选择框的值
|
||||
item: SearchFormItem // 表单项配置
|
||||
}>()
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', value: ValueVO): void // 更新选择框值的事件
|
||||
}>()
|
||||
|
||||
// 计算属性:处理v-model双向绑定
|
||||
const value = computed({
|
||||
get: () => prop.value as string,
|
||||
set: (value: ValueVO) => emit('update:value', value)
|
||||
})
|
||||
|
||||
// 合并默认配置和自定义配置
|
||||
const config = reactive({
|
||||
placeholder: `${t('table.searchBar.searchSelectPlaceholder')}${prop.item.label}`, // 修改默认placeholder文案
|
||||
...(prop.item.config || {})
|
||||
})
|
||||
|
||||
// 选择框值变化处理函数
|
||||
const changeValue = (val: unknown): void => {
|
||||
if (prop.item.onChange) {
|
||||
prop.item.onChange({
|
||||
prop: prop.item.prop,
|
||||
val
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:处理选项数据
|
||||
const options = computed(() => {
|
||||
if (prop.item.options) {
|
||||
// 判断options是数组还是函数
|
||||
if (Array.isArray(prop.item.options)) {
|
||||
return prop.item.options
|
||||
} else {
|
||||
return prop.item.options()
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
106
src/components/core/layouts/art-breadcrumb/index.vue
Normal file
106
src/components/core/layouts/art-breadcrumb/index.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<nav class="breadcrumb" aria-label="breadcrumb">
|
||||
<ul>
|
||||
<li v-for="(item, index) in breadList" :key="item.path">
|
||||
<div
|
||||
:class="{ clickable: item.path !== '/outside' && !isLastItem(index) }"
|
||||
@click="!isLastItem(index) && handleClick(item)"
|
||||
>
|
||||
<span>
|
||||
{{ formatMenuTitle(item.meta?.title as string) }}
|
||||
</span>
|
||||
</div>
|
||||
<i v-if="!isLastItem(index) && item.meta?.title" aria-hidden="true">/</i>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
path: string
|
||||
meta: RouteRecordRaw['meta']
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadList = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// 计算函数
|
||||
const isLastItem = (index: number) => index === breadList.value.length - 1
|
||||
const isHome = (route: RouteLocationMatched) => route.name === '/'
|
||||
|
||||
// 获取面包屑数据
|
||||
const getBreadcrumb = () => {
|
||||
const { matched } = route
|
||||
|
||||
// 处理首页情况
|
||||
if (!matched.length || isHome(matched[0])) {
|
||||
breadList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 处理一级菜单和普通路由
|
||||
const isFirstLevel = matched[0].meta?.isFirstLevel
|
||||
const currentRoute = matched[matched.length - 1]
|
||||
|
||||
breadList.value = isFirstLevel
|
||||
? [{ path: currentRoute.path, meta: currentRoute.meta }]
|
||||
: matched.map(({ path, meta }) => ({ path, meta }))
|
||||
}
|
||||
|
||||
// 处理面包屑点击
|
||||
const handleClick = async (item: BreadcrumbItem) => {
|
||||
const { path } = item
|
||||
|
||||
if (path === '/outside') {
|
||||
return
|
||||
}
|
||||
|
||||
const currentRoute = router.getRoutes().find((route) => route.path === path)
|
||||
|
||||
if (!currentRoute?.children?.length) {
|
||||
await router.push(path)
|
||||
return
|
||||
}
|
||||
|
||||
const firstValidChild = currentRoute.children.find(
|
||||
(child) => !child.redirect && !child.meta?.isHide
|
||||
)
|
||||
|
||||
if (firstValidChild) {
|
||||
const fullPath = `/${firstValidChild.path}`.replace('//', '/')
|
||||
await router.push(fullPath)
|
||||
} else {
|
||||
await router.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, getBreadcrumb, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
|
||||
ul {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
src/components/core/layouts/art-breadcrumb/style.scss
Normal file
29
src/components/core/layouts/art-breadcrumb/style.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: 10px;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
color: var(--art-text-gray-700) !important;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
i {
|
||||
margin: 0 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
406
src/components/core/layouts/art-chat-window/index.vue
Normal file
406
src/components/core/layouts/art-chat-window/index.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<template>
|
||||
<div class="layout-chat">
|
||||
<el-drawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<span class="name">Art Bot</span>
|
||||
<div class="status">
|
||||
<div class="dot" :class="{ online: isOnline, offline: !isOnline }"></div>
|
||||
<span class="status-text">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-icon class="icon-close" :size="20" @click="closeChat">
|
||||
<Close />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<!-- 聊天消息区域 -->
|
||||
<div class="chat-messages" ref="messageContainer">
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div :class="['message-item', message.isMe ? 'message-right' : 'message-left']">
|
||||
<el-avatar :size="32" :src="message.avatar" class="message-avatar" />
|
||||
<div class="message-content">
|
||||
<div class="message-info">
|
||||
<span class="sender-name">{{ message.sender }}</span>
|
||||
<span class="message-time">{{ message.time }}</span>
|
||||
</div>
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="input-actions">
|
||||
<el-button :icon="Paperclip" circle plain />
|
||||
<el-button :icon="Picture" circle plain />
|
||||
<el-button type="primary" @click="sendMessage" v-ripple>发送</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="chat-input-actions">
|
||||
<div class="left">
|
||||
<i class="iconfont-sys"></i>
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<el-button type="primary" @click="sendMessage" v-ripple>发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Picture, Paperclip } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import meAvatar from '@/assets/img/avatar/avatar5.webp'
|
||||
import aiAvatar from '@/assets/img/avatar/avatar10.webp'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
// 抽屉显示状态
|
||||
const isDrawerVisible = ref(false)
|
||||
// 是否在线
|
||||
const isOnline = ref(true)
|
||||
|
||||
// 消息相关数据
|
||||
const messageText = ref('')
|
||||
const messages = ref([
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Art Bot',
|
||||
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
||||
time: '10:00',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'Ricky',
|
||||
content: '我想了解一下系统的使用方法。',
|
||||
time: '10:01',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sender: 'Art Bot',
|
||||
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
||||
time: '10:02',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sender: 'Ricky',
|
||||
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
||||
time: '10:05',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sender: 'Art Bot',
|
||||
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
||||
time: '10:06',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
sender: 'Ricky',
|
||||
content: '太好了,那我如何开始使用呢?',
|
||||
time: '10:08',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
sender: 'Art Bot',
|
||||
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
||||
time: '10:09',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
sender: 'Ricky',
|
||||
content: '明白了,谢谢你的帮助!',
|
||||
time: '10:10',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
sender: 'Art Bot',
|
||||
content: '不客气,有任何问题随时联系我。',
|
||||
time: '10:11',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
}
|
||||
])
|
||||
|
||||
const messageId = ref(10) // 用于生成唯一的消息ID
|
||||
|
||||
const userAvatar = ref(meAvatar) // 使用导入的头像
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = () => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text) return
|
||||
|
||||
messages.value.push({
|
||||
id: messageId.value++,
|
||||
sender: 'Ricky',
|
||||
content: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
isMe: true,
|
||||
avatar: userAvatar.value
|
||||
})
|
||||
|
||||
messageText.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const messageContainer = ref<HTMLElement | null>(null)
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const openChat = () => {
|
||||
isDrawerVisible.value = true
|
||||
}
|
||||
|
||||
const closeChat = () => {
|
||||
isDrawerVisible.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
mittBus.on('openChat', openChat)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.layout-chat {
|
||||
.el-overlay {
|
||||
background-color: rgb(0 0 0 / 20%) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header-left {
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.online {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.icon-close {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 70px);
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: 30px 16px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-900);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&.message-left {
|
||||
justify-content: flex-start;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-start;
|
||||
|
||||
.message-info {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background-color: #f8f5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.message-right {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
|
||||
.message-info {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background-color: #e9f3ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
|
||||
.message-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
.message-time {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.sender-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.message-text {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 16px 16px 0;
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
align-items: center; // 修正为单数
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 20px;
|
||||
font-size: 16px;
|
||||
color: var(--art-gray-500);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// 确保发送按钮与输入框对齐
|
||||
el-button {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.chat-container {
|
||||
.chat-messages {
|
||||
.message-item {
|
||||
&.message-left {
|
||||
.message-text {
|
||||
background-color: #232323 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.message-right {
|
||||
.message-text {
|
||||
background-color: #182331 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
283
src/components/core/layouts/art-fast-enter/index.vue
Normal file
283
src/components/core/layouts/art-fast-enter/index.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<el-popover
|
||||
ref="popoverRef"
|
||||
:width="700"
|
||||
trigger="hover"
|
||||
popper-class="fast-enter-popover"
|
||||
:show-arrow="false"
|
||||
placement="bottom-start"
|
||||
:offset="0"
|
||||
popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); "
|
||||
>
|
||||
<template #reference>
|
||||
<div class="fast-enter-trigger">
|
||||
<div class="btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span class="red-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="fast-enter">
|
||||
<div class="apps-section">
|
||||
<div class="apps-grid">
|
||||
<!-- 左侧应用列表 -->
|
||||
<div
|
||||
class="app-item"
|
||||
v-for="app in applications"
|
||||
:key="app.name"
|
||||
@click="handleAppClick(app.path)"
|
||||
>
|
||||
<div class="app-icon">
|
||||
<i class="iconfont-sys" v-html="app.icon" :style="{ color: app.iconColor }"></i>
|
||||
</div>
|
||||
<div class="app-info">
|
||||
<h3>{{ app.name }}</h3>
|
||||
<p>{{ app.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-links">
|
||||
<h3>快速链接</h3>
|
||||
<ul>
|
||||
<li v-for="link in quickLinks" :key="link.name" @click="handleAppClick(link.path)">
|
||||
<span>{{ link.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const popoverRef = ref()
|
||||
|
||||
interface Application {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
iconColor: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface QuickLink {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const applications: Application[] = [
|
||||
{
|
||||
name: '工作台',
|
||||
description: '系统概览与数据统计',
|
||||
icon: '',
|
||||
iconColor: '#377dff',
|
||||
path: RoutesAlias.Dashboard
|
||||
},
|
||||
{
|
||||
name: '分析页',
|
||||
description: '数据分析与可视化',
|
||||
icon: '',
|
||||
iconColor: '#ff3b30',
|
||||
path: RoutesAlias.Analysis
|
||||
},
|
||||
{
|
||||
name: '礼花效果',
|
||||
description: '动画特效展示',
|
||||
icon: '',
|
||||
iconColor: '#7A7FFF',
|
||||
path: RoutesAlias.Fireworks
|
||||
},
|
||||
{
|
||||
name: '聊天',
|
||||
description: '即时通讯功能',
|
||||
icon: '',
|
||||
iconColor: '#13DEB9',
|
||||
path: RoutesAlias.Chat
|
||||
},
|
||||
{
|
||||
name: '官方文档',
|
||||
description: '使用指南与开发文档',
|
||||
icon: '',
|
||||
iconColor: '#ffb100',
|
||||
path: WEB_LINKS.DOCS
|
||||
},
|
||||
{
|
||||
name: '技术支持',
|
||||
description: '技术支持与问题反馈',
|
||||
icon: '',
|
||||
iconColor: '#ff6b6b',
|
||||
path: WEB_LINKS.COMMUNITY
|
||||
},
|
||||
{
|
||||
name: '更新日志',
|
||||
description: '版本更新与变更记录',
|
||||
icon: '',
|
||||
iconColor: '#38C0FC',
|
||||
path: RoutesAlias.ChangeLog
|
||||
},
|
||||
{
|
||||
name: '哔哩哔哩',
|
||||
description: '技术分享与交流',
|
||||
icon: '',
|
||||
iconColor: '#FB7299',
|
||||
path: WEB_LINKS.BILIBILI
|
||||
}
|
||||
]
|
||||
|
||||
const quickLinks: QuickLink[] = [
|
||||
{ name: '登录', path: RoutesAlias.Login },
|
||||
{ name: '注册', path: RoutesAlias.Register },
|
||||
{ name: '忘记密码', path: RoutesAlias.ForgetPassword },
|
||||
{ name: '定价', path: RoutesAlias.Pricing },
|
||||
{ name: '个人中心', path: RoutesAlias.UserCenter },
|
||||
{ name: '留言管理', path: RoutesAlias.Comment }
|
||||
]
|
||||
|
||||
const handleAppClick = (path: string) => {
|
||||
if (path.startsWith('http')) {
|
||||
window.open(path, '_blank')
|
||||
} else {
|
||||
router.push(path)
|
||||
}
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fast-enter-trigger {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.btn {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
|
||||
.red-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fast-enter {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 0.8fr;
|
||||
|
||||
.apps-section {
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
|
||||
.app-icon {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-info {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--art-text-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-links {
|
||||
padding: 8px 0 0 24px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
span {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--art-text-gray-600);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
410
src/components/core/layouts/art-fireworks-effect/index.vue
Normal file
410
src/components/core/layouts/art-fireworks-effect/index.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<canvas ref="canvas" class="layout-fireworks"></canvas>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import type { Handler } from 'mitt'
|
||||
|
||||
// 对象池大小
|
||||
const POOL_SIZE = 600
|
||||
|
||||
// 烟花配置
|
||||
const CONFIG = {
|
||||
// 每次爆炸产生的粒子数量
|
||||
PARTICLES_PER_BURST: 200,
|
||||
// 各种形状的尺寸配置
|
||||
SIZES: {
|
||||
RECTANGLE: { WIDTH: 24, HEIGHT: 12 }, // 矩形宽高
|
||||
SQUARE: { SIZE: 12 }, // 正方形边长
|
||||
CIRCLE: { SIZE: 12 }, // 圆形直径
|
||||
TRIANGLE: { SIZE: 10 }, // 三角形边长
|
||||
OVAL: { WIDTH: 24, HEIGHT: 12 }, // 椭圆宽高
|
||||
IMAGE: { WIDTH: 30, HEIGHT: 30 } // 图片尺寸
|
||||
},
|
||||
// 旋转相关参数
|
||||
ROTATION: {
|
||||
BASE_SPEED: 2, // 基础旋转速度
|
||||
RANDOM_SPEED: 3, // 随机旋转速度增量
|
||||
DECAY: 0.85 // 旋转衰减系数
|
||||
},
|
||||
// 烟花粒子颜色配置(带透明度)
|
||||
COLORS: [
|
||||
'rgba(255, 68, 68, 1)',
|
||||
'rgba(255, 68, 68, 0.9)',
|
||||
'rgba(255, 68, 68, 0.8)',
|
||||
'rgba(255, 116, 188, 1)',
|
||||
'rgba(255, 116, 188, 0.9)',
|
||||
'rgba(255, 116, 188, 0.8)',
|
||||
'rgba(68, 68, 255, 0.8)',
|
||||
'rgba(92, 202, 56, 0.7)',
|
||||
'rgba(255, 68, 255, 0.8)',
|
||||
'rgba(68, 255, 255, 0.7)',
|
||||
'rgba(255, 136, 68, 0.7)',
|
||||
'rgba(68, 136, 255, 1)',
|
||||
'rgba(250, 198, 122, 0.8)'
|
||||
],
|
||||
// 烟花粒子形状配置(矩形出现概率更高)
|
||||
SHAPES: [
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'rectangle',
|
||||
'circle',
|
||||
'triangle',
|
||||
'oval'
|
||||
]
|
||||
}
|
||||
|
||||
// 图片缓存
|
||||
const imageCache: { [url: string]: HTMLImageElement } = {}
|
||||
|
||||
// 预加载图片函数
|
||||
const preloadImage = (url: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (imageCache[url]) {
|
||||
if (imageCache[url].complete) {
|
||||
resolve(imageCache[url])
|
||||
} else {
|
||||
imageCache[url].onload = () => resolve(imageCache[url])
|
||||
imageCache[url].onerror = reject
|
||||
}
|
||||
} else {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous' // 处理CORS
|
||||
img.src = url
|
||||
img.onload = () => {
|
||||
imageCache[url] = img
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = reject
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Firework {
|
||||
x: number
|
||||
y: number
|
||||
color: string
|
||||
velocity: { x: number; y: number }
|
||||
rotation: number
|
||||
rotationX: number
|
||||
rotationY: number
|
||||
scale: number
|
||||
shape: string
|
||||
active: boolean
|
||||
rotationSpeed: { x: number; y: number; z: number }
|
||||
imageUrl?: string
|
||||
opacity: number // 新增透明度属性
|
||||
}
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
const particlePool = ref<Firework[]>([])
|
||||
const fireworks = ref<Firework[]>([])
|
||||
|
||||
// 初始化对象池
|
||||
const initParticlePool = () => {
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
particlePool.value.push({
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '',
|
||||
velocity: { x: 0, y: 0 },
|
||||
rotation: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
scale: 1,
|
||||
shape: 'circle',
|
||||
active: false,
|
||||
rotationSpeed: { x: 0, y: 0, z: 0 },
|
||||
opacity: 1 // 初始化透明度为1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 从对象池获取粒子
|
||||
const getParticleFromPool = (): Firework | null => {
|
||||
const particle = particlePool.value.find((p) => !p.active)
|
||||
if (particle) {
|
||||
particle.active = true
|
||||
return particle
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建烟花
|
||||
const createFirework = (imageUrl?: string) => {
|
||||
// 异步创建粒子,避免阻塞主线程
|
||||
setTimeout(() => {
|
||||
const startX = Math.random() * window.innerWidth
|
||||
const startY = window.innerHeight
|
||||
|
||||
const availableShapes = imageUrl && imageCache[imageUrl] ? ['image'] : CONFIG.SHAPES
|
||||
|
||||
for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) {
|
||||
const particle = getParticleFromPool()
|
||||
if (!particle) continue
|
||||
|
||||
const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2)
|
||||
const speed = (12 + Math.random() * 6) * 1.5
|
||||
const spread = Math.random() * Math.PI * 2
|
||||
|
||||
Object.assign(particle, {
|
||||
x: startX,
|
||||
y: startY,
|
||||
color: CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)],
|
||||
velocity: {
|
||||
x: Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5),
|
||||
y: Math.sin(angle) * speed - 15
|
||||
},
|
||||
rotation: Math.random() * 360,
|
||||
rotationX: Math.random() * 360 - 180,
|
||||
rotationY: Math.random() * 360 - 180,
|
||||
scale: 0.8 + Math.random() * 0.4,
|
||||
shape: availableShapes[Math.floor(Math.random() * availableShapes.length)],
|
||||
imageUrl: imageUrl && imageCache[imageUrl] ? imageUrl : undefined,
|
||||
rotationSpeed: {
|
||||
x:
|
||||
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
|
||||
(Math.random() > 0.5 ? 1 : -1),
|
||||
y:
|
||||
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
|
||||
(Math.random() > 0.5 ? 1 : -1),
|
||||
z:
|
||||
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
|
||||
(Math.random() > 0.5 ? 1 : -1)
|
||||
},
|
||||
opacity: 1 // 初始化透明度为1
|
||||
})
|
||||
|
||||
fireworks.value.push(particle)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 更新烟花状态
|
||||
const updateFireworks = () => {
|
||||
const velocityThreshold = 10 // 设置下落速度阈值,超过此值开始淡出
|
||||
const opacityDecay = 0.02 // 设置透明度减少的速度(增大值加快淡出速度)
|
||||
|
||||
for (let i = fireworks.value.length - 1; i >= 0; i--) {
|
||||
const firework = fireworks.value[i]
|
||||
firework.x += firework.velocity.x
|
||||
firework.y += firework.velocity.y
|
||||
firework.velocity.y += 0.525
|
||||
firework.rotation += firework.rotationSpeed.z
|
||||
firework.rotationX += firework.rotationSpeed.x
|
||||
firework.rotationY += firework.rotationSpeed.y
|
||||
|
||||
firework.rotationSpeed.x *= CONFIG.ROTATION.DECAY
|
||||
firework.rotationSpeed.y *= CONFIG.ROTATION.DECAY
|
||||
firework.rotationSpeed.z *= CONFIG.ROTATION.DECAY
|
||||
|
||||
// 如果粒子的下落速度超过阈值,开始减少透明度
|
||||
if (firework.velocity.y > velocityThreshold) {
|
||||
firework.opacity -= opacityDecay // 根据需要调整淡出的速度
|
||||
if (firework.opacity <= 0) {
|
||||
firework.active = false
|
||||
fireworks.value.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 如果粒子超出屏幕范围,回收粒子
|
||||
if (
|
||||
firework.x < -100 ||
|
||||
firework.x > window.innerWidth + 100 ||
|
||||
firework.y < -100 ||
|
||||
firework.y > window.innerHeight + 100
|
||||
) {
|
||||
firework.active = false
|
||||
fireworks.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制单个粒子
|
||||
const drawFirework = (firework: Firework) => {
|
||||
if (!ctx.value) return
|
||||
|
||||
ctx.value.save()
|
||||
ctx.value.globalAlpha = firework.opacity // 设置当前粒子的透明度
|
||||
ctx.value.translate(firework.x, firework.y)
|
||||
ctx.value.rotate((firework.rotation * Math.PI) / 180)
|
||||
ctx.value.scale(firework.scale, firework.scale)
|
||||
|
||||
switch (firework.shape) {
|
||||
case 'rectangle':
|
||||
ctx.value.fillStyle = firework.color
|
||||
ctx.value.fillRect(
|
||||
-CONFIG.SIZES.RECTANGLE.WIDTH / 2,
|
||||
-CONFIG.SIZES.RECTANGLE.HEIGHT / 2,
|
||||
CONFIG.SIZES.RECTANGLE.WIDTH,
|
||||
CONFIG.SIZES.RECTANGLE.HEIGHT
|
||||
)
|
||||
break
|
||||
case 'square':
|
||||
ctx.value.fillStyle = firework.color
|
||||
ctx.value.fillRect(
|
||||
-CONFIG.SIZES.SQUARE.SIZE / 2,
|
||||
-CONFIG.SIZES.SQUARE.SIZE / 2,
|
||||
CONFIG.SIZES.SQUARE.SIZE,
|
||||
CONFIG.SIZES.SQUARE.SIZE
|
||||
)
|
||||
break
|
||||
case 'circle':
|
||||
ctx.value.fillStyle = firework.color
|
||||
ctx.value.beginPath()
|
||||
ctx.value.arc(0, 0, CONFIG.SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2)
|
||||
ctx.value.closePath()
|
||||
ctx.value.fill()
|
||||
break
|
||||
case 'triangle':
|
||||
ctx.value.fillStyle = firework.color
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(0, -CONFIG.SIZES.TRIANGLE.SIZE)
|
||||
ctx.value.lineTo(CONFIG.SIZES.TRIANGLE.SIZE, CONFIG.SIZES.TRIANGLE.SIZE)
|
||||
ctx.value.lineTo(-CONFIG.SIZES.TRIANGLE.SIZE, CONFIG.SIZES.TRIANGLE.SIZE)
|
||||
ctx.value.closePath()
|
||||
ctx.value.fill()
|
||||
break
|
||||
case 'oval':
|
||||
ctx.value.fillStyle = firework.color
|
||||
ctx.value.beginPath()
|
||||
ctx.value.ellipse(
|
||||
0,
|
||||
0,
|
||||
CONFIG.SIZES.OVAL.WIDTH / 2,
|
||||
CONFIG.SIZES.OVAL.HEIGHT / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2
|
||||
)
|
||||
ctx.value.closePath()
|
||||
ctx.value.fill()
|
||||
break
|
||||
case 'image':
|
||||
if (firework.imageUrl) {
|
||||
const img = imageCache[firework.imageUrl]
|
||||
if (img && img.complete) {
|
||||
ctx.value.drawImage(
|
||||
img,
|
||||
-CONFIG.SIZES.IMAGE.WIDTH / 2,
|
||||
-CONFIG.SIZES.IMAGE.HEIGHT / 2,
|
||||
CONFIG.SIZES.IMAGE.WIDTH,
|
||||
CONFIG.SIZES.IMAGE.HEIGHT
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
ctx.value.restore()
|
||||
}
|
||||
|
||||
// 绘制所有烟花
|
||||
const draw = () => {
|
||||
if (!ctx.value || !canvas.value) return
|
||||
|
||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height)
|
||||
ctx.value.globalCompositeOperation = 'lighter'
|
||||
|
||||
fireworks.value.forEach((firework) => {
|
||||
drawFirework(firework)
|
||||
})
|
||||
}
|
||||
|
||||
// 动画循环
|
||||
const animate = () => {
|
||||
updateFireworks()
|
||||
draw()
|
||||
animationFrame = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
let animationFrame: number
|
||||
|
||||
// 处理快捷键
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// 检查是否同时按下 Ctrl + Shift + F (Windows/Linux) 或 Command + Shift + F (macOS)
|
||||
if (
|
||||
(event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'p') ||
|
||||
(event.metaKey && event.shiftKey && event.key.toLowerCase() === 'p')
|
||||
) {
|
||||
event.preventDefault()
|
||||
createFirework()
|
||||
}
|
||||
}
|
||||
|
||||
// 调整Canvas大小
|
||||
const resizeCanvas = () => {
|
||||
if (canvas.value) {
|
||||
canvas.value.width = window.innerWidth
|
||||
canvas.value.height = window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
import bp from '@imgs/ceremony/hb.png'
|
||||
import sd from '@imgs/ceremony/sd.png'
|
||||
import yd from '@imgs/ceremony/yd.png'
|
||||
|
||||
// 预加载所有需要的图片
|
||||
const preloadAllImages = async () => {
|
||||
const imageUrls = [bp, sd, yd]
|
||||
|
||||
try {
|
||||
await Promise.all(imageUrls.map((url) => preloadImage(url)))
|
||||
} catch (error) {
|
||||
console.error('Image preloading failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (canvas.value) {
|
||||
ctx.value = canvas.value.getContext('2d')
|
||||
resizeCanvas()
|
||||
}
|
||||
initParticlePool()
|
||||
|
||||
// 预加载所有图片
|
||||
await preloadAllImages()
|
||||
|
||||
animate()
|
||||
useEventListener(window, 'keydown', handleKeyPress)
|
||||
useEventListener(window, 'resize', resizeCanvas)
|
||||
|
||||
// 监听触发烟花的事件
|
||||
mittBus.on('triggerFireworks', ((event: unknown) => {
|
||||
const imageUrl = event as string | undefined
|
||||
if (imageUrl && imageCache[imageUrl]?.complete) {
|
||||
createFirework(imageUrl)
|
||||
} else {
|
||||
createFirework()
|
||||
}
|
||||
}) as Handler<unknown>)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
mittBus.off('triggerFireworks')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-fireworks {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
346
src/components/core/layouts/art-global-search/index.vue
Normal file
346
src/components/core/layouts/art-global-search/index.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<div class="layout-search">
|
||||
<el-dialog
|
||||
v-model="showSearchDialog"
|
||||
width="600"
|
||||
:show-close="false"
|
||||
:lock-scroll="false"
|
||||
modal-class="search-modal"
|
||||
@close="closeSearchDialog"
|
||||
>
|
||||
<el-input
|
||||
v-model.trim="searchVal"
|
||||
:placeholder="$t('search.placeholder')"
|
||||
@input="search"
|
||||
@blur="searchBlur"
|
||||
ref="searchInput"
|
||||
:prefix-icon="Search"
|
||||
>
|
||||
<template #suffix>
|
||||
<div class="search-keydown">
|
||||
<span>ESC</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="search-scrollbar" max-height="370px" ref="searchResultScrollbar" always>
|
||||
<div class="result" v-show="searchResult.length">
|
||||
<div class="box" v-for="(item, index) in searchResult" :key="index">
|
||||
<div
|
||||
:class="{ highlighted: isHighlighted(index) }"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHover(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<i class="selected-icon iconfont-sys" v-show="isHighlighted(index)"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="history-box"
|
||||
v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0"
|
||||
>
|
||||
<p class="title">{{ $t('search.historyTitle') }}</p>
|
||||
<div class="history-result">
|
||||
<div
|
||||
class="box"
|
||||
v-for="(item, index) in historyResult"
|
||||
:key="index"
|
||||
:class="{ highlighted: historyHIndex === index }"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHoverHistory(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<i class="selected-icon iconfont-sys" @click.stop="deleteHistory(index)"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div>
|
||||
<i class="iconfont-sys"></i>
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ $t('search.switchKeydown') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ $t('search.selectKeydown') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick } from 'vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import type { ScrollbarInstance } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
|
||||
const showSearchDialog = ref(false)
|
||||
const searchVal = ref('')
|
||||
const searchResult = ref<AppRouteRecord[]>([])
|
||||
const historyMaxLength = 10
|
||||
|
||||
const { searchHistory: historyResult } = storeToRefs(userStore)
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const highlightedIndex = ref(0)
|
||||
const historyHIndex = ref(0)
|
||||
const searchResultScrollbar = ref<ScrollbarInstance>()
|
||||
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
mittBus.on('openSearchDialog', openSearchDialog)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 键盘快捷键处理
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
|
||||
|
||||
if (isCommandKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// 当搜索对话框打开时,处理方向键和回车键
|
||||
if (showSearchDialog.value) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
highlightPrevious()
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
highlightNext()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectHighlighted()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 搜索逻辑
|
||||
const search = (val: string) => {
|
||||
if (val) {
|
||||
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
|
||||
} else {
|
||||
searchResult.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
||||
const lowerVal = val.toLowerCase()
|
||||
const result: AppRouteRecord[] = []
|
||||
|
||||
const flattenAndMatch = (item: AppRouteRecord) => {
|
||||
if (item.meta?.isHide) return
|
||||
|
||||
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
item.children.forEach(flattenAndMatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (lowerItemTitle.includes(lowerVal) && item.path) {
|
||||
result.push({ ...item, children: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(flattenAndMatch)
|
||||
return result
|
||||
}
|
||||
|
||||
// 高亮控制并实现滚动条跟随
|
||||
const highlightPrevious = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value =
|
||||
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const highlightNext = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const scrollToHighlightedItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !searchResult.value.length) return
|
||||
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
|
||||
if (!highlightedElements[highlightedIndex.value]) return
|
||||
|
||||
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToHighlightedHistoryItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !historyResult.value.length) return
|
||||
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
|
||||
if (!historyItems[historyHIndex.value]) return
|
||||
|
||||
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectHighlighted = () => {
|
||||
if (searchVal.value && searchResult.value.length) {
|
||||
searchGoPage(searchResult.value[highlightedIndex.value])
|
||||
} else if (!searchVal.value && historyResult.value.length) {
|
||||
searchGoPage(historyResult.value[historyHIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
const isHighlighted = (index: number) => {
|
||||
return highlightedIndex.value === index
|
||||
}
|
||||
|
||||
const searchBlur = () => {
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
const searchGoPage = (item: AppRouteRecord) => {
|
||||
showSearchDialog.value = false
|
||||
addHistory(item)
|
||||
router.push(item.path)
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
}
|
||||
|
||||
// 历史记录管理
|
||||
const updateHistory = () => {
|
||||
if (Array.isArray(historyResult.value)) {
|
||||
userStore.setSearchHistory(historyResult.value)
|
||||
}
|
||||
}
|
||||
|
||||
const addHistory = (item: AppRouteRecord) => {
|
||||
const hasItemIndex = historyResult.value.findIndex(
|
||||
(historyItem: AppRouteRecord) => historyItem.path === item.path
|
||||
)
|
||||
|
||||
if (hasItemIndex !== -1) {
|
||||
historyResult.value.splice(hasItemIndex, 1)
|
||||
} else if (historyResult.value.length >= historyMaxLength) {
|
||||
historyResult.value.pop()
|
||||
}
|
||||
|
||||
const cleanedItem = { ...item }
|
||||
delete cleanedItem.children
|
||||
delete cleanedItem.meta.authList
|
||||
historyResult.value.unshift(cleanedItem)
|
||||
updateHistory()
|
||||
}
|
||||
|
||||
const deleteHistory = (index: number) => {
|
||||
historyResult.value.splice(index, 1)
|
||||
updateHistory()
|
||||
}
|
||||
|
||||
// 对话框控制
|
||||
const openSearchDialog = () => {
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const closeSearchDialog = () => {
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
highlightedIndex.value = 0
|
||||
historyHIndex.value = 0
|
||||
}
|
||||
|
||||
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
|
||||
const highlightOnHover = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && searchVal.value) {
|
||||
highlightedIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
const highlightOnHoverHistory = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && !searchVal.value) {
|
||||
historyHIndex.value = index
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
243
src/components/core/layouts/art-global-search/style.scss
Normal file
243
src/components/core/layouts/art-global-search/style.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.layout-search {
|
||||
:deep(.search-modal) {
|
||||
background-color: rgba($color: #000, $alpha: 20%);
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
padding: 0 15px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 8px) !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
height: 48px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.8);
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--art-gray-600) !important;
|
||||
}
|
||||
|
||||
.search-keydown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
color: var(--art-gray-500);
|
||||
background-color: var(--art-bg-color);
|
||||
border-radius: 4px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-scrollbar {
|
||||
margin-top: 20px;
|
||||
|
||||
.result {
|
||||
width: 100%;
|
||||
background: var(--rt-main-bg-color);
|
||||
|
||||
.box {
|
||||
margin-top: 0 !important;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 5px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
padding: 0 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--art-gray-700);
|
||||
background: var(--art-gray-100);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
&.highlighted {
|
||||
color: #fff !important;
|
||||
background-color: var(--el-color-primary-light-3) !important;
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-box {
|
||||
.title {
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
|
||||
.history-result {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
background: var(--rt-main-bg-color);
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
padding: 0 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--art-gray-800);
|
||||
cursor: pointer;
|
||||
background: var(--art-gray-100);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
&.highlighted {
|
||||
color: #fff !important;
|
||||
background-color: var(--el-color-primary-light-3) !important;
|
||||
|
||||
.selected-icon {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
color: var(--art-gray-500);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($color: #000, $alpha: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--art-border-color);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
|
||||
i {
|
||||
top: 6px;
|
||||
left: 117px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 20px;
|
||||
padding: 6px;
|
||||
margin-right: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
background: var(--art-bg-color);
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 0 var(--art-border-dashed-color);
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-right: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.layout-search {
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #252526;
|
||||
border: 1px solid #4c4d50;
|
||||
}
|
||||
|
||||
.search-keydown {
|
||||
background-color: #252526;
|
||||
border: 1px solid #4c4d50;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.search-modal) {
|
||||
background-color: rgb(23 23 26 / 60%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
background-color: #252526;
|
||||
}
|
||||
|
||||
.result {
|
||||
.box {
|
||||
div {
|
||||
color: rgba($color: #fff, $alpha: 60%) !important;
|
||||
|
||||
&.highlighted {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
> div {
|
||||
color: var(--art-gray-600) !important;
|
||||
|
||||
i {
|
||||
background-color: var(--art-gray-100);
|
||||
}
|
||||
|
||||
span {
|
||||
margin-right: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
383
src/components/core/layouts/art-header-bar/index.vue
Normal file
383
src/components/core/layouts/art-header-bar/index.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="layout-top-bar" :class="[tabStyle]" :style="{ width: topBarWidth() }">
|
||||
<div class="menu">
|
||||
<div class="left" style="display: flex">
|
||||
<!-- 系统信息 -->
|
||||
<div class="top-header" @click="toHome" v-if="isTopMenu">
|
||||
<ArtLogo class="logo" />
|
||||
<p v-if="width >= 1400">{{ AppConfig.systemInfo.name }}</p>
|
||||
</div>
|
||||
|
||||
<ArtLogo class="logo2" @click="toHome" />
|
||||
|
||||
<!-- 菜单按钮 -->
|
||||
<div class="btn-box" v-if="isLeftMenu && showMenuButton">
|
||||
<div class="btn menu-btn">
|
||||
<i class="iconfont-sys" @click="visibleMenu"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 刷新按钮 -->
|
||||
<div class="btn-box" v-if="showRefreshButton">
|
||||
<div class="btn refresh-btn" :style="{ marginLeft: !isLeftMenu ? '10px' : '0' }">
|
||||
<i class="iconfont-sys" @click="reload()">  </i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速入口 -->
|
||||
<ArtFastEnter v-if="width >= 1200" />
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<ArtBreadcrumb
|
||||
v-if="(showCrumbs && isLeftMenu) || (showCrumbs && isDualMenu)"
|
||||
:style="{ paddingLeft: !showRefreshButton && !showMenuButton ? '10px' : '0' }"
|
||||
/>
|
||||
|
||||
<!-- 顶部菜单 -->
|
||||
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" :width="menuTopWidth" />
|
||||
|
||||
<!-- 混合菜单-顶部 -->
|
||||
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" :width="menuTopWidth" />
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<!-- 搜索 -->
|
||||
<div class="search-wrap">
|
||||
<div class="search-input" @click="openSearchDialog">
|
||||
<div class="left">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span>{{ $t('topBar.search.title') }}</span>
|
||||
</div>
|
||||
<div class="search-keydown">
|
||||
<i class="iconfont-sys" v-if="isWindows"></i>
|
||||
<i class="iconfont-sys" v-else></i>
|
||||
<span>k</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全屏按钮 -->
|
||||
<div class="btn-box screen-box" @click="toggleFullScreen">
|
||||
<div
|
||||
class="btn"
|
||||
:class="{ 'full-screen-btn': !isFullscreen, 'exit-full-screen-btn': isFullscreen }"
|
||||
>
|
||||
<i class="iconfont-sys">{{ isFullscreen ? '' : '' }}</i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<div class="btn-box notice-btn" @click="visibleNotice">
|
||||
<div class="btn notice-button">
|
||||
<i class="iconfont-sys notice-btn"></i>
|
||||
<span class="count notice-btn"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 聊天 -->
|
||||
<div class="btn-box chat-btn" @click="openChat">
|
||||
<div class="btn chat-button">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 语言 -->
|
||||
<div class="btn-box" v-if="showLanguage">
|
||||
<el-dropdown @command="changeLanguage" popper-class="langDropDownStyle">
|
||||
<div class="btn language-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
|
||||
<el-dropdown-item
|
||||
:command="item.value"
|
||||
:class="{ 'is-selected': locale === item.value }"
|
||||
>
|
||||
<span class="menu-txt">{{ item.label }}</span>
|
||||
<i v-if="locale === item.value" class="iconfont-sys"></i>
|
||||
</el-dropdown-item>
|
||||
</div>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<!-- 设置 -->
|
||||
<div class="btn-box" @click="openSetting">
|
||||
<el-popover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
|
||||
<template #reference>
|
||||
<div class="btn setting-btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<p
|
||||
>点击这里查看<span :style="{ color: systemThemeColor }"> 主题风格 </span>、
|
||||
<span :style="{ color: systemThemeColor }"> 开启顶栏菜单 </span>等更多配置
|
||||
</p>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
<!-- 切换主题 -->
|
||||
<div class="btn-box" @click="themeAnimation">
|
||||
<div class="btn theme-btn">
|
||||
<i class="iconfont-sys">{{ isDark ? '' : '' }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<div class="user">
|
||||
<el-popover
|
||||
ref="userMenuPopover"
|
||||
placement="bottom-end"
|
||||
:width="240"
|
||||
:hide-after="0"
|
||||
:offset="10"
|
||||
trigger="hover"
|
||||
:show-arrow="false"
|
||||
popper-class="user-menu-popover"
|
||||
popper-style="border: 1px solid var(--art-border-dashed-color); border-radius: calc(var(--custom-radius) / 2 + 4px); padding: 5px 16px; 5px 16px;"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="user-info-display">
|
||||
<div class="avatar">{{ getUserAvatar }}</div>
|
||||
<div class="info">
|
||||
<div class="username">{{ userInfo.username || '用户' }}</div>
|
||||
<div class="user-type">{{ userInfo.user_type_name || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="user-menu-box">
|
||||
<div class="user-head">
|
||||
<div class="avatar-large">{{ getUserAvatar }}</div>
|
||||
<div class="user-wrap">
|
||||
<span class="name">{{ userInfo.username }}</span>
|
||||
<span class="email">{{ userInfo.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="user-menu">
|
||||
<li @click="goPage('/system/user-center')">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
|
||||
</li>
|
||||
<li @click="lockScreen()">
|
||||
<i class="menu-icon iconfont-sys"></i>
|
||||
<span class="menu-txt">{{ $t('topBar.user.lockScreen') }}</span>
|
||||
</li>
|
||||
<div class="line"></div>
|
||||
<div class="logout-btn" @click="loginOut">
|
||||
{{ $t('topBar.user.logout') }}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArtWorkTab />
|
||||
|
||||
<art-notification v-model:value="showNotice" ref="notice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LanguageEnum, MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { HOME_PAGE } from '@/router/routesAlias'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import AppConfig from '@/config'
|
||||
import { languageOptions } from '@/locales'
|
||||
const isWindows = navigator.userAgent.includes('Windows')
|
||||
const { locale } = useI18n()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
showMenuButton,
|
||||
showRefreshButton,
|
||||
showLanguage,
|
||||
menuOpen,
|
||||
showCrumbs,
|
||||
systemThemeColor,
|
||||
showSettingGuide,
|
||||
menuType,
|
||||
isDark,
|
||||
tabStyle
|
||||
} = storeToRefs(settingStore)
|
||||
|
||||
const { language, getUserInfo: userInfo } = storeToRefs(userStore)
|
||||
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
|
||||
const showNotice = ref(false)
|
||||
const notice = ref(null)
|
||||
const userMenuPopover = ref()
|
||||
|
||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
import { themeAnimation } from '@/utils/theme/animation'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const menuTopWidth = computed(() => {
|
||||
return width.value * 0.5
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户头像显示文字
|
||||
* 英文:取第一个字母并大写
|
||||
* 中文:取第一个汉字
|
||||
*/
|
||||
const getUserAvatar = computed(() => {
|
||||
const username = userInfo.value.username
|
||||
if (!username) return 'U'
|
||||
|
||||
const firstChar = username.charAt(0)
|
||||
// 检查是否为中文字符(Unicode 范围:\u4e00-\u9fa5)
|
||||
const isChinese = /[\u4e00-\u9fa5]/.test(firstChar)
|
||||
|
||||
return isChinese ? firstChar : firstChar.toUpperCase()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initLanguage()
|
||||
document.addEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
const topBarWidth = (): string => {
|
||||
const { TOP, DUAL_MENU, TOP_LEFT } = MenuTypeEnum
|
||||
const { getMenuOpenWidth } = settingStore
|
||||
const { isFirstLevel } = router.currentRoute.value.meta
|
||||
const type = menuType.value
|
||||
const isMenuOpen = menuOpen.value
|
||||
|
||||
const isTopLayout = type === TOP || (type === TOP_LEFT && isFirstLevel)
|
||||
|
||||
if (isTopLayout) {
|
||||
return '100%'
|
||||
}
|
||||
|
||||
if (type === DUAL_MENU) {
|
||||
return isFirstLevel ? 'calc(100% - 80px)' : `calc(100% - 80px - ${getMenuOpenWidth})`
|
||||
}
|
||||
|
||||
return isMenuOpen ? `calc(100% - ${getMenuOpenWidth})` : `calc(100% - ${MenuWidth.CLOSE})`
|
||||
}
|
||||
|
||||
const visibleMenu = () => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
}
|
||||
|
||||
const goPage = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
const toHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
}
|
||||
|
||||
const loginOut = () => {
|
||||
closeUserMenu()
|
||||
setTimeout(() => {
|
||||
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
customClass: 'login-out-dialog'
|
||||
}).then(() => {
|
||||
userStore.logOut()
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const reload = (time: number = 0) => {
|
||||
setTimeout(() => {
|
||||
useCommon().refresh()
|
||||
}, time)
|
||||
}
|
||||
|
||||
const initLanguage = () => {
|
||||
locale.value = language.value
|
||||
}
|
||||
|
||||
const changeLanguage = (lang: LanguageEnum) => {
|
||||
if (locale.value === lang) return
|
||||
locale.value = lang
|
||||
userStore.setLanguage(lang)
|
||||
reload(50)
|
||||
}
|
||||
|
||||
const openSetting = () => {
|
||||
mittBus.emit('openSetting')
|
||||
|
||||
// 隐藏设置引导
|
||||
if (showSettingGuide.value) {
|
||||
settingStore.hideSettingGuide()
|
||||
}
|
||||
// 打开设置引导
|
||||
// settingStore.openSettingGuide()
|
||||
}
|
||||
|
||||
const openSearchDialog = () => {
|
||||
mittBus.emit('openSearchDialog')
|
||||
}
|
||||
|
||||
const bodyCloseNotice = (e: any) => {
|
||||
let { className } = e.target
|
||||
|
||||
if (showNotice.value) {
|
||||
if (typeof className === 'object') {
|
||||
showNotice.value = false
|
||||
return
|
||||
}
|
||||
if (className.indexOf('notice-btn') === -1) {
|
||||
showNotice.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNotice = () => {
|
||||
showNotice.value = !showNotice.value
|
||||
}
|
||||
|
||||
const openChat = () => {
|
||||
mittBus.emit('openChat')
|
||||
}
|
||||
|
||||
const lockScreen = () => {
|
||||
mittBus.emit('openLockScreen')
|
||||
}
|
||||
|
||||
const closeUserMenu = () => {
|
||||
setTimeout(() => {
|
||||
userMenuPopover.value.hide()
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
@use './mobile';
|
||||
</style>
|
||||
59
src/components/core/layouts/art-header-bar/mobile.scss
Normal file
59
src/components/core/layouts/art-header-bar/mobile.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
@media screen and (max-width: $device-ipad-pro) {
|
||||
.layout-top-bar {
|
||||
.menu {
|
||||
.right {
|
||||
.search-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $device-ipad) {
|
||||
.layout-top-bar {
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
width: 100% !important;
|
||||
|
||||
.refresh-btn,
|
||||
.screen-box {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $device-phone) {
|
||||
.layout-top-bar {
|
||||
.btn-box {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
.left {
|
||||
.logo {
|
||||
padding: 0 10px 0 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
.user {
|
||||
.cover {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
505
src/components/core/layouts/art-header-bar/style.scss
Normal file
505
src/components/core/layouts/art-header-bar/style.scss
Normal file
@@ -0,0 +1,505 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
@use '@styles/mixin.scss' as *;
|
||||
|
||||
.user-menu-popover {
|
||||
padding: 0 !important;
|
||||
|
||||
.user-menu-box {
|
||||
padding-top: 10px;
|
||||
|
||||
.user-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0 4px;
|
||||
|
||||
.avatar-large {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 10px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-wrap {
|
||||
width: calc(100% - 60px);
|
||||
height: 100%;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-800);
|
||||
|
||||
@include ellipsis();
|
||||
}
|
||||
|
||||
.email {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
|
||||
@include ellipsis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
padding: 16px 0;
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--art-border-color);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
display: block;
|
||||
width: 25px;
|
||||
font-size: 16px;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 10px 0;
|
||||
background-color: var(--art-border-color);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 7px 0;
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--art-text-gray-800);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: 7px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 10px rgb(var(--art-gray-300-rgb), 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
// background: transparent !important;
|
||||
background-color: var(--art-bg-color) !important;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&.tab-card {
|
||||
background-color: var(--art-main-bg-color) !important;
|
||||
|
||||
.menu {
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-google {
|
||||
background-color: var(--art-main-bg-color) !important;
|
||||
|
||||
.menu {
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 60px;
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
|
||||
&.refresh-btn:hover {
|
||||
i {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
&.language-btn:hover {
|
||||
i {
|
||||
animation: moveUp 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
&.setting-btn:hover {
|
||||
i {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
&.full-screen-btn:hover {
|
||||
i {
|
||||
animation: expand 0.6s forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&.exit-full-screen-btn:hover {
|
||||
i {
|
||||
animation: shrink 0.6s forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&.notice-button:hover {
|
||||
i {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
&.chat-button:hover {
|
||||
i {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
|
||||
&.menu-btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.chat-btn {
|
||||
.btn {
|
||||
position: relative;
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--el-color-success) !important;
|
||||
border-radius: 50%;
|
||||
animation: breathing 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
user-select: none;
|
||||
// background: var(--art-bg-color);
|
||||
|
||||
> .left {
|
||||
line-height: 60px;
|
||||
|
||||
.top-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.logo {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 10px 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo2 {
|
||||
display: none;
|
||||
padding-left: 15px;
|
||||
overflow: hidden;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
.el-route {
|
||||
margin-left: 10px;
|
||||
line-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
|
||||
.search-input {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 160px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.left {
|
||||
> i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
.search-keydown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
color: var(--art-gray-500);
|
||||
background-color: var(--art-bg-color);
|
||||
border: 1px solid var(--art-border-dashed-color);
|
||||
border-radius: 4px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-box {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.count {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
right: 17px;
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--el-color-danger) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
padding: 0 10px;
|
||||
line-height: 60px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover ul {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.user-info-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.2;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: var(--art-gray-800);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-type {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate180 {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expand {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveUp {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathing {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
60
src/components/core/layouts/art-layouts/index.vue
Normal file
60
src/components/core/layouts/art-layouts/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="layouts" :style="layoutStyle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@/assets/styles/transition.scss'
|
||||
import { MenuWidth, MenuTypeEnum } from '@/enums/appEnum'
|
||||
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { getTabConfig } from '@/utils/ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const menuStore = useMenuStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { menuType, menuOpen, showWorkTab, tabStyle } = storeToRefs(settingStore)
|
||||
|
||||
// 菜单宽度变化
|
||||
watchEffect(() => {
|
||||
const isOpen = menuOpen.value
|
||||
const width = isOpen ? settingStore.getMenuOpenWidth : MenuWidth.CLOSE
|
||||
menuStore.setMenuWidth(width)
|
||||
})
|
||||
|
||||
// 布局样式
|
||||
const layoutStyle = computed(() => ({
|
||||
paddingLeft: paddingLeft.value,
|
||||
paddingTop: paddingTop.value
|
||||
}))
|
||||
|
||||
// 左侧距离
|
||||
const paddingLeft = computed(() => {
|
||||
const { meta } = router.currentRoute.value
|
||||
const isFirstLevel = meta.isFirstLevel
|
||||
const type = menuType.value
|
||||
const isOpen = menuOpen.value
|
||||
const width = isOpen ? settingStore.getMenuOpenWidth : MenuWidth.CLOSE
|
||||
|
||||
switch (type) {
|
||||
case MenuTypeEnum.DUAL_MENU:
|
||||
return isFirstLevel ? '80px' : `calc(${width} + 80px)`
|
||||
case MenuTypeEnum.TOP_LEFT:
|
||||
return isFirstLevel ? 0 : width
|
||||
case MenuTypeEnum.TOP:
|
||||
return 0
|
||||
default:
|
||||
return width
|
||||
}
|
||||
})
|
||||
|
||||
// 顶部距离
|
||||
const paddingTop = computed(() => {
|
||||
const { openTop, closeTop } = getTabConfig(tabStyle.value)
|
||||
return `${showWorkTab.value ? openTop : closeTop}px`
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- 水平菜单 -->
|
||||
<template>
|
||||
<div class="top-menu">
|
||||
<el-menu
|
||||
:ellipsis="true"
|
||||
class="el-menu-popper-demo"
|
||||
mode="horizontal"
|
||||
:default-active="routerPath"
|
||||
text-color="var(--art-text-gray-700)"
|
||||
:popper-offset="-6"
|
||||
:style="{ width: width + 'px' }"
|
||||
background-color="transparent"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
>
|
||||
<HorizontalSubmenu
|
||||
v-for="item in filteredMenuItems"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:isMobile="false"
|
||||
:level="0"
|
||||
/>
|
||||
</el-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: [Array] as PropType<AppRouteRecord[]>,
|
||||
default: () => []
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 500
|
||||
}
|
||||
})
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
return props.list.filter((item) => !item.meta.isHide)
|
||||
})
|
||||
|
||||
const routerPath = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 去除 el-sub-menu 的底部横线
|
||||
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
// 去除 el-menu 的底部横线
|
||||
.top-menu {
|
||||
.el-menu {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 可自定义选中样式
|
||||
// :deep(.el-menu--horizontal .el-sub-menu.is-active) {
|
||||
// background-color: var(--art-gray-200);
|
||||
// margin: 10px 0;
|
||||
// border-radius: 6px;
|
||||
// }
|
||||
|
||||
@media only screen and (max-width: $device-notebook) {
|
||||
.top-menu {
|
||||
.el-menu {
|
||||
width: 38vw !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<el-sub-menu v-if="hasChildren" :index="item.path || item.meta.title">
|
||||
<template #title>
|
||||
<i
|
||||
class="menu-icon iconfont-sys"
|
||||
:style="{ color: theme?.iconColor }"
|
||||
v-html="item.meta.icon"
|
||||
></i>
|
||||
<span>{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 递归调用自身处理子菜单 -->
|
||||
<HorizontalSubmenu
|
||||
v-for="child in filteredChildren"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:theme="theme"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else-if="!item.meta.isHide"
|
||||
:index="item.path || item.meta.title"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<i
|
||||
class="menu-icon iconfont-sys"
|
||||
:style="{ color: theme?.iconColor }"
|
||||
v-html="item.meta.icon"
|
||||
></i>
|
||||
<span>{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div class="badge" v-if="item.meta.showBadge"></div>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<AppRouteRecord>,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isMobile: Boolean,
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 计算当前项是否有子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return props.item.children && props.item.children.length > 0
|
||||
})
|
||||
|
||||
// 过滤后的子菜单项(不包含隐藏的)
|
||||
const filteredChildren = computed(() => {
|
||||
return props.item.children?.filter((child) => !child.meta.isHide) || []
|
||||
})
|
||||
|
||||
const goPage = (item: AppRouteRecord) => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-sub-menu {
|
||||
padding: 0 !important;
|
||||
|
||||
:deep(.el-sub-menu__title) {
|
||||
padding: 0 30px 0 15px !important;
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
right: 10px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
199
src/components/core/layouts/art-menus/art-mixed-menu/index.vue
Normal file
199
src/components/core/layouts/art-menus/art-mixed-menu/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!-- 混合菜单 -->
|
||||
<template>
|
||||
<div class="mixed-top-menu">
|
||||
<div class="scroll-btn left" v-show="showLeftArrow" @click="scroll('left')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</div>
|
||||
|
||||
<el-scrollbar
|
||||
ref="scrollbarRef"
|
||||
wrap-class="scrollbar-wrapper"
|
||||
:horizontal="true"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="scroll-bar">
|
||||
<template v-for="item in list" :key="item.meta.title">
|
||||
<div
|
||||
class="item"
|
||||
:class="{ active: isActive(item) }"
|
||||
@click="handleMenuJump(item, true)"
|
||||
v-if="!item.meta.isHide"
|
||||
>
|
||||
<i class="iconfont-sys" v-html="item.meta.icon"></i>
|
||||
<span>{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<div class="scroll-btn right" v-show="showRightArrow" @click="scroll('right')">
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
const route = useRoute()
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { getMenuOpenWidth: menuopenwidth } = storeToRefs(settingStore)
|
||||
|
||||
defineProps({
|
||||
list: {
|
||||
type: [Array] as PropType<AppRouteRecord[]>,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const isActive = (item: AppRouteRecord): boolean => {
|
||||
const currentPath = route.path
|
||||
|
||||
if (item.children?.length) {
|
||||
return item.children.some((child) => {
|
||||
if (child.children?.length) {
|
||||
return isActive(child)
|
||||
}
|
||||
return child.path === currentPath
|
||||
})
|
||||
}
|
||||
|
||||
return item.path === currentPath
|
||||
}
|
||||
|
||||
const scrollbarRef = ref()
|
||||
const showLeftArrow = ref(false)
|
||||
const showRightArrow = ref(false)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollbarRef.value) return
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
|
||||
showLeftArrow.value = scrollLeft > 0
|
||||
showRightArrow.value = scrollLeft + clientWidth < scrollWidth
|
||||
}
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (!scrollbarRef.value) return
|
||||
|
||||
const scrollDistance = 200
|
||||
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
|
||||
const targetScroll =
|
||||
direction === 'left' ? currentScroll - scrollDistance : currentScroll + scrollDistance
|
||||
|
||||
scrollbarRef.value.wrapRef.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mixed-top-menu {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||
bottom: 5px;
|
||||
display: none;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
:deep(.scrollbar-wrapper) {
|
||||
width: calc(60vw - v-bind(menuopenwidth));
|
||||
margin: 0 30px;
|
||||
}
|
||||
|
||||
.scroll-bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
white-space: nowrap;
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 40px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--main-color);
|
||||
background-color: var(--main-bg-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
margin: auto;
|
||||
content: '';
|
||||
background-color: var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&.right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-notebook) {
|
||||
.mixed-top-menu {
|
||||
:deep(.scrollbar-wrapper) {
|
||||
width: calc(48vw - v-bind(menuopenwidth));
|
||||
margin: 0 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
284
src/components/core/layouts/art-menus/art-sidebar-menu/index.vue
Normal file
284
src/components/core/layouts/art-menus/art-sidebar-menu/index.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<!-- 左侧菜单 或 双列菜单 -->
|
||||
<template>
|
||||
<div
|
||||
class="layout-sidebar"
|
||||
v-if="showLeftMenu || isDualMenu"
|
||||
:class="{ 'no-border': menuList.length === 0 }"
|
||||
>
|
||||
<!-- 双列菜单(左侧) -->
|
||||
<div class="dual-menu-left" :style="{ background: getMenuTheme.background }" v-if="isDualMenu">
|
||||
<ArtLogo class="logo" @click="toHome" />
|
||||
<el-scrollbar style="height: calc(100% - 135px)">
|
||||
<ul>
|
||||
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
:content="$t(menu.meta.title)"
|
||||
placement="right"
|
||||
:offset="25"
|
||||
:hide-after="0"
|
||||
:disabled="dualMenuShowText"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'is-active': menu.meta.isFirstLevel
|
||||
? menu.path === route.path
|
||||
: menu.path === firstLevelMenuPath
|
||||
}"
|
||||
:style="{
|
||||
margin: dualMenuShowText ? '5px' : '15px',
|
||||
height: dualMenuShowText ? '60px' : '46px'
|
||||
}"
|
||||
>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
v-html="menu.meta.icon"
|
||||
:style="{
|
||||
fontSize: dualMenuShowText ? '18px' : '22px',
|
||||
marginBottom: dualMenuShowText ? '5px' : '0'
|
||||
}"
|
||||
></i>
|
||||
<span v-if="dualMenuShowText">
|
||||
{{ $t(menu.meta.title) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
<div class="switch-btn" @click="setDualMenuMode">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧菜单 || 双列菜单(右侧) -->
|
||||
<div
|
||||
v-show="menuList.length > 0"
|
||||
class="menu-left"
|
||||
id="menu-left"
|
||||
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
|
||||
:style="{ background: getMenuTheme.background }"
|
||||
>
|
||||
<div class="header" @click="toHome" :style="{ background: getMenuTheme.background }">
|
||||
<ArtLogo class="logo" v-if="!isDualMenu" />
|
||||
<p
|
||||
:class="{ 'is-dual-menu-name': isDualMenu }"
|
||||
:style="{ color: getMenuTheme.systemNameColor, opacity: !menuOpen ? 0 : 1 }"
|
||||
style="font-size: 16px"
|
||||
>
|
||||
{{ AppConfig.systemInfo.name }}
|
||||
</p>
|
||||
</div>
|
||||
<el-menu
|
||||
:class="'el-menu-' + getMenuTheme.theme"
|
||||
:collapse="!menuOpen"
|
||||
:default-active="routerPath"
|
||||
:text-color="getMenuTheme.textColor"
|
||||
:unique-opened="uniqueOpened"
|
||||
:background-color="getMenuTheme.background"
|
||||
:active-text-color="getMenuTheme.textActiveColor"
|
||||
:default-openeds="defaultOpenedsArray"
|
||||
:popper-class="`menu-left-${getMenuTheme.theme}-popper`"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
>
|
||||
<SidebarSubmenu
|
||||
:list="menuList"
|
||||
:isMobile="isMobileModel"
|
||||
:theme="getMenuTheme"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</el-menu>
|
||||
|
||||
<div
|
||||
class="menu-model"
|
||||
@click="visibleMenu"
|
||||
:style="{
|
||||
opacity: !menuOpen ? 0 : 1,
|
||||
transform: showMobileModel ? 'scale(1)' : 'scale(0)'
|
||||
}"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { HOME_PAGE } from '@/router/routesAlias'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { isIframe } from '@/utils/navigation'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
const menuCloseWidth = MenuWidth.CLOSE
|
||||
|
||||
const openwidth = computed(() => getMenuOpenWidth.value)
|
||||
const closewidth = computed(() => menuCloseWidth)
|
||||
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
const showLeftMenu = computed(
|
||||
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
|
||||
)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
|
||||
const defaultOpenedsArray = ref([])
|
||||
|
||||
// 一级菜单列表
|
||||
const firstLevelMenus = computed(() => {
|
||||
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
|
||||
})
|
||||
|
||||
const menuList = computed(() => {
|
||||
const list = useMenuStore().menuList
|
||||
|
||||
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
||||
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
||||
return list
|
||||
}
|
||||
|
||||
// 处理 iframe 路径
|
||||
if (isIframe(route.path)) {
|
||||
return findIframeMenuList(route.path, list)
|
||||
}
|
||||
|
||||
const currentTopPath = `/${route.path.split('/')[1]}`
|
||||
|
||||
// 处理一级菜单
|
||||
if (route.meta.isFirstLevel) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 返回当前顶级路径对应的子菜单
|
||||
const currentMenu = list.find((menu) => menu.path === currentTopPath)
|
||||
return currentMenu?.children ?? []
|
||||
})
|
||||
|
||||
// 查找 iframe 对应的二级菜单列表
|
||||
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
||||
// 递归查找包含当前路径的菜单项
|
||||
const hasPath = (items: any[]) => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
return true
|
||||
}
|
||||
if (item.children && hasPath(item.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 遍历一级菜单查找匹配的子菜单
|
||||
for (const menu of menuList) {
|
||||
if (menu.children && hasPath(menu.children)) {
|
||||
return menu.children
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const firstLevelMenuPath = computed(() => {
|
||||
return route.matched[0].path
|
||||
})
|
||||
|
||||
const routerPath = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
listenerWindowResize()
|
||||
})
|
||||
|
||||
const isMobileModel = ref(false)
|
||||
const showMobileModel = ref(false)
|
||||
|
||||
watch(
|
||||
() => !menuOpen.value,
|
||||
(collapse: boolean) => {
|
||||
if (!collapse) {
|
||||
showMobileModel.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const toHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
}
|
||||
|
||||
let screenWidth = 0
|
||||
|
||||
const listenerWindowResize = () => {
|
||||
screenWidth = document.body.clientWidth
|
||||
|
||||
setMenuModel()
|
||||
|
||||
window.onresize = () => {
|
||||
return (() => {
|
||||
screenWidth = document.body.clientWidth
|
||||
setMenuModel()
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
const setMenuModel = () => {
|
||||
// 小屏幕折叠菜单
|
||||
if (screenWidth < 800) {
|
||||
settingStore.setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleMenu = () => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
|
||||
// 移动端模态框
|
||||
if (!showMobileModel.value) {
|
||||
showMobileModel.value = true
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
showMobileModel.value = false
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
if (document.body.clientWidth < 800) {
|
||||
settingStore.setMenuOpen(false)
|
||||
showMobileModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDualMenuMode = () => {
|
||||
settingStore.setDualMenuShowText(!dualMenuShowText.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './theme';
|
||||
|
||||
.layout-sidebar {
|
||||
// 展开的宽度
|
||||
.el-menu:not(.el-menu--collapse) {
|
||||
width: v-bind(openwidth);
|
||||
}
|
||||
|
||||
// 折叠后宽度
|
||||
.el-menu--collapse {
|
||||
width: v-bind(closewidth);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.layout-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&.no-border {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.dual-menu-left {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
|
||||
// 隐藏滚动条
|
||||
:deep(.el-scrollbar__bar.is-vertical) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: auto;
|
||||
margin-top: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--main-color);
|
||||
|
||||
i,
|
||||
span {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 15px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
transition: all 0.1s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.badge) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
background: #ff3860;
|
||||
border-radius: 50%;
|
||||
animation: breathe 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:deep(.text-badge) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 60px;
|
||||
cursor: pointer;
|
||||
|
||||
.logo {
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
p {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 64px;
|
||||
box-sizing: border-box;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
|
||||
&.is-dual-menu-name {
|
||||
right: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
// 防止菜单内的滚动影响整个页面滚动
|
||||
overscroll-behavior: contain;
|
||||
border-right: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-scroll-chaining: contain;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-model {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.layout-sidebar {
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.layout-sidebar {
|
||||
width: 0;
|
||||
|
||||
.menu-model {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba($color: #000, $alpha: 50%);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
@use '@styles/mixin.scss' as *;
|
||||
|
||||
// 重新修改菜单样式
|
||||
$menu-height: 46px; // 菜单高度
|
||||
$menu-icon-size: 20px; // 菜单图标大小
|
||||
$menu-font-size: 14px; // 菜单字体大小
|
||||
$hover-bg-color: rgba(var(--art-gray-200-rgb), 0.8); // 鼠标移入背景色
|
||||
|
||||
.layout-sidebar {
|
||||
// ---------------------- Modify default style ----------------------
|
||||
|
||||
// 菜单折叠样式
|
||||
.menu-left-close {
|
||||
.header {
|
||||
.logo {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单图标
|
||||
.menu-icon {
|
||||
margin-right: 10px;
|
||||
font-size: $menu-icon-size;
|
||||
}
|
||||
|
||||
// 菜单高度
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
height: $menu-height !important;
|
||||
margin-bottom: 4px;
|
||||
line-height: $menu-height !important;
|
||||
|
||||
span {
|
||||
font-size: $menu-font-size !important;
|
||||
|
||||
@include ellipsis();
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.menu-icon {
|
||||
// 选中菜单图标颜色
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧箭头
|
||||
.el-sub-menu__icon-arrow {
|
||||
width: 13px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
// 菜单折叠
|
||||
.el-menu--collapse {
|
||||
.el-sub-menu.is-active {
|
||||
.el-sub-menu__title {
|
||||
.iconfont-sys {
|
||||
// 选中菜单图标颜色
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Design theme menu ----------------------
|
||||
|
||||
.el-menu-design {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
width: calc(100% - 16px);
|
||||
margin-left: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
.menu-icon {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中颜色
|
||||
.el-menu-item.is-active {
|
||||
color: var(--main-color) !important;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
// 鼠标移入背景色
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
background: $hover-bg-color !important;
|
||||
}
|
||||
|
||||
// 右侧箭头
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Dark theme menu ----------------------
|
||||
.el-menu-dark {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
width: calc(100% - 16px);
|
||||
margin-left: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
.menu-icon {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中颜色
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--el-color-primary-light-1);
|
||||
|
||||
.menu-icon {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标移入背景色
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
background: #0f1015 !important;
|
||||
}
|
||||
|
||||
// 右侧箭头
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Light theme menu ----------------------
|
||||
.el-menu-light {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
.menu-icon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 选中颜色
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background: var(--main-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 鼠标移入背景色
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
background: $hover-bg-color !important;
|
||||
}
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.layout-sidebar {
|
||||
.el-menu-item.is-active {
|
||||
span {
|
||||
// 暗黑主题模式,选中菜单文字颜色
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.layout-sidebar {
|
||||
.el-menu-design {
|
||||
> .el-sub-menu {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-popper.is-pure {
|
||||
border: 0.5px solid var(--art-border-dashed-color) !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
// 菜单折叠 hover 弹窗样式
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
.el-menu--popup {
|
||||
padding: 8px;
|
||||
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:hover {
|
||||
background-color: var(--art-gray-200) !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
height: 40px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
height: 40px !important;
|
||||
margin-bottom: 5px;
|
||||
|
||||
.el-sub-menu__title {
|
||||
height: 40px !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单折叠 hover 弹窗样式(黑色菜单)
|
||||
.menu-left-dark-popper {
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
.el-menu--popup {
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:hover {
|
||||
background-color: rgb(255 255 255 / 8%) !important;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
color: #eee !important;
|
||||
background-color: rgb(255 255 255 / 8%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<template v-for="item in filteredMenuItems" :key="item.path">
|
||||
<!-- 包含子菜单的项目 -->
|
||||
<el-sub-menu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
||||
<template #title>
|
||||
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
|
||||
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div v-if="item.meta.showBadge" class="badge" style="right: 35px" />
|
||||
</template>
|
||||
<SidebarSubmenu
|
||||
:list="item.children"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
:theme="theme"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 普通菜单项 -->
|
||||
<el-menu-item
|
||||
v-else
|
||||
:index="item.path || item.meta.title"
|
||||
:level-item="level + 1"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<MenuItemIcon :icon="item.meta.icon" :color="theme?.iconColor" />
|
||||
<template #title>
|
||||
<span class="menu-name">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div v-if="item.meta.showBadge" class="badge" />
|
||||
<div v-if="item.meta.showTextBadge" class="text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
|
||||
// 类型定义
|
||||
interface Props {
|
||||
title?: string
|
||||
list?: AppRouteRecord[]
|
||||
theme?: {
|
||||
iconColor?: string
|
||||
}
|
||||
isMobile?: boolean
|
||||
level?: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
list: () => [],
|
||||
theme: () => ({}),
|
||||
isMobile: false,
|
||||
level: 0
|
||||
})
|
||||
|
||||
// Emits定义
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// 计算属性
|
||||
const filteredMenuItems = computed(() => filterRoutes(props.list))
|
||||
|
||||
// 跳转页面
|
||||
const goPage = (item: AppRouteRecord) => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
|
||||
// 关闭菜单
|
||||
const closeMenu = () => emit('close')
|
||||
|
||||
// 判断是否有子菜单
|
||||
const hasChildren = (item: AppRouteRecord): boolean => {
|
||||
return Boolean(item.children?.length)
|
||||
}
|
||||
|
||||
// 过滤菜单项
|
||||
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items
|
||||
.filter((item) => !item.meta.isHide)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterRoutes(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// 抽取图标组件
|
||||
const MenuItemIcon = defineComponent({
|
||||
name: 'MenuItemIcon',
|
||||
props: {
|
||||
icon: String,
|
||||
color: String
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('i', {
|
||||
class: 'menu-icon iconfont-sys',
|
||||
style: props.color ? { color: props.color } : undefined,
|
||||
innerHTML: props.icon
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
290
src/components/core/layouts/art-notification/index.vue
Normal file
290
src/components/core/layouts/art-notification/index.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div
|
||||
class="notice"
|
||||
v-show="visible"
|
||||
:style="{
|
||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||
opacity: show ? 1 : 0
|
||||
}"
|
||||
@click.stop=""
|
||||
>
|
||||
<div class="header">
|
||||
<span class="text">{{ $t('notice.title') }}</span>
|
||||
<span class="btn">{{ $t('notice.btnRead') }}</span>
|
||||
</div>
|
||||
<ul class="bar">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
:class="{ active: barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
</li>
|
||||
</ul>
|
||||
<div class="content">
|
||||
<div class="scroll">
|
||||
<!-- 通知 -->
|
||||
<ul class="notice-list" v-show="barActiveIndex === 0">
|
||||
<li v-for="(item, index) in noticeList" :key="index">
|
||||
<div
|
||||
class="icon"
|
||||
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
|
||||
>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
|
||||
v-html="getNoticeStyle(item.type).icon"
|
||||
>
|
||||
</i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 消息 -->
|
||||
<ul class="user-list" v-show="barActiveIndex === 1">
|
||||
<li v-for="(item, index) in msgList" :key="index">
|
||||
<div class="avatar">
|
||||
<img :src="item.avatar" />
|
||||
</div>
|
||||
<div class="text">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 待办 -->
|
||||
<ul class="base" v-show="barActiveIndex === 3">
|
||||
<li v-for="(item, index) in pendingList" :key="index">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-wrapper">
|
||||
<el-button class="view-all" @click="handleViewAll" v-ripple>
|
||||
{{ $t('notice.viewAll') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 100px"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import avatar1 from '@/assets/img/avatar/avatar1.webp'
|
||||
import avatar2 from '@/assets/img/avatar/avatar2.webp'
|
||||
import avatar3 from '@/assets/img/avatar/avatar3.webp'
|
||||
import avatar4 from '@/assets/img/avatar/avatar4.webp'
|
||||
import avatar5 from '@/assets/img/avatar/avatar5.webp'
|
||||
import avatar6 from '@/assets/img/avatar/avatar6.webp'
|
||||
import AppConfig from '@/config'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
showNotice(props.value)
|
||||
}
|
||||
)
|
||||
|
||||
const show = ref(false)
|
||||
const visible = ref(false)
|
||||
const barActiveIndex = ref(0)
|
||||
const pendingList: any = []
|
||||
|
||||
const barList = ref([
|
||||
{
|
||||
name: computed(() => t('notice.bar[0]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[1]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[2]')),
|
||||
num: 0
|
||||
}
|
||||
])
|
||||
|
||||
const noticeList = [
|
||||
{
|
||||
title: '新增国际化',
|
||||
time: '2024-6-13 0:10',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆给你发了一条消息',
|
||||
time: '2024-4-21 8:05',
|
||||
type: 'message'
|
||||
},
|
||||
{
|
||||
title: '小肥猪关注了你',
|
||||
time: '2020-3-17 21:12',
|
||||
type: 'collection'
|
||||
},
|
||||
{
|
||||
title: '新增使用文档',
|
||||
time: '2024-02-14 0:20',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '小肥猪给你发了一封邮件',
|
||||
time: '2024-1-20 0:15',
|
||||
type: 'email'
|
||||
},
|
||||
{
|
||||
title: '菜单mock本地真实数据',
|
||||
time: '2024-1-17 22:06',
|
||||
type: 'notice'
|
||||
}
|
||||
]
|
||||
const msgList: any = [
|
||||
{
|
||||
title: '池不胖 关注了你',
|
||||
time: '2021-2-26 23:50',
|
||||
avatar: avatar1
|
||||
},
|
||||
{
|
||||
title: '唐不苦 关注了你',
|
||||
time: '2021-2-21 8:05',
|
||||
avatar: avatar2
|
||||
},
|
||||
{
|
||||
title: '中小鱼 关注了你',
|
||||
time: '2020-1-17 21:12',
|
||||
avatar: avatar3
|
||||
},
|
||||
{
|
||||
title: '何小荷 关注了你',
|
||||
time: '2021-01-14 0:20',
|
||||
avatar: avatar4
|
||||
},
|
||||
{
|
||||
title: '誶誶淰 关注了你',
|
||||
time: '2020-12-20 0:15',
|
||||
avatar: avatar5
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆 关注了你',
|
||||
time: '2020-12-17 22:06',
|
||||
avatar: avatar6
|
||||
}
|
||||
]
|
||||
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index
|
||||
}
|
||||
|
||||
const getRandomColor = () => {
|
||||
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
|
||||
return AppConfig.systemMainColor[index]
|
||||
}
|
||||
|
||||
const noticeStyleMap = {
|
||||
email: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-warning))',
|
||||
backgroundColor: 'rgb(var(--art-bg-warning))'
|
||||
},
|
||||
message: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-success))',
|
||||
backgroundColor: 'rgb(var(--art-bg-success))'
|
||||
},
|
||||
collection: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-danger))',
|
||||
backgroundColor: 'rgb(var(--art-bg-danger))'
|
||||
},
|
||||
user: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-info))',
|
||||
backgroundColor: 'rgb(var(--art-bg-info))'
|
||||
},
|
||||
notice: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-primary))',
|
||||
backgroundColor: 'rgb(var(--art-bg-primary))'
|
||||
}
|
||||
}
|
||||
|
||||
const getNoticeStyle = (type: string) => {
|
||||
const defaultStyle = {
|
||||
icon: '',
|
||||
iconColor: '#FFFFFF',
|
||||
backgroundColor: getRandomColor()
|
||||
}
|
||||
|
||||
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
|
||||
|
||||
return {
|
||||
...style,
|
||||
backgroundColor: style.backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = open
|
||||
setTimeout(() => {
|
||||
show.value = open
|
||||
}, 5)
|
||||
} else {
|
||||
show.value = open
|
||||
setTimeout(() => {
|
||||
visible.value = open
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看全部
|
||||
const handleViewAll = () => {
|
||||
switch (barActiveIndex.value) {
|
||||
case 0:
|
||||
handleNoticeAll()
|
||||
break
|
||||
case 1:
|
||||
handleMsgAll()
|
||||
break
|
||||
case 2:
|
||||
handlePendingAll()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoticeAll = () => {}
|
||||
const handleMsgAll = () => {}
|
||||
const handlePendingAll = () => {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
262
src/components/core/layouts/art-notification/style.scss
Normal file
262
src/components/core/layouts/art-notification/style.scss
Normal file
@@ -0,0 +1,262 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
@use '@styles/mixin.scss' as *;
|
||||
|
||||
.notice {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
width: 360px;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
background: var(--art-main-bg-color);
|
||||
border: 1px solid var(--art-border-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 6px) !important;
|
||||
box-shadow:
|
||||
0 8px 26px -4px hsl(0deg 0% 8% / 15%),
|
||||
0 8px 9px -5px hsl(0deg 0% 8% / 6%);
|
||||
transition: all 0.2s;
|
||||
transform-origin: center top 0;
|
||||
will-change: top, left;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
margin-top: 15px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
line-height: 50px;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
li {
|
||||
height: 48px;
|
||||
margin-right: 20px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-700);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
|
||||
@include userSelect;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--main-color) !important;
|
||||
border-bottom: 2px solid var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: calc(100% - 95px);
|
||||
|
||||
.scroll {
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-100);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
width: calc(100% - 45px);
|
||||
margin-left: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-100);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
width: calc(100% - 45px);
|
||||
margin-left: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
padding: 15px 20px;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
position: relative;
|
||||
top: 100px;
|
||||
height: 100%;
|
||||
color: var(--art-gray-500);
|
||||
text-align: center;
|
||||
background: transparent !important;
|
||||
|
||||
i {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 15px;
|
||||
font-size: 12px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 15px;
|
||||
|
||||
.view-all {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.notice {
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.notice {
|
||||
top: 65px;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
}
|
||||
61
src/components/core/layouts/art-page-content/index.vue
Normal file
61
src/components/core/layouts/art-page-content/index.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- 内容布局 -->
|
||||
<template>
|
||||
<div class="layout-content" :style="containerStyle">
|
||||
<!-- 节日滚动 -->
|
||||
<ArtFestivalTextScroll />
|
||||
|
||||
<RouterView
|
||||
v-if="isRefresh"
|
||||
v-slot="{ Component, route }"
|
||||
:style="{ minHeight: containerMinHeight }"
|
||||
>
|
||||
<div v-if="isOpenRouteInfo === 'true'" class="route-info">
|
||||
{{ route.meta }}
|
||||
</div>
|
||||
|
||||
<!-- 路由动画 -->
|
||||
<Transition :name="pageTransition" mode="out-in" appear>
|
||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||
<component :is="Component" :key="route.path" v-if="route.meta.keepAlive" />
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
||||
<Transition :name="pageTransition" mode="out-in" appear>
|
||||
<component :is="Component" :key="route.path" v-if="!route.meta.keepAlive" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
// Store refs
|
||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||
|
||||
const { containerMinHeight } = useCommon()
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
maxWidth: containerWidth.value
|
||||
}
|
||||
})
|
||||
|
||||
// State
|
||||
const isRefresh = ref(true)
|
||||
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
|
||||
|
||||
// Methods
|
||||
const reload = () => {
|
||||
isRefresh.value = false
|
||||
nextTick(() => {
|
||||
isRefresh.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(refresh, reload)
|
||||
</script>
|
||||
486
src/components/core/layouts/art-screen-lock/index.vue
Normal file
486
src/components/core/layouts/art-screen-lock/index.vue
Normal file
@@ -0,0 +1,486 @@
|
||||
<template>
|
||||
<div class="layout-lock-screen">
|
||||
<div v-if="!isLock">
|
||||
<el-dialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
||||
<div class="lock-content">
|
||||
<img class="cover" src="@imgs/user/avatar.webp" />
|
||||
<div class="username">{{ userInfo.userName }}</div>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" @submit.prevent="handleLock">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:placeholder="$t(`lockScreen.lock.inputPlaceholder`)"
|
||||
:show-password="true"
|
||||
ref="lockInputRef"
|
||||
@keyup.enter="handleLock"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="cursor-pointer" @click="handleLock">
|
||||
<Lock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="lock-btn" @click="handleLock" v-ripple>
|
||||
{{ $t(`lockScreen.lock.btnText`) }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<div class="unlock-content" v-else>
|
||||
<div class="box">
|
||||
<img class="cover" src="@imgs/user/avatar.webp" />
|
||||
<div class="username">{{ userInfo.userName }}</div>
|
||||
<el-form
|
||||
ref="unlockFormRef"
|
||||
:model="unlockForm"
|
||||
:rules="rules"
|
||||
@submit.prevent="handleUnlock"
|
||||
>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="unlockForm.password"
|
||||
type="password"
|
||||
:placeholder="$t(`lockScreen.unlock.inputPlaceholder`)"
|
||||
:show-password="true"
|
||||
ref="unlockInputRef"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="cursor-pointer" @click="handleUnlock">
|
||||
<Unlock />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" class="unlock-btn" @click="handleUnlock" v-ripple>
|
||||
{{ $t(`lockScreen.unlock.btnText`) }}
|
||||
</el-button>
|
||||
<el-button text class="login-btn" @click="toLogin">
|
||||
{{ $t(`lockScreen.unlock.backBtnText`) }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, Unlock } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
|
||||
const userStore = useUserStore()
|
||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||
|
||||
const visible = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: t('lockScreen.lock.inputPlaceholder'),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const unlockFormRef = ref<FormInstance>()
|
||||
const unlockForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 添加禁用控制台的函数
|
||||
const disableDevTools = () => {
|
||||
// 禁用右键菜单
|
||||
const handleContextMenu = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('contextmenu', handleContextMenu, true)
|
||||
|
||||
// 禁用开发者工具相关快捷键
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isLock.value) return
|
||||
|
||||
// 禁用 F12
|
||||
if (e.key === 'F12') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (['i', 'j', 'c', 'k'].includes(key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+U (查看源代码)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+S (保存页面)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+A (全选)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+P (打印)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+F (查找)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Alt+Tab (切换窗口)
|
||||
if (e.altKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Tab (切换标签页)
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+W (关闭标签页)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+R 和 F5 (刷新页面)
|
||||
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Shift+R (强制刷新)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
|
||||
// 禁用选择文本
|
||||
const handleSelectStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('selectstart', handleSelectStart, true)
|
||||
|
||||
// 禁用拖拽
|
||||
const handleDragStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('dragstart', handleDragStart, true)
|
||||
|
||||
// 监听开发者工具打开状态
|
||||
let devtools = { open: false, orientation: null }
|
||||
const threshold = 160
|
||||
|
||||
const checkDevTools = () => {
|
||||
if (!isLock.value) return
|
||||
|
||||
if (
|
||||
window.outerHeight - window.innerHeight > threshold ||
|
||||
window.outerWidth - window.innerWidth > threshold
|
||||
) {
|
||||
if (!devtools.open) {
|
||||
devtools.open = true
|
||||
// 检测到开发者工具打开,可以在这里添加额外的处理逻辑
|
||||
console.clear()
|
||||
document.body.innerHTML =
|
||||
'<div style="position:fixed;top:0;left:0;width:100%;height:100%;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;font-size:24px;z-index:99999;">系统已锁定,请勿尝试打开开发者工具</div>'
|
||||
}
|
||||
} else {
|
||||
devtools.open = false
|
||||
}
|
||||
}
|
||||
|
||||
// 定期检查开发者工具状态
|
||||
const devToolsInterval = setInterval(checkDevTools, 500)
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('selectstart', handleSelectStart, true)
|
||||
document.removeEventListener('dragstart', handleDragStart, true)
|
||||
clearInterval(devToolsInterval)
|
||||
}
|
||||
}
|
||||
|
||||
watch(isLock, (newValue) => {
|
||||
if (newValue) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
} else {
|
||||
document.body.style.overflow = 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// 存储清理函数
|
||||
let cleanupDevTools: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
mittBus.on('openLockScreen', openLockScreen)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isLock.value) {
|
||||
visible.value = true
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 初始化禁用开发者工具功能
|
||||
cleanupDevTools = disableDevTools()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = 'auto'
|
||||
// 清理禁用开发者工具的事件监听器
|
||||
if (cleanupDevTools) {
|
||||
cleanupDevTools()
|
||||
cleanupDevTools = null
|
||||
}
|
||||
})
|
||||
|
||||
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
|
||||
try {
|
||||
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
|
||||
CryptoJS.enc.Utf8
|
||||
)
|
||||
return inputPassword === decryptedPassword
|
||||
} catch (error) {
|
||||
console.error('密码解密失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlock = async () => {
|
||||
if (!unlockFormRef.value) return
|
||||
|
||||
await unlockFormRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
userStore.setLockStatus(false)
|
||||
userStore.setLockPassword('')
|
||||
unlockForm.password = ''
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
console.error('更新store失败:', error)
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(t('lockScreen.pwdError'))
|
||||
}
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.altKey && event.key.toLowerCase() === '¬') {
|
||||
event.preventDefault()
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString()
|
||||
userStore.setLockStatus(true)
|
||||
userStore.setLockPassword(encryptedPassword)
|
||||
visible.value = false
|
||||
formData.password = ''
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toLogin = () => {
|
||||
userStore.logOut()
|
||||
}
|
||||
|
||||
const openLockScreen = () => {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 添加输入框的 ref
|
||||
const lockInputRef = ref<any>(null)
|
||||
const unlockInputRef = ref<any>(null)
|
||||
|
||||
// 修改处理方法
|
||||
const handleDialogOpen = () => {
|
||||
setTimeout(() => {
|
||||
lockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout-lock-screen {
|
||||
.el-dialog {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.lock-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.cover {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin: 15px 0;
|
||||
margin-top: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-form {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 100%;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.unlock-content {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
background-image: url('@imgs/lock/lock_screen_1.webp');
|
||||
background-size: cover;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
padding: 30px;
|
||||
background: rgb(255 255 255 / 90%);
|
||||
border-radius: 10px;
|
||||
|
||||
.cover {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-top: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.username {
|
||||
margin: 15px 0;
|
||||
margin-top: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.el-form {
|
||||
width: 100%;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-top: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.unlock-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: block;
|
||||
margin: 10px auto;
|
||||
color: #333 !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContainerWidthEnum } from '@/enums/appEnum'
|
||||
import AppConfig from '@/config'
|
||||
|
||||
/**
|
||||
* 设置项配置选项管理
|
||||
*/
|
||||
export function useSettingsConfig() {
|
||||
const { t } = useI18n()
|
||||
|
||||
// 标签页风格选项
|
||||
const tabStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'tab-default',
|
||||
label: t('setting.tabStyle.default')
|
||||
},
|
||||
{
|
||||
value: 'tab-card',
|
||||
label: t('setting.tabStyle.card')
|
||||
},
|
||||
{
|
||||
value: 'tab-google',
|
||||
label: t('setting.tabStyle.google')
|
||||
}
|
||||
])
|
||||
|
||||
// 页面切换动画选项
|
||||
const pageTransitionOptions = computed(() => [
|
||||
{
|
||||
value: '',
|
||||
label: t('setting.transition.list.none')
|
||||
},
|
||||
{
|
||||
value: 'fade',
|
||||
label: t('setting.transition.list.fade')
|
||||
},
|
||||
{
|
||||
value: 'slide-left',
|
||||
label: t('setting.transition.list.slideLeft')
|
||||
},
|
||||
{
|
||||
value: 'slide-bottom',
|
||||
label: t('setting.transition.list.slideBottom')
|
||||
},
|
||||
{
|
||||
value: 'slide-top',
|
||||
label: t('setting.transition.list.slideTop')
|
||||
}
|
||||
])
|
||||
|
||||
// 圆角大小选项
|
||||
const customRadiusOptions = [
|
||||
{ value: '0', label: '0' },
|
||||
{ value: '0.25', label: '0.25' },
|
||||
{ value: '0.5', label: '0.5' },
|
||||
{ value: '0.75', label: '0.75' },
|
||||
{ value: '1', label: '1' }
|
||||
]
|
||||
|
||||
// 容器宽度选项
|
||||
const containerWidthOptions = computed(() => [
|
||||
{
|
||||
value: ContainerWidthEnum.FULL,
|
||||
label: t('setting.container.list[0]'),
|
||||
icon: ''
|
||||
},
|
||||
{
|
||||
value: ContainerWidthEnum.BOXED,
|
||||
label: t('setting.container.list[1]'),
|
||||
icon: ''
|
||||
}
|
||||
])
|
||||
|
||||
// 盒子样式选项
|
||||
const boxStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'border-mode',
|
||||
label: t('setting.box.list[0]'),
|
||||
type: 'border-mode' as const
|
||||
},
|
||||
{
|
||||
value: 'shadow-mode',
|
||||
label: t('setting.box.list[1]'),
|
||||
type: 'shadow-mode' as const
|
||||
}
|
||||
])
|
||||
|
||||
// 从配置文件获取的选项
|
||||
const configOptions = {
|
||||
// 主题色彩选项
|
||||
mainColors: AppConfig.systemMainColor,
|
||||
|
||||
// 主题风格选项
|
||||
themeList: AppConfig.settingThemeList,
|
||||
|
||||
// 菜单布局选项
|
||||
menuLayoutList: AppConfig.menuLayoutList
|
||||
}
|
||||
|
||||
// 基础设置项配置
|
||||
const basicSettingsConfig = computed(() => [
|
||||
{
|
||||
key: 'showWorkTab',
|
||||
label: t('setting.basics.list.multiTab'),
|
||||
type: 'switch' as const,
|
||||
handler: 'workTab'
|
||||
},
|
||||
{
|
||||
key: 'uniqueOpened',
|
||||
label: t('setting.basics.list.accordion'),
|
||||
type: 'switch' as const,
|
||||
handler: 'uniqueOpened'
|
||||
},
|
||||
{
|
||||
key: 'showMenuButton',
|
||||
label: t('setting.basics.list.collapseSidebar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'menuButton'
|
||||
},
|
||||
{
|
||||
key: 'showRefreshButton',
|
||||
label: t('setting.basics.list.reloadPage'),
|
||||
type: 'switch' as const,
|
||||
handler: 'refreshButton'
|
||||
},
|
||||
{
|
||||
key: 'showCrumbs',
|
||||
label: t('setting.basics.list.breadcrumb'),
|
||||
type: 'switch' as const,
|
||||
handler: 'crumbs',
|
||||
mobileHide: true
|
||||
},
|
||||
{
|
||||
key: 'showLanguage',
|
||||
label: t('setting.basics.list.language'),
|
||||
type: 'switch' as const,
|
||||
handler: 'language'
|
||||
},
|
||||
{
|
||||
key: 'showNprogress',
|
||||
label: t('setting.basics.list.progressBar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'nprogress'
|
||||
},
|
||||
{
|
||||
key: 'colorWeak',
|
||||
label: t('setting.basics.list.weakMode'),
|
||||
type: 'switch' as const,
|
||||
handler: 'colorWeak'
|
||||
},
|
||||
{
|
||||
key: 'watermarkVisible',
|
||||
label: t('setting.basics.list.watermark'),
|
||||
type: 'switch' as const,
|
||||
handler: 'watermark'
|
||||
},
|
||||
{
|
||||
key: 'menuOpenWidth',
|
||||
label: t('setting.basics.list.menuWidth'),
|
||||
type: 'input-number' as const,
|
||||
handler: 'menuOpenWidth',
|
||||
min: 180,
|
||||
max: 320,
|
||||
step: 10,
|
||||
style: { width: '120px' },
|
||||
controlsPosition: 'right' as const
|
||||
},
|
||||
{
|
||||
key: 'tabStyle',
|
||||
label: t('setting.basics.list.tabStyle'),
|
||||
type: 'select' as const,
|
||||
handler: 'tabStyle',
|
||||
options: tabStyleOptions.value,
|
||||
style: { width: '120px' }
|
||||
},
|
||||
{
|
||||
key: 'pageTransition',
|
||||
label: t('setting.basics.list.pageTransition'),
|
||||
type: 'select' as const,
|
||||
handler: 'pageTransition',
|
||||
options: pageTransitionOptions.value,
|
||||
style: { width: '120px' }
|
||||
},
|
||||
{
|
||||
key: 'customRadius',
|
||||
label: t('setting.basics.list.borderRadius'),
|
||||
type: 'select' as const,
|
||||
handler: 'customRadius',
|
||||
options: customRadiusOptions,
|
||||
style: { width: '120px' }
|
||||
}
|
||||
])
|
||||
|
||||
return {
|
||||
// 选项配置
|
||||
tabStyleOptions,
|
||||
pageTransitionOptions,
|
||||
customRadiusOptions,
|
||||
containerWidthOptions,
|
||||
boxStyleOptions,
|
||||
configOptions,
|
||||
|
||||
// 设置项配置
|
||||
basicSettingsConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ContainerWidthEnum } from '@/enums/appEnum'
|
||||
|
||||
/**
|
||||
* 设置项通用处理逻辑
|
||||
*/
|
||||
export function useSettingsHandlers() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// DOM 操作相关
|
||||
const domOperations = {
|
||||
// 设置HTML类名
|
||||
setHtmlClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
if (add) {
|
||||
el.classList.add(className)
|
||||
} else {
|
||||
el.classList.remove(className)
|
||||
}
|
||||
},
|
||||
|
||||
// 设置根元素属性
|
||||
setRootAttribute: (attribute: string, value: string) => {
|
||||
const el = document.documentElement
|
||||
el.setAttribute(attribute, value)
|
||||
},
|
||||
|
||||
// 设置body类名
|
||||
setBodyClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('body')[0]
|
||||
if (add) {
|
||||
el.setAttribute('class', className)
|
||||
} else {
|
||||
el.removeAttribute('class')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用切换处理器
|
||||
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
|
||||
return () => {
|
||||
storeMethod()
|
||||
callback?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 通用值变更处理器
|
||||
const createValueHandler = <T>(
|
||||
storeMethod: (value: T) => void,
|
||||
callback?: (value: T) => void
|
||||
) => {
|
||||
return (value: T) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
storeMethod(value)
|
||||
callback?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 基础设置处理器
|
||||
const basicHandlers = {
|
||||
// 工作台标签页
|
||||
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
|
||||
|
||||
// 菜单手风琴
|
||||
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
|
||||
|
||||
// 显示菜单按钮
|
||||
menuButton: createToggleHandler(() => settingStore.setButton()),
|
||||
|
||||
// 显示刷新按钮
|
||||
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
|
||||
|
||||
// 显示面包屑
|
||||
crumbs: createToggleHandler(() => settingStore.setCrumbs()),
|
||||
|
||||
// 显示语言切换
|
||||
language: createToggleHandler(() => settingStore.setLanguage()),
|
||||
|
||||
// 显示进度条
|
||||
nprogress: createToggleHandler(() => settingStore.setNprogress()),
|
||||
|
||||
// 色弱模式
|
||||
colorWeak: createToggleHandler(
|
||||
() => settingStore.setColorWeak(),
|
||||
() => {
|
||||
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
|
||||
}
|
||||
),
|
||||
|
||||
// 水印显示
|
||||
watermark: createToggleHandler(() =>
|
||||
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
|
||||
),
|
||||
|
||||
// 菜单展开宽度
|
||||
menuOpenWidth: createValueHandler<number>((width: number) =>
|
||||
settingStore.setMenuOpenWidth(width)
|
||||
),
|
||||
|
||||
// 标签页风格
|
||||
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
|
||||
|
||||
// 页面切换动画
|
||||
pageTransition: createValueHandler<string>((transition: string) =>
|
||||
settingStore.setPageTransition(transition)
|
||||
),
|
||||
|
||||
// 圆角大小
|
||||
customRadius: createValueHandler<string>((radius: string) =>
|
||||
settingStore.setCustomRadius(radius)
|
||||
)
|
||||
}
|
||||
|
||||
// 盒子样式处理器
|
||||
const boxStyleHandlers = {
|
||||
// 设置盒子模式
|
||||
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
|
||||
// 防止重复设置
|
||||
if (
|
||||
(type === 'shadow-mode' && boxBorderMode.value === false) ||
|
||||
(type === 'border-mode' && boxBorderMode.value === true)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
domOperations.setRootAttribute('data-box-mode', type)
|
||||
settingStore.setBorderMode()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色设置处理器
|
||||
const colorHandlers = {
|
||||
// 选择主题色
|
||||
selectColor: (theme: string) => {
|
||||
settingStore.setElementTheme(theme)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// 容器设置处理器
|
||||
const containerHandlers = {
|
||||
// 设置容器宽度
|
||||
setWidth: (type: ContainerWidthEnum) => {
|
||||
settingStore.setContainerWidth(type)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domOperations,
|
||||
basicHandlers,
|
||||
boxStyleHandlers,
|
||||
colorHandlers,
|
||||
containerHandlers,
|
||||
createToggleHandler,
|
||||
createValueHandler
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import AppConfig from '@/config'
|
||||
import { SystemThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useCeremony } from '@/composables/useCeremony'
|
||||
import { useSettingsState } from './useSettingsState'
|
||||
import { useSettingsHandlers } from './useSettingsHandlers'
|
||||
|
||||
/**
|
||||
* 设置面板核心逻辑管理
|
||||
*/
|
||||
export function useSettingsPanel() {
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
|
||||
|
||||
// Composables
|
||||
const { openFestival, cleanup } = useCeremony()
|
||||
const { setSystemTheme, setSystemAutoTheme } = useTheme()
|
||||
const { initColorWeak } = useSettingsState()
|
||||
const { domOperations } = useSettingsHandlers()
|
||||
|
||||
// 响应式状态
|
||||
const showDrawer = ref(false)
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 记录窗口宽度变化前的菜单类型
|
||||
const beforeMenuType = ref<MenuTypeEnum>()
|
||||
const hasChangedMenu = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
|
||||
|
||||
// 主题相关处理
|
||||
const useThemeHandlers = () => {
|
||||
// 初始化系统颜色
|
||||
const initSystemColor = () => {
|
||||
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
||||
settingStore.setElementTheme(AppConfig.systemMainColor[0])
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化系统主题
|
||||
const initSystemTheme = () => {
|
||||
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
|
||||
setSystemAutoTheme()
|
||||
} else {
|
||||
setSystemTheme(systemThemeType.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
const listenerSystemTheme = () => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', initSystemTheme)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', initSystemTheme)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initSystemColor,
|
||||
initSystemTheme,
|
||||
listenerSystemTheme
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式布局处理
|
||||
const useResponsiveLayout = () => {
|
||||
const handleWindowResize = () => {
|
||||
watch(width, (newWidth: number) => {
|
||||
if (newWidth < 1000) {
|
||||
if (!hasChangedMenu.value) {
|
||||
beforeMenuType.value = menuType.value
|
||||
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
|
||||
settingStore.setMenuOpen(false)
|
||||
hasChangedMenu.value = true
|
||||
}
|
||||
} else {
|
||||
if (hasChangedMenu.value && beforeMenuType.value) {
|
||||
useSettingsState().switchMenuLayouts(beforeMenuType.value)
|
||||
settingStore.setMenuOpen(true)
|
||||
hasChangedMenu.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { handleWindowResize }
|
||||
}
|
||||
|
||||
// 抽屉控制
|
||||
const useDrawerControl = () => {
|
||||
// 打开抽屉
|
||||
const handleOpen = () => {
|
||||
setTimeout(() => {
|
||||
domOperations.setBodyClass('theme-change', true)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
domOperations.setBodyClass('theme-change', false)
|
||||
}
|
||||
|
||||
// 打开设置
|
||||
const openSetting = () => {
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
// 关闭设置
|
||||
const closeDrawer = () => {
|
||||
showDrawer.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen,
|
||||
handleClose,
|
||||
openSetting,
|
||||
closeDrawer
|
||||
}
|
||||
}
|
||||
|
||||
// Props 变化监听
|
||||
const usePropsWatcher = (props: { open?: boolean }) => {
|
||||
watch(
|
||||
() => props.open,
|
||||
(val: boolean | undefined) => {
|
||||
if (val !== undefined) {
|
||||
showDrawer.value = val
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化设置
|
||||
const useSettingsInitializer = () => {
|
||||
const themeHandlers = useThemeHandlers()
|
||||
const { openSetting } = useDrawerControl()
|
||||
let themeCleanup: (() => void) | null = null
|
||||
|
||||
const initializeSettings = () => {
|
||||
mittBus.on('openSetting', openSetting)
|
||||
themeHandlers.initSystemColor()
|
||||
themeCleanup = themeHandlers.listenerSystemTheme()
|
||||
initColorWeak()
|
||||
|
||||
// 设置盒子模式
|
||||
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
|
||||
setTimeout(() => {
|
||||
domOperations.setRootAttribute('data-box-mode', boxMode)
|
||||
}, 50)
|
||||
|
||||
themeHandlers.initSystemTheme()
|
||||
openFestival()
|
||||
}
|
||||
|
||||
const cleanupSettings = () => {
|
||||
themeCleanup?.()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
return {
|
||||
initializeSettings,
|
||||
cleanupSettings
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
showDrawer,
|
||||
|
||||
// 方法组合
|
||||
useThemeHandlers,
|
||||
useResponsiveLayout,
|
||||
useDrawerControl,
|
||||
usePropsWatcher,
|
||||
useSettingsInitializer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||
|
||||
/**
|
||||
* 设置状态管理
|
||||
*/
|
||||
export function useSettingsState() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// 色弱模式初始化
|
||||
const initColorWeak = () => {
|
||||
if (settingStore.colorWeak) {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
setTimeout(() => {
|
||||
el.classList.add('color-weak')
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单布局切换
|
||||
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
||||
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
settingStore.switchMenuLayouts(type)
|
||||
if (type === MenuTypeEnum.DUAL_MENU) {
|
||||
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 方法
|
||||
initColorWeak,
|
||||
switchMenuLayouts
|
||||
}
|
||||
}
|
||||
67
src/components/core/layouts/art-settings-panel/index.vue
Normal file
67
src/components/core/layouts/art-settings-panel/index.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="layout-settings">
|
||||
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
|
||||
<!-- 头部关闭按钮 -->
|
||||
<SettingHeader @close="closeDrawer" />
|
||||
<!-- 主题风格 -->
|
||||
<ThemeSettings />
|
||||
<!-- 菜单布局 -->
|
||||
<MenuLayoutSettings />
|
||||
<!-- 菜单风格 -->
|
||||
<MenuStyleSettings />
|
||||
<!-- 系统主题色 -->
|
||||
<ColorSettings />
|
||||
<!-- 盒子样式 -->
|
||||
<BoxStyleSettings />
|
||||
<!-- 容器宽度 -->
|
||||
<ContainerSettings />
|
||||
<!-- 基础配置 -->
|
||||
<BasicSettings />
|
||||
</SettingDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsPanel } from './composables/useSettingsPanel'
|
||||
|
||||
import SettingDrawer from './widget/SettingDrawer.vue'
|
||||
import SettingHeader from './widget/SettingHeader.vue'
|
||||
import ThemeSettings from './widget/ThemeSettings.vue'
|
||||
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
|
||||
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
|
||||
import ColorSettings from './widget/ColorSettings.vue'
|
||||
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
|
||||
import ContainerSettings from './widget/ContainerSettings.vue'
|
||||
import BasicSettings from './widget/BasicSettings.vue'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 使用设置面板逻辑
|
||||
const settingsPanel = useSettingsPanel()
|
||||
const { showDrawer } = settingsPanel
|
||||
|
||||
// 获取各种处理器
|
||||
const { handleWindowResize } = settingsPanel.useResponsiveLayout()
|
||||
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
|
||||
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
|
||||
|
||||
// 监听 props 变化
|
||||
settingsPanel.usePropsWatcher(props)
|
||||
|
||||
onMounted(() => {
|
||||
initializeSettings()
|
||||
handleWindowResize()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use './style';
|
||||
</style>
|
||||
150
src/components/core/layouts/art-settings-panel/style.scss
Normal file
150
src/components/core/layouts/art-settings-panel/style.scss
Normal file
@@ -0,0 +1,150 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
@use '@styles/mixin.scss' as *;
|
||||
|
||||
// 设置抽屉模态框样式
|
||||
.setting-modal {
|
||||
background: transparent !important;
|
||||
|
||||
.el-drawer {
|
||||
// 背景滤镜效果
|
||||
background: rgba($color: #fff, $alpha: 50%) !important;
|
||||
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
|
||||
|
||||
@include backdropBlur();
|
||||
|
||||
.setting-box-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: calc(100% + 15px);
|
||||
margin-bottom: 10px;
|
||||
|
||||
.setting-item {
|
||||
box-sizing: border-box;
|
||||
width: calc(33.333% - 15px);
|
||||
margin-right: 15px;
|
||||
text-align: center;
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--art-gray-100);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px 0 rgb(0 0 0 / 20%);
|
||||
transition: box-shadow 0.1s;
|
||||
|
||||
&.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border: 2px solid var(--main-color);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去除滚动条
|
||||
.el-drawer__body::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.setting-modal {
|
||||
.el-drawer {
|
||||
background: rgba($color: #000, $alpha: 50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-con {
|
||||
.box-style {
|
||||
.button {
|
||||
&.is-active {
|
||||
color: #fff !important;
|
||||
background-color: rgba(var(--art-gray-400-rgb), 0.7);
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
background-color: rgba($color: #000, $alpha: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去除火狐浏览器滚动条
|
||||
:deep(.el-drawer__body) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// 移动端隐藏
|
||||
@media screen and (max-width: $device-ipad) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.drawer-con {
|
||||
.style-item {
|
||||
width: calc(50% - 10px);
|
||||
margin-right: 10px;
|
||||
|
||||
&:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container-width {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.item {
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.basic-box {
|
||||
.item {
|
||||
padding: 6px 0;
|
||||
margin-top: 15px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕适配
|
||||
@media screen and (width <= 480px) {
|
||||
.drawer-con {
|
||||
padding: 0 8px 20px;
|
||||
|
||||
.main-color-wrap {
|
||||
.offset {
|
||||
justify-content: center;
|
||||
|
||||
> div {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="basic-settings">
|
||||
<SectionTitle :title="$t('setting.basics.title')" :style="{ marginTop: '40px' }" />
|
||||
<div class="basic-box">
|
||||
<SettingItem
|
||||
v-for="config in basicSettingsConfig"
|
||||
:key="config.key"
|
||||
:config="config"
|
||||
:model-value="getSettingValue(config.key)"
|
||||
@change="handleSettingChange(config.handler, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import SettingItem from './SettingItem.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { basicSettingsConfig } = useSettingsConfig()
|
||||
const { basicHandlers } = useSettingsHandlers()
|
||||
|
||||
// 获取store的响应式状态
|
||||
const {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
} = storeToRefs(settingStore)
|
||||
|
||||
// 创建设置值映射
|
||||
const settingValueMap = {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
}
|
||||
|
||||
// 获取设置值的方法
|
||||
const getSettingValue = (key: string) => {
|
||||
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
|
||||
return settingRef?.value ?? null
|
||||
}
|
||||
|
||||
// 统一的设置变更处理
|
||||
const handleSettingChange = (handlerName: string, value: any) => {
|
||||
const handler = (basicHandlers as any)[handlerName]
|
||||
if (typeof handler === 'function') {
|
||||
handler(value)
|
||||
} else {
|
||||
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-settings {
|
||||
padding-bottom: 30px;
|
||||
|
||||
.basic-box {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="box-style-settings">
|
||||
<SectionTitle :title="$t('setting.box.title')" :style="{ marginTop: '40px' }" />
|
||||
<div class="box-style">
|
||||
<div
|
||||
v-for="option in boxStyleOptions"
|
||||
:key="option.value"
|
||||
class="button"
|
||||
:class="{ 'is-active': isActive(option.type) }"
|
||||
@click="boxStyleHandlers.setBoxMode(option.type)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
const { boxStyleOptions } = useSettingsConfig()
|
||||
const { boxStyleHandlers } = useSettingsHandlers()
|
||||
|
||||
// 判断当前选项是否激活
|
||||
const isActive = (type: 'border-mode' | 'shadow-mode') => {
|
||||
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.box-style-settings {
|
||||
.box-style {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px;
|
||||
margin-top: 20px;
|
||||
background-color: var(--art-gray-200);
|
||||
border-radius: 7px;
|
||||
|
||||
.button {
|
||||
width: calc(50% - 3px);
|
||||
height: 34px;
|
||||
font-size: 14px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s !important;
|
||||
|
||||
&.is-active {
|
||||
color: var(--art-gray-800);
|
||||
background-color: var(--art-main-bg-color);
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
color: var(--art-gray-800);
|
||||
background-color: rgba($color: #000, $alpha: 4%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.box-style-settings {
|
||||
.box-style {
|
||||
.button {
|
||||
&.is-active {
|
||||
color: #fff !important;
|
||||
background-color: rgba(var(--art-gray-400-rgb), 0.7);
|
||||
}
|
||||
|
||||
&:hover:not(.is-active) {
|
||||
background-color: rgba($color: #000, $alpha: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="color-settings">
|
||||
<SectionTitle :title="$t('setting.color.title')" style="margin-top: 40px" />
|
||||
<div class="main-color-wrap">
|
||||
<div class="offset">
|
||||
<div
|
||||
v-for="color in configOptions.mainColors"
|
||||
:key="color"
|
||||
:style="{ background: `${color} !important` }"
|
||||
@click="colorHandlers.selectColor(color)"
|
||||
>
|
||||
<i class="iconfont-sys" v-show="color === systemThemeColor"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeColor } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { colorHandlers } = useSettingsHandlers()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.color-settings {
|
||||
.main-color-wrap {
|
||||
.offset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: calc(100% + 16px);
|
||||
|
||||
$size: 23px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: $size;
|
||||
height: $size;
|
||||
margin: 0 16px 10px 0;
|
||||
cursor: pointer;
|
||||
border-radius: $size;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="container-settings">
|
||||
<SectionTitle :title="$t('setting.container.title')" :style="{ marginTop: '50px' }" />
|
||||
<div class="container-width">
|
||||
<div
|
||||
v-for="option in containerWidthOptions"
|
||||
:key="option.value"
|
||||
class="item"
|
||||
:class="{ 'is-active': containerWidth === option.value }"
|
||||
@click="containerHandlers.setWidth(option.value)"
|
||||
>
|
||||
<i class="iconfont-sys" v-html="option.icon"></i>
|
||||
<span>{{ option.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { containerWidth } = storeToRefs(settingStore)
|
||||
const { containerWidthOptions } = useSettingsConfig()
|
||||
const { containerHandlers } = useSettingsHandlers()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-settings {
|
||||
.container-width {
|
||||
display: flex;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
margin-top: 20px;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 15px;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--art-border-color);
|
||||
border-radius: 10px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--main-color);
|
||||
|
||||
i {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 10px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div v-if="width > 1000">
|
||||
<SectionTitle :title="$t('setting.menuType.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.menuLayoutList"
|
||||
:key="item.value"
|
||||
@click="switchMenuLayouts(item.value)"
|
||||
>
|
||||
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }">
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsState } from '../composables/useSettingsState'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const settingStore = useSettingStore()
|
||||
const { menuType } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchMenuLayouts } = useSettingsState()
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<SectionTitle :title="$t('setting.menu.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="item in menuThemeList"
|
||||
:key="item.theme"
|
||||
@click="switchMenuStyles(item.theme)"
|
||||
>
|
||||
<div
|
||||
class="box"
|
||||
:class="{ 'is-active': item.theme === menuThemeType }"
|
||||
:style="{
|
||||
cursor: disabled ? 'no-drop' : 'pointer'
|
||||
}"
|
||||
>
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
const menuThemeList = AppConfig.themeList
|
||||
const settingStore = useSettingStore()
|
||||
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
|
||||
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value)
|
||||
|
||||
// 菜单样式切换
|
||||
const switchMenuStyles = (theme: MenuThemeEnum) => {
|
||||
if (isDualMenu.value || isTopMenu.value || isDark.value) {
|
||||
return
|
||||
}
|
||||
settingStore.switchMenuStyles(theme)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<p class="section-title" :style="style">
|
||||
{{ title }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
style?: Record<string, any>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.section-title {
|
||||
position: relative;
|
||||
margin: 30px 0 20px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-800);
|
||||
text-align: center;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
margin: auto;
|
||||
content: '';
|
||||
border-bottom: 1px solid rgba(var(--art-gray-300-rgb), 0.8);
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="setting-drawer">
|
||||
<el-drawer
|
||||
size="300px"
|
||||
v-model="visible"
|
||||
:lock-scroll="false"
|
||||
:with-header="false"
|
||||
:before-close="handleClose"
|
||||
:destroy-on-close="false"
|
||||
modal-class="setting-modal"
|
||||
@open="handleOpen"
|
||||
@close="handleDrawerClose"
|
||||
>
|
||||
<div class="drawer-con">
|
||||
<slot />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'open'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const handleOpen = () => {
|
||||
emit('open')
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-con {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="setting-header">
|
||||
<div class="close-wrap">
|
||||
<i class="iconfont-sys" @click="$emit('close')"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setting-header {
|
||||
.close-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: var(--art-gray-600);
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
color: var(--art-gray-700);
|
||||
background-color: rgb(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="setting-item" :class="{ 'mobile-hide': config.mobileHide }">
|
||||
<span class="label">{{ config.label }}</span>
|
||||
|
||||
<!-- 开关类型 -->
|
||||
<el-switch v-if="config.type === 'switch'" :model-value="modelValue" @change="handleChange" />
|
||||
|
||||
<!-- 数字输入类型 -->
|
||||
<el-input-number
|
||||
v-else-if="config.type === 'input-number'"
|
||||
:model-value="modelValue"
|
||||
:min="config.min"
|
||||
:max="config.max"
|
||||
:step="config.step"
|
||||
:style="config.style"
|
||||
:controls-position="config.controlsPosition"
|
||||
@change="handleChange"
|
||||
/>
|
||||
|
||||
<!-- 选择器类型 -->
|
||||
<el-select
|
||||
v-else-if="config.type === 'select'"
|
||||
:model-value="modelValue"
|
||||
:style="config.style"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
interface SettingItemConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: 'switch' | 'input-number' | 'select'
|
||||
handler: string
|
||||
mobileHide?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
style?: Record<string, string>
|
||||
controlsPosition?: '' | 'right'
|
||||
options?:
|
||||
| Array<{ value: any; label: string }>
|
||||
| ComputedRef<Array<{ value: any; label: string }>>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: SettingItemConfig
|
||||
modelValue: any
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', value: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 标准化选项,处理computed和普通数组
|
||||
const normalizedOptions = computed(() => {
|
||||
if (!props.config.options) return []
|
||||
|
||||
try {
|
||||
// 如果是 ComputedRef,则返回其值
|
||||
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
|
||||
return props.config.options.value || []
|
||||
}
|
||||
|
||||
// 如果是普通数组,直接返回
|
||||
return Array.isArray(props.config.options) ? props.config.options : []
|
||||
} catch (error) {
|
||||
console.warn('Error processing options for config:', props.config.key, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
try {
|
||||
emit('change', value)
|
||||
} catch (error) {
|
||||
console.error('Error handling change for config:', props.config.key, error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setting-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 25px;
|
||||
background: transparent !important;
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 768px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<SectionTitle :title="$t('setting.theme.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.themeList"
|
||||
:key="item.theme"
|
||||
@click="switchThemeStyles(item.theme)"
|
||||
>
|
||||
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeMode } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchThemeStyles } = useTheme()
|
||||
</script>
|
||||
379
src/components/core/layouts/art-work-tab/index.vue
Normal file
379
src/components/core/layouts/art-work-tab/index.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div class="worktab" :class="[tabStyle]" v-if="showWorkTab">
|
||||
<div class="scroll-view" ref="scrollRef">
|
||||
<ul
|
||||
class="tabs"
|
||||
ref="tabsRef"
|
||||
:style="{ transform: `translateX(${translateX}px)`, transition: `${transition}` }"
|
||||
>
|
||||
<li
|
||||
class="art-custom-card"
|
||||
v-for="(item, index) in list"
|
||||
:key="item.path"
|
||||
:ref="item.path"
|
||||
:class="{ 'activ-tab': item.path === activeTab }"
|
||||
:id="`scroll-li-${index}`"
|
||||
:style="{ padding: item.fixedTab ? '0 10px' : '0 8px 0 12px' }"
|
||||
@click="clickTab(item)"
|
||||
@contextmenu.prevent="(e: MouseEvent) => showMenu(e, item.path)"
|
||||
>
|
||||
{{ formatMenuTitle(item.title) }}
|
||||
<el-icon
|
||||
v-if="list.length > 1 && !item.fixedTab"
|
||||
@click.stop="closeWorktab('current', item.path)"
|
||||
>
|
||||
<Close />
|
||||
</el-icon>
|
||||
<div class="line"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<el-icon
|
||||
class="btn console-box art-custom-card"
|
||||
@click="(e: MouseEvent) => showMenu(e, activeTab)"
|
||||
>
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</div>
|
||||
<ArtMenuRight
|
||||
ref="menuRef"
|
||||
:menu-items="menuItems"
|
||||
:menu-width="140"
|
||||
:border-radius="10"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 导入必要的组件和工具
|
||||
import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { LocationQueryRaw, useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowDown, Close } from '@element-plus/icons-vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { formatMenuTitle } from '@/router/utils/utils'
|
||||
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { MenuItemType } from '../../others/ArtMenuRight.vue'
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
import { WorkTab } from '@/types'
|
||||
const { t } = useI18n()
|
||||
const store = useWorktabStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { currentRoute } = router
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const { tabStyle, showWorkTab } = storeToRefs(settingStore)
|
||||
|
||||
// 初始化状态和引用
|
||||
const scrollRef = ref<HTMLElement | null>(null) // 滚动容器引用
|
||||
const tabsRef = ref<HTMLElement | null>(null) // 标签列表容器引用
|
||||
const menuRef = ref() // 右键菜单引用
|
||||
|
||||
// 滚动相关状态
|
||||
const translateX = ref(0) // 标签列表水平偏移量
|
||||
const transition = ref('') // 过渡动画样式
|
||||
const clickedPath = ref('') // 当前点击的标签路径
|
||||
let startX = 0 // 触摸开始位置
|
||||
let currentX = 0 // 当前触摸位置
|
||||
|
||||
// 打开的标签页列表
|
||||
const list = computed(() => store.opened)
|
||||
// 当前激活的标签页
|
||||
const activeTab = computed(() => currentRoute.value.path)
|
||||
// 获取当前激活 tab 的 index
|
||||
const activeTabIndex = computed(() => list.value.findIndex((tab) => tab.path === activeTab.value))
|
||||
|
||||
// 右键菜单选项
|
||||
const menuItems = computed(() => {
|
||||
const clickedIndex = list.value.findIndex((tab) => tab.path === clickedPath.value)
|
||||
const isLastTab = clickedIndex === list.value.length - 1
|
||||
const isOneTab = list.value.length === 1
|
||||
const isCurrentTab = clickedPath.value === activeTab.value
|
||||
const currentTab = list.value[clickedIndex]
|
||||
|
||||
// 检查左侧标签页是否全部为固定标签页
|
||||
const leftTabs = list.value.slice(0, clickedIndex)
|
||||
const areAllLeftTabsFixed = leftTabs.length > 0 && leftTabs.every((tab) => tab.fixedTab)
|
||||
|
||||
// 检查右侧标签页是否全部为固定标签页
|
||||
const rightTabs = list.value.slice(clickedIndex + 1)
|
||||
const areAllRightTabsFixed = rightTabs.length > 0 && rightTabs.every((tab) => tab.fixedTab)
|
||||
|
||||
// 检查其他标签页是否全部为固定标签页
|
||||
const otherTabs = list.value.filter((_, index) => index !== clickedIndex)
|
||||
const areAllOtherTabsFixed = otherTabs.length > 0 && otherTabs.every((tab) => tab.fixedTab)
|
||||
|
||||
// 检查所有标签页是否全部为固定标签页
|
||||
const areAllTabsFixed = list.value.every((tab) => tab.fixedTab)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'refresh',
|
||||
label: t('worktab.btn.refresh'),
|
||||
icon: '',
|
||||
disabled: !isCurrentTab
|
||||
},
|
||||
{
|
||||
key: 'fixed',
|
||||
label: currentTab?.fixedTab ? t('worktab.btn.unfixed') : t('worktab.btn.fixed'),
|
||||
icon: '',
|
||||
disabled: false,
|
||||
showLine: true
|
||||
},
|
||||
{
|
||||
key: 'left',
|
||||
label: t('worktab.btn.closeLeft'),
|
||||
icon: '',
|
||||
disabled: clickedIndex === 0 || areAllLeftTabsFixed
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
label: t('worktab.btn.closeRight'),
|
||||
icon: '',
|
||||
disabled: isLastTab || areAllRightTabsFixed
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: t('worktab.btn.closeOther'),
|
||||
icon: '',
|
||||
disabled: isOneTab || areAllOtherTabsFixed
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t('worktab.btn.closeAll'),
|
||||
icon: '',
|
||||
disabled: isOneTab || areAllTabsFixed
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 获取当前标签页索引和元素
|
||||
const getCurTabEl = () =>
|
||||
document.getElementById(`scroll-li-${activeTabIndex.value}`) as HTMLElement
|
||||
|
||||
// 设置过渡动画
|
||||
const setTransition = () => {
|
||||
transition.value = 'transform 0.5s cubic-bezier(0.15, 0, 0.15, 1)'
|
||||
setTimeout(() => {
|
||||
transition.value = ''
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// 自动定位当前标签页到可视区域
|
||||
const worktabAutoPosition = () => {
|
||||
if (!scrollRef.value || !tabsRef.value) return
|
||||
|
||||
const scrollWidth = scrollRef.value.offsetWidth
|
||||
const ulWidth = tabsRef.value.offsetWidth
|
||||
const curTabEl = getCurTabEl()
|
||||
|
||||
if (!curTabEl) return
|
||||
|
||||
const { offsetLeft, clientWidth } = curTabEl
|
||||
const curTabRight = offsetLeft + clientWidth
|
||||
const targetLeft = scrollWidth - curTabRight
|
||||
|
||||
if (
|
||||
(offsetLeft > Math.abs(translateX.value) && curTabRight <= scrollWidth) ||
|
||||
(translateX.value < targetLeft && targetLeft < 0)
|
||||
)
|
||||
return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (curTabRight > scrollWidth) {
|
||||
translateX.value = Math.max(targetLeft - 6, scrollWidth - ulWidth)
|
||||
} else if (offsetLeft < Math.abs(translateX.value)) {
|
||||
translateX.value = -offsetLeft
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
listenerScroll() // 监听滚动事件
|
||||
addTouchListeners() // 添加触摸事件监听
|
||||
worktabAutoPosition() // 初始定位
|
||||
})
|
||||
|
||||
// 监听路由变化,自动定位标签页
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
() => {
|
||||
setTransition()
|
||||
worktabAutoPosition()
|
||||
}
|
||||
)
|
||||
|
||||
// 监听语言变化,重置标签页位置
|
||||
watch(
|
||||
() => userStore.language,
|
||||
() => {
|
||||
translateX.value = 0
|
||||
nextTick(() => {
|
||||
worktabAutoPosition()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 标签页操作方法
|
||||
const clickTab = (item: WorkTab) => {
|
||||
router.push({
|
||||
path: item.path,
|
||||
query: item.query as LocationQueryRaw
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭标签页的不同方式
|
||||
const closeWorktab = (type: string, tabPath: string) => {
|
||||
let path = typeof tabPath === 'string' ? tabPath : route.path
|
||||
|
||||
switch (type) {
|
||||
case 'current':
|
||||
store.removeTab(path)
|
||||
break
|
||||
case 'left':
|
||||
store.removeLeft(path)
|
||||
break
|
||||
case 'right':
|
||||
store.removeRight(path)
|
||||
break
|
||||
case 'other':
|
||||
store.removeOthers(path)
|
||||
break
|
||||
case 'all':
|
||||
store.removeAll()
|
||||
break
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
worktabClosePosition()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 关闭标签页后的位置调整
|
||||
const worktabClosePosition = () => {
|
||||
if (!scrollRef.value || !tabsRef.value) return
|
||||
|
||||
const curTabEl = getCurTabEl()
|
||||
if (!curTabEl) return
|
||||
|
||||
const { offsetLeft, clientWidth } = curTabEl
|
||||
const scrollWidth = scrollRef.value.offsetWidth
|
||||
const ulWidth = tabsRef.value.offsetWidth
|
||||
const curTabLeft = offsetLeft + clientWidth
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
translateX.value = curTabLeft > scrollWidth ? scrollWidth - ulWidth : 0
|
||||
})
|
||||
}
|
||||
|
||||
// 右键菜单相关方法
|
||||
const showMenu = (e: MouseEvent, path?: string) => {
|
||||
clickedPath.value = path || ''
|
||||
menuRef.value?.show(e)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleSelect = (item: MenuItemType) => {
|
||||
const { key } = item
|
||||
|
||||
// 刷新页面操作
|
||||
if (key === 'refresh') {
|
||||
useCommon().refresh()
|
||||
return
|
||||
}
|
||||
|
||||
// 固定
|
||||
if (key === 'fixed') {
|
||||
useWorktabStore().toggleFixedTab(clickedPath.value)
|
||||
return
|
||||
}
|
||||
|
||||
const activeIndex = list.value.findIndex((tab) => tab.path === activeTab.value)
|
||||
const clickedIndex = list.value.findIndex((tab) => tab.path === clickedPath.value)
|
||||
|
||||
// 定义需要导航的操作类型
|
||||
const navigationRules = {
|
||||
left: activeIndex < clickedIndex,
|
||||
right: activeIndex > clickedIndex,
|
||||
other: true
|
||||
} as const
|
||||
|
||||
// 处理标签跳转逻辑
|
||||
const shouldNavigate = navigationRules[key as keyof typeof navigationRules]
|
||||
|
||||
if (shouldNavigate) {
|
||||
router.push(clickedPath.value)
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
closeWorktab(key, clickedPath.value)
|
||||
}
|
||||
|
||||
// 滚动事件处理
|
||||
const listenerScroll = () => {
|
||||
const xMax = 0
|
||||
|
||||
if (tabsRef.value) {
|
||||
tabsRef.value.addEventListener(
|
||||
'wheel',
|
||||
(event: WheelEvent) => {
|
||||
if (scrollRef.value && tabsRef.value) {
|
||||
event.preventDefault()
|
||||
|
||||
if (tabsRef.value.offsetWidth <= scrollRef.value.offsetWidth) return
|
||||
|
||||
const xMin = scrollRef.value.offsetWidth - tabsRef.value.offsetWidth
|
||||
// 使用 deltaX 来处理水平滚动,使用 deltaY 来处理垂直滚动
|
||||
const delta =
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||
translateX.value = Math.min(Math.max(translateX.value - delta, xMin), xMax)
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸事件处理
|
||||
const addTouchListeners = () => {
|
||||
if (tabsRef.value) {
|
||||
tabsRef.value.addEventListener('touchstart', handleTouchStart)
|
||||
tabsRef.value.addEventListener('touchmove', handleTouchMove)
|
||||
tabsRef.value.addEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
startX = event.touches[0].clientX
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!scrollRef.value || !tabsRef.value) return
|
||||
|
||||
currentX = event.touches[0].clientX
|
||||
const deltaX = currentX - startX
|
||||
const xMin = scrollRef.value.offsetWidth - tabsRef.value.offsetWidth
|
||||
|
||||
translateX.value = Math.min(Math.max(translateX.value + deltaX, xMin), 0)
|
||||
startX = currentX
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setTransition()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
</style>
|
||||
215
src/components/core/layouts/art-work-tab/style.scss
Normal file
215
src/components/core/layouts/art-work-tab/style.scss
Normal file
@@ -0,0 +1,215 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.worktab {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 20px 10px;
|
||||
user-select: none;
|
||||
|
||||
.scroll-view {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.tabs {
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
background: transparent !important;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: var(--art-text-gray-600);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--art-main-bg-color);
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--custom-radius) / 2.5 + 2px) !important;
|
||||
transition: color 0.1s;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color) !important;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
padding: 2px;
|
||||
margin-left: 5px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgb(238 238 238 / 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activ-tab {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.tab-card {
|
||||
padding: 4px 20px;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
}
|
||||
|
||||
&.tab-google {
|
||||
padding: 5px 20px 0;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
.tabs {
|
||||
padding-left: 5px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
height: 37px !important;
|
||||
line-height: 37px !important;
|
||||
border: none !important;
|
||||
border-radius: calc(var(--custom-radius) / 2.5 + 4px) !important;
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
background: var(--art-border-dashed-color);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.line {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
$tab-radius-size: 20px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: $tab-radius-size;
|
||||
height: $tab-radius-size;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 30px transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: -$tab-radius-size;
|
||||
clip-path: inset(50% -10px 0 50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: -$tab-radius-size;
|
||||
clip-path: inset(50% 50% 0 -10px);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-sizing: border-box;
|
||||
color: var(--art-text-gray-600) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
border-bottom: 1px solid var(--art-main-bg-color) !important;
|
||||
border-radius: calc(var(--custom-radius) / 2.5 + 4px) !important;
|
||||
|
||||
.line {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover + li .line {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.activ-tab {
|
||||
color: var(--main-color) !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border-bottom: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
box-shadow: 0 0 0 30px var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.line {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.activ-tab + li .line {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
&:hover {
|
||||
color: var(--art-text-gray-700);
|
||||
background: var(--art-gray-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
|
||||
.btn {
|
||||
position: relative;
|
||||
top: 0;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--art-main-bg-color);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover ul {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&.history {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.tabs {
|
||||
li {
|
||||
i {
|
||||
&:hover {
|
||||
background: rgb(238 238 238 / 10%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad) {
|
||||
.worktab {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.worktab {
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
306
src/components/core/media/ArtCutterImg.vue
Normal file
306
src/components/core/media/ArtCutterImg.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<!-- github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
|
||||
<template>
|
||||
<div class="cutter-container">
|
||||
<div class="cutter-component">
|
||||
<div class="title">{{ title }}</div>
|
||||
<ImgCutter
|
||||
ref="imgCutterModal"
|
||||
@cutDown="cutDownImg"
|
||||
@onPrintImg="cutterPrintImg"
|
||||
@onImageLoadComplete="handleImageLoadComplete"
|
||||
@onImageLoadError="handleImageLoadError"
|
||||
@onClearAll="handleClearAll"
|
||||
v-bind="cutterProps"
|
||||
class="img-cutter"
|
||||
>
|
||||
<template #choose>
|
||||
<el-button type="primary" plain v-ripple>选择图片</el-button>
|
||||
</template>
|
||||
<template #cancel>
|
||||
<el-button type="danger" plain v-ripple>清除</el-button>
|
||||
</template>
|
||||
<template #confirm>
|
||||
<!-- <el-button type="primary" style="margin-left: 10px">确定</el-button> -->
|
||||
<div></div>
|
||||
</template>
|
||||
</ImgCutter>
|
||||
</div>
|
||||
|
||||
<div v-if="showPreview" class="preview-container">
|
||||
<div class="title">{{ previewTitle }}</div>
|
||||
<div
|
||||
class="preview-box"
|
||||
:style="{
|
||||
width: `${cutterProps.cutWidth}px`,
|
||||
height: `${cutterProps.cutHeight}px`
|
||||
}"
|
||||
>
|
||||
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
|
||||
</div>
|
||||
<el-button class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
|
||||
>下载图片</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImgCutter from 'vue-img-cutter'
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
|
||||
interface CutterProps {
|
||||
// 基础配置
|
||||
isModal?: boolean
|
||||
tool?: boolean
|
||||
toolBgc?: string
|
||||
title?: string
|
||||
previewTitle?: string
|
||||
showPreview?: boolean
|
||||
|
||||
// 尺寸相关
|
||||
boxWidth?: number
|
||||
boxHeight?: number
|
||||
cutWidth?: number
|
||||
cutHeight?: number
|
||||
sizeChange?: boolean
|
||||
|
||||
// 移动和缩放
|
||||
moveAble?: boolean
|
||||
imgMove?: boolean
|
||||
scaleAble?: boolean
|
||||
|
||||
// 图片相关
|
||||
originalGraph?: boolean
|
||||
crossOrigin?: boolean
|
||||
fileType?: 'png' | 'jpeg' | 'webp'
|
||||
quality?: number
|
||||
|
||||
// 水印
|
||||
watermarkText?: string
|
||||
watermarkFontSize?: number
|
||||
watermarkColor?: string
|
||||
|
||||
// 其他功能
|
||||
saveCutPosition?: boolean
|
||||
previewMode?: boolean
|
||||
|
||||
// 输入图片
|
||||
imgUrl?: string
|
||||
}
|
||||
|
||||
interface CutterResult {
|
||||
fileName: string
|
||||
file: File
|
||||
blob: Blob
|
||||
dataURL: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CutterProps>(), {
|
||||
// 基础配置默认值
|
||||
isModal: false,
|
||||
tool: true,
|
||||
toolBgc: '#fff',
|
||||
title: '图像裁剪',
|
||||
previewTitle: '图像预览',
|
||||
showPreview: true,
|
||||
|
||||
// 尺寸相关默认值
|
||||
boxWidth: 700,
|
||||
boxHeight: 458,
|
||||
cutWidth: 470,
|
||||
cutHeight: 270,
|
||||
sizeChange: true,
|
||||
|
||||
// 移动和缩放默认值
|
||||
moveAble: true,
|
||||
imgMove: true,
|
||||
scaleAble: true,
|
||||
|
||||
// 图片相关默认值
|
||||
originalGraph: true,
|
||||
crossOrigin: true,
|
||||
fileType: 'png',
|
||||
quality: 0.9,
|
||||
|
||||
// 水印默认值
|
||||
watermarkText: '',
|
||||
watermarkFontSize: 20,
|
||||
watermarkColor: '#ffffff',
|
||||
|
||||
// 其他功能默认值
|
||||
saveCutPosition: true,
|
||||
previewMode: true
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
|
||||
|
||||
const temImgPath = ref('')
|
||||
const imgCutterModal = ref()
|
||||
|
||||
// 计算属性:整合所有ImgCutter的props
|
||||
const cutterProps = computed(() => ({
|
||||
...props,
|
||||
WatermarkText: props.watermarkText,
|
||||
WatermarkFontSize: props.watermarkFontSize,
|
||||
WatermarkColor: props.watermarkColor
|
||||
}))
|
||||
|
||||
// 图片预加载
|
||||
function preloadImage(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve()
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化裁剪器
|
||||
async function initImgCutter() {
|
||||
if (props.imgUrl) {
|
||||
try {
|
||||
await preloadImage(props.imgUrl)
|
||||
imgCutterModal.value?.handleOpen({
|
||||
name: '封面图片',
|
||||
src: props.imgUrl
|
||||
})
|
||||
} catch (error) {
|
||||
emit('error', error)
|
||||
console.error('图片加载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
if (props.imgUrl) {
|
||||
temImgPath.value = props.imgUrl
|
||||
initImgCutter()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听图片URL变化
|
||||
watch(
|
||||
() => props.imgUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
temImgPath.value = newVal
|
||||
initImgCutter()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 实时预览
|
||||
function cutterPrintImg(result: { dataURL: string }) {
|
||||
temImgPath.value = result.dataURL
|
||||
}
|
||||
|
||||
// 裁剪完成
|
||||
function cutDownImg(result: CutterResult) {
|
||||
emit('update:imgUrl', result.dataURL)
|
||||
}
|
||||
|
||||
// 图片加载完成
|
||||
function handleImageLoadComplete(result: any) {
|
||||
emit('imageLoadComplete', result)
|
||||
}
|
||||
|
||||
// 图片加载失败
|
||||
function handleImageLoadError(error: any) {
|
||||
emit('error', error)
|
||||
emit('imageLoadError', error)
|
||||
}
|
||||
|
||||
// 清除所有
|
||||
function handleClearAll() {
|
||||
temImgPath.value = ''
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
function downloadImg() {
|
||||
console.log('下载图片')
|
||||
const a = document.createElement('a')
|
||||
a.href = temImgPath.value
|
||||
a.download = 'image.png'
|
||||
a.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cutter-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
|
||||
.title {
|
||||
padding-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cutter-component {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
.preview-box {
|
||||
background-color: #f6f6f6 !important;
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.toolBoxControl) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:deep(.dockMain) {
|
||||
right: 0;
|
||||
bottom: -50px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.copyright) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
margin-top: 60px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.cutter-container {
|
||||
:deep(.dockBtn) {
|
||||
background-color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.toolBox) {
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
:deep(.dialogMain) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
.btn {
|
||||
background-color: var(--el-color-primary) !important;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
97
src/components/core/media/ArtVideoPlayer.vue
Normal file
97
src/components/core/media/ArtVideoPlayer.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<!-- 视频播放器组件:https://h5player.bytedance.com/-->
|
||||
<template>
|
||||
<div :id="playerId" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Player from 'xgplayer'
|
||||
import 'xgplayer/dist/index.min.css'
|
||||
|
||||
// 组件属性定义
|
||||
const props = defineProps<{
|
||||
playerId: string // 播放器容器的唯一标识
|
||||
videoUrl: string // 视频源URL
|
||||
posterUrl: string // 视频封面图URL
|
||||
autoplay?: boolean // 是否自动播放
|
||||
volume?: number // 音量大小(0-1)
|
||||
playbackRates?: number[] // 可选的播放速率
|
||||
loop?: boolean // 是否循环播放
|
||||
muted?: boolean // 是否静音
|
||||
commonStyle?: VideoPlayerStyle // 播放器样式配置
|
||||
}>()
|
||||
|
||||
// 设置属性默认值
|
||||
const defaultAutoplay = props.autoplay ?? false
|
||||
const defaultVolume = props.volume ?? 0.5
|
||||
const defaultPlaybackRates = props.playbackRates ?? [0.5, 0.75, 1, 1.5, 2]
|
||||
const defaultLoop = props.loop ?? false
|
||||
const defaultMuted = props.muted ?? false
|
||||
|
||||
// 播放器实例引用
|
||||
const playerInstance = ref<Player | null>(null)
|
||||
|
||||
// 播放器样式接口定义
|
||||
interface VideoPlayerStyle {
|
||||
progressColor?: string // 进度条背景色
|
||||
playedColor?: string // 已播放部分颜色
|
||||
cachedColor?: string // 缓存部分颜色
|
||||
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
|
||||
volumeColor?: string // 音量控制器颜色
|
||||
}
|
||||
|
||||
// 默认样式配置
|
||||
const defaultStyle: VideoPlayerStyle = {
|
||||
progressColor: 'rgba(255, 255, 255, 0.3)',
|
||||
playedColor: '#00AEED',
|
||||
cachedColor: 'rgba(255, 255, 255, 0.6)',
|
||||
sliderBtnStyle: {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
backgroundColor: '#00AEED'
|
||||
},
|
||||
volumeColor: '#00AEED'
|
||||
}
|
||||
|
||||
// 组件挂载时初始化播放器
|
||||
onMounted(() => {
|
||||
playerInstance.value = new Player({
|
||||
id: props.playerId,
|
||||
lang: 'zh', // 设置界面语言为中文
|
||||
volume: defaultVolume,
|
||||
autoplay: defaultAutoplay,
|
||||
screenShot: true, // 启用截图功能
|
||||
url: props.videoUrl,
|
||||
poster: props.posterUrl,
|
||||
fluid: true, // 启用流式布局,自适应容器大小
|
||||
playbackRate: defaultPlaybackRates,
|
||||
loop: defaultLoop,
|
||||
muted: defaultMuted,
|
||||
commonStyle: {
|
||||
...defaultStyle,
|
||||
...props.commonStyle
|
||||
}
|
||||
})
|
||||
|
||||
// 播放事件监听器
|
||||
playerInstance.value.on('play', () => {
|
||||
console.log('Video is playing')
|
||||
})
|
||||
|
||||
// 暂停事件监听器
|
||||
playerInstance.value.on('pause', () => {
|
||||
console.log('Video is paused')
|
||||
})
|
||||
|
||||
// 错误事件监听器
|
||||
playerInstance.value.on('error', (error) => {
|
||||
console.error('Error occurred:', error)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载前清理播放器实例
|
||||
onBeforeUnmount(() => {
|
||||
if (playerInstance.value) {
|
||||
playerInstance.value.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
425
src/components/core/others/ArtMenuRight.vue
Normal file
425
src/components/core/others/ArtMenuRight.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<!-- 右键菜单 -->
|
||||
<template>
|
||||
<div class="menu-right">
|
||||
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
||||
<div v-show="visible" :style="menuStyle" class="context-menu">
|
||||
<ul class="menu-list" :style="menuListStyle">
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<!-- 普通菜单项 -->
|
||||
<li
|
||||
v-if="!item.children"
|
||||
class="menu-item"
|
||||
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<i v-if="item.icon" class="iconfont-sys" v-html="item.icon"></i>
|
||||
<span class="menu-label">{{ item.label }}</span>
|
||||
</li>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<li v-else class="menu-item submenu" :style="menuItemStyle">
|
||||
<div class="submenu-title">
|
||||
<i v-if="item.icon" class="iconfont-sys" v-html="item.icon"></i>
|
||||
<span class="menu-label">{{ item.label }}</span>
|
||||
<i class="iconfont-sys submenu-arrow"></i>
|
||||
</div>
|
||||
<ul class="submenu-list" :style="submenuListStyle">
|
||||
<li
|
||||
v-for="child in item.children"
|
||||
:key="child.key"
|
||||
class="menu-item"
|
||||
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(child)"
|
||||
>
|
||||
<i v-if="child.icon" class="iconfont-sys" v-html="child.icon"></i>
|
||||
<span class="menu-label">{{ child.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
export interface MenuItemType {
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
showLine?: boolean
|
||||
children?: MenuItemType[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
menuItems: MenuItemType[]
|
||||
/** 菜单宽度 */
|
||||
menuWidth?: number
|
||||
/** 子菜单宽度 */
|
||||
submenuWidth?: number
|
||||
/** 菜单项高度 */
|
||||
itemHeight?: number
|
||||
/** 边界距离 */
|
||||
boundaryDistance?: number
|
||||
/** 菜单内边距 */
|
||||
menuPadding?: number
|
||||
/** 菜单项水平内边距 */
|
||||
itemPaddingX?: number
|
||||
/** 菜单圆角 */
|
||||
borderRadius?: number
|
||||
/** 动画持续时间 */
|
||||
animationDuration?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menuWidth: 120,
|
||||
submenuWidth: 150,
|
||||
itemHeight: 32,
|
||||
boundaryDistance: 10,
|
||||
menuPadding: 5,
|
||||
itemPaddingX: 6,
|
||||
borderRadius: 6,
|
||||
animationDuration: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: MenuItemType): void
|
||||
(e: 'show'): void
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
|
||||
// 计算菜单样式
|
||||
const menuStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'fixed' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
zIndex: 2000,
|
||||
width: `${props.menuWidth}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单列表样式
|
||||
const menuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
padding: `${props.menuPadding}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单项样式
|
||||
const menuItemStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
height: `${props.itemHeight}px`,
|
||||
padding: `0 ${props.itemPaddingX}px`,
|
||||
borderRadius: '4px'
|
||||
})
|
||||
)
|
||||
|
||||
// 计算子菜单列表样式
|
||||
const submenuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minWidth: 'max-content',
|
||||
padding: `${props.menuPadding}px 0`,
|
||||
borderRadius: `${props.borderRadius}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单高度(用于边界检测)
|
||||
const calculateMenuHeight = (): number => {
|
||||
let totalHeight = props.menuPadding * 2 // 上下内边距
|
||||
|
||||
props.menuItems.forEach((item) => {
|
||||
totalHeight += props.itemHeight
|
||||
if (item.showLine) {
|
||||
totalHeight += 10 // 分割线额外高度
|
||||
}
|
||||
})
|
||||
|
||||
return totalHeight
|
||||
}
|
||||
|
||||
const show = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuHeight = calculateMenuHeight()
|
||||
|
||||
// 计算最佳位置
|
||||
let x = e.clientX
|
||||
let y = e.clientY
|
||||
|
||||
// 检查右边界
|
||||
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
||||
x = screenWidth - props.menuWidth - props.boundaryDistance
|
||||
}
|
||||
|
||||
// 检查下边界
|
||||
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
||||
y = screenHeight - menuHeight - props.boundaryDistance
|
||||
}
|
||||
|
||||
// 确保不会超出左边界和上边界
|
||||
x = Math.max(props.boundaryDistance, x)
|
||||
y = Math.max(props.boundaryDistance, y)
|
||||
|
||||
position.value = { x, y }
|
||||
visible.value = true
|
||||
|
||||
emit('show')
|
||||
|
||||
// 延迟添加事件监听器,避免立即触发关闭
|
||||
setTimeout(() => {
|
||||
if (visible.value) {
|
||||
document.addEventListener('click', hide, { once: true })
|
||||
document.addEventListener('contextmenu', hide, { once: true })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
if (visible.value) {
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
// 清理事件监听器
|
||||
document.removeEventListener('click', hide)
|
||||
document.removeEventListener('contextmenu', hide)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMenuClick = (item: MenuItemType) => {
|
||||
if (item.disabled) return
|
||||
emit('select', item)
|
||||
hide()
|
||||
}
|
||||
|
||||
// 动画钩子函数
|
||||
const onBeforeEnter = (el: Element) => {
|
||||
const element = el as HTMLElement
|
||||
element.style.transformOrigin = 'top left'
|
||||
}
|
||||
|
||||
const onAfterLeave = () => {
|
||||
// 清理逻辑
|
||||
document.removeEventListener('click', hide)
|
||||
document.removeEventListener('contextmenu', hide)
|
||||
}
|
||||
|
||||
// 导出方法供父组件调用
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
visible: computed(() => visible.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-right {
|
||||
.context-menu {
|
||||
width: v-bind('props.menuWidth + "px"');
|
||||
min-width: v-bind('props.menuWidth + "px"');
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: v-bind('props.borderRadius + "px"');
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
|
||||
.menu-list {
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
|
||||
&.has-line {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
i:not(.submenu-arrow) {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--art-gray-800);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
i:not(.submenu-arrow) {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.submenu {
|
||||
&:hover {
|
||||
.submenu-list {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.submenu-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.submenu-arrow {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-600);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .submenu-title .submenu-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.submenu-list {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
z-index: 2001;
|
||||
display: none;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
list-style: none;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.7);
|
||||
}
|
||||
|
||||
&.has-line {
|
||||
margin-bottom: 10px;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
i:not(.submenu-arrow) {
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--art-gray-800);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
i:not(.submenu-arrow) {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画样式
|
||||
.context-menu-enter-active,
|
||||
.context-menu-leave-active {
|
||||
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
||||
}
|
||||
|
||||
.context-menu-enter-from,
|
||||
.context-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.context-menu-enter-to,
|
||||
.context-menu-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/components/core/others/ArtWatermark.vue
Normal file
59
src/components/core/others/ArtWatermark.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<!-- 全局水印组件 -->
|
||||
<template>
|
||||
<div v-if="watermarkVisible" class="layout-watermark" :style="{ zIndex: zIndex }">
|
||||
<el-watermark
|
||||
:content="content"
|
||||
:font="{ fontSize: fontSize, color: fontColor }"
|
||||
:rotate="rotate"
|
||||
:gap="[gapX, gapY]"
|
||||
:offset="[offsetX, offsetY]"
|
||||
>
|
||||
<div style="height: 100vh"></div>
|
||||
</el-watermark>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
const settingStore = useSettingStore()
|
||||
const { watermarkVisible } = storeToRefs(settingStore)
|
||||
|
||||
interface WatermarkProps {
|
||||
content?: string
|
||||
visible?: boolean
|
||||
fontSize?: number
|
||||
fontColor?: string
|
||||
rotate?: number
|
||||
gapX?: number
|
||||
gapY?: number
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
// 定义组件属性,设置默认值
|
||||
withDefaults(defineProps<WatermarkProps>(), {
|
||||
content: AppConfig.systemInfo.name,
|
||||
visible: false,
|
||||
fontSize: 16,
|
||||
fontColor: 'rgba(128, 128, 128, 0.2)',
|
||||
rotate: -22,
|
||||
gapX: 100,
|
||||
gapY: 100,
|
||||
offsetX: 50,
|
||||
offsetY: 50,
|
||||
zIndex: 3100
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-watermark {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
374
src/components/core/tables/ArtTable.vue
Normal file
374
src/components/core/tables/ArtTable.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<!-- 表格组件,带分页(默认分页大于一页时显示) -->
|
||||
<template>
|
||||
<div
|
||||
class="art-table"
|
||||
:class="{ 'header-background': showHeaderBackground }"
|
||||
:style="{
|
||||
marginTop: marginTop + 'px',
|
||||
height: total ? 'calc(100% - 90px)' : 'calc(100% - 25px)'
|
||||
}"
|
||||
>
|
||||
<div class="table-container">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
:row-key="rowKey"
|
||||
:height="height"
|
||||
:max-height="maxHeight"
|
||||
:show-header="showHeader"
|
||||
:highlight-current-row="highlightCurrentRow"
|
||||
:size="tableSizeComputed"
|
||||
:stripe="stripeComputed"
|
||||
:border="borderComputed"
|
||||
:header-cell-style="{
|
||||
backgroundColor: showHeaderBackground ? 'var(--el-fill-color-lighter)' : '',
|
||||
fontWeight: '500'
|
||||
}"
|
||||
@row-click="handleRowClick"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<!-- 序号列 -->
|
||||
<el-table-column
|
||||
v-if="index && tableData.length > 0"
|
||||
type="index"
|
||||
width="60"
|
||||
label="序号"
|
||||
align="center"
|
||||
fixed="left"
|
||||
/>
|
||||
|
||||
<!-- 动态列 -->
|
||||
<slot v-if="tableData.length"></slot>
|
||||
|
||||
<!-- 空数据 -->
|
||||
<template #empty>
|
||||
<el-empty :description="emptyText" v-show="!loading" />
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="pagination && tableData.length > 0"
|
||||
class="table-pagination"
|
||||
:class="paginationAlign"
|
||||
>
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="pageSizes"
|
||||
:pager-count="isMobile ? 5 : 7"
|
||||
:total="total"
|
||||
:background="true"
|
||||
:size="paginationSize"
|
||||
:layout="paginationLayoutComputed"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
:hide-on-single-page="hideOnSinglePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
interface TableProps {
|
||||
/** 表格数据源 */
|
||||
data?: any[]
|
||||
/** 是否显示加载状态 */
|
||||
loading?: boolean
|
||||
/** 行数据的 Key,用于标识每一行数据 */
|
||||
rowKey?: string
|
||||
/** 是否显示边框 */
|
||||
border?: boolean | null
|
||||
/** 是否使用斑马纹样式 */
|
||||
stripe?: boolean | null
|
||||
/** 是否显示序号列 */
|
||||
index?: boolean
|
||||
/** 表格高度,可以是数字或 CSS 值 */
|
||||
height?: string | number
|
||||
/** 表格最大高度,可以是数字或 CSS 值 */
|
||||
maxHeight?: string | number
|
||||
/** 是否显示表头 */
|
||||
showHeader?: boolean
|
||||
/** 是否高亮当前行 */
|
||||
highlightCurrentRow?: boolean
|
||||
/** 空数据时显示的文本 */
|
||||
emptyText?: string
|
||||
/** 是否显示分页 */
|
||||
pagination?: boolean
|
||||
/** 总条目数 */
|
||||
total?: number
|
||||
/** 当前页码 */
|
||||
currentPage?: number
|
||||
/** 每页显示条目个数 */
|
||||
pageSize?: number
|
||||
/** 每页显示个数选择器的选项列表 */
|
||||
pageSizes?: number[]
|
||||
/** 只有一页时是否隐藏分页器 */
|
||||
hideOnSinglePage?: boolean
|
||||
/** 分页器的对齐方式 */
|
||||
paginationAlign?: 'left' | 'center' | 'right'
|
||||
/** 分页器的大小 */
|
||||
paginationSize?: 'small' | 'default' | 'large'
|
||||
/** 分页器的布局 */
|
||||
paginationLayout?: string
|
||||
/** 是否显示表头背景色 */
|
||||
showHeaderBackground?: boolean | null
|
||||
/** 表格距离顶部距离 */
|
||||
marginTop?: number
|
||||
/** 表格大小 */
|
||||
size?: 'small' | 'default' | 'large'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TableProps>(), {
|
||||
data: () => [],
|
||||
loading: false,
|
||||
rowKey: 'id',
|
||||
border: null,
|
||||
stripe: null,
|
||||
index: false,
|
||||
height: '100%',
|
||||
showHeader: true,
|
||||
highlightCurrentRow: false,
|
||||
emptyText: '暂无数据',
|
||||
pagination: true,
|
||||
total: 0,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
hideOnSinglePage: true,
|
||||
pageSizes: () => [10, 20, 30, 50],
|
||||
paginationAlign: 'center',
|
||||
paginationSize: 'default',
|
||||
paginationLayout: '',
|
||||
showHeaderBackground: null,
|
||||
marginTop: 20
|
||||
})
|
||||
|
||||
/*
|
||||
* 计算分页布局
|
||||
* 移动端跟pc端使用两种不同的布局
|
||||
*/
|
||||
const paginationLayoutComputed = computed(() => {
|
||||
if (props.paginationLayout) {
|
||||
return props.paginationLayout
|
||||
}
|
||||
return isMobile.value
|
||||
? 'prev, pager, next, jumper, sizes, total'
|
||||
: 'total, sizes, prev, pager, next, jumper'
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:currentPage',
|
||||
'update:pageSize',
|
||||
'row-click',
|
||||
'size-change',
|
||||
'current-change',
|
||||
'selection-change'
|
||||
])
|
||||
|
||||
const tableStore = useTableStore()
|
||||
const { tableSize } = storeToRefs(tableStore)
|
||||
|
||||
// 表格实例
|
||||
const tableRef = ref()
|
||||
|
||||
// 提供给父组件的方法
|
||||
defineExpose({
|
||||
// 展开所有行
|
||||
expandAll: () => {
|
||||
const elTable = tableRef.value
|
||||
if (!elTable) return
|
||||
|
||||
// 递归处理树形数据
|
||||
const processRows = (rows: any[]) => {
|
||||
rows.forEach((row) => {
|
||||
const hasChildren = row.children?.length > 0
|
||||
if (hasChildren) {
|
||||
elTable.toggleRowExpansion(row, true)
|
||||
processRows(row.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
processRows(props.data)
|
||||
},
|
||||
|
||||
// 收起所有行
|
||||
collapseAll: () => {
|
||||
const elTable = tableRef.value
|
||||
if (!elTable) return
|
||||
|
||||
// 递归处理树形数据
|
||||
const processRows = (rows: any[]) => {
|
||||
rows.forEach((row) => {
|
||||
const hasChildren = row.children?.length > 0
|
||||
if (hasChildren) {
|
||||
elTable.toggleRowExpansion(row, false)
|
||||
processRows(row.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
processRows(props.data)
|
||||
}
|
||||
})
|
||||
|
||||
// 表格大小 - props优先级高于store
|
||||
const tableSizeComputed = computed(() => {
|
||||
return props.size || tableSize.value
|
||||
})
|
||||
|
||||
// 斑马纹
|
||||
const stripeComputed = computed(() => {
|
||||
return props.stripe === null ? tableStore.isZebra : props.stripe
|
||||
})
|
||||
|
||||
// 边框
|
||||
const borderComputed = computed(() => {
|
||||
return props.border === null ? tableStore.isBorder : props.border
|
||||
})
|
||||
|
||||
// 表头背景
|
||||
const showHeaderBackground = computed(() => {
|
||||
return props.showHeaderBackground === null
|
||||
? tableStore.isHeaderBackground
|
||||
: props.showHeaderBackground
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = computed(() => {
|
||||
// 如果不显示分页或使用后端分页,直接返回原始数据
|
||||
if (!props.pagination || props.total > props.data.length) return props.data
|
||||
const start = (props.currentPage - 1) * props.pageSize
|
||||
const end = start + props.pageSize
|
||||
return props.data.slice(start, end)
|
||||
})
|
||||
|
||||
// 当前页
|
||||
const currentPage = computed({
|
||||
get: () => props.currentPage,
|
||||
set: (val) => emit('update:currentPage', val)
|
||||
})
|
||||
|
||||
// 每页条数
|
||||
const pageSize = computed({
|
||||
get: () => props.pageSize,
|
||||
set: (val) => emit('update:pageSize', val)
|
||||
})
|
||||
|
||||
// 行点击事件
|
||||
const handleRowClick = (row: any, column: any, event: any) => {
|
||||
emit('row-click', row, column, event)
|
||||
}
|
||||
|
||||
// 选择变化事件
|
||||
const handleSelectionChange = (selection: any) => {
|
||||
emit('selection-change', selection)
|
||||
}
|
||||
|
||||
// 每页条数改变
|
||||
const handleSizeChange = (val: number) => {
|
||||
emit('size-change', val)
|
||||
}
|
||||
|
||||
// 当前页改变
|
||||
const handleCurrentChange = (val: number) => {
|
||||
emit('current-change', val)
|
||||
|
||||
scrollToTop()
|
||||
}
|
||||
|
||||
// 表格滚动到顶部
|
||||
const scrollToTop = () => {
|
||||
nextTick(() => {
|
||||
if (tableRef.value) {
|
||||
tableRef.value.setScrollTop(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.art-table {
|
||||
.table-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
|
||||
// 分页对齐方式
|
||||
&.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
th.el-table__cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.header-background {
|
||||
:deep(.el-table) {
|
||||
th.el-table__cell {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解决el-image 和 el-table冲突层级冲突问题
|
||||
::v-deep(.el-table__cell) {
|
||||
position: static !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端分页
|
||||
@media (max-width: $device-phone) {
|
||||
:deep(.el-pagination) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-pagination__sizes {
|
||||
.el-select {
|
||||
width: 100px !important;
|
||||
|
||||
.el-select__wrapper {
|
||||
height: 30px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-pager {
|
||||
li {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pagination__jump {
|
||||
margin-left: 5px;
|
||||
|
||||
.el-input {
|
||||
height: 32px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
src/components/core/tables/ArtTableFullScreen.vue
Normal file
24
src/components/core/tables/ArtTableFullScreen.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="art-table-full-screen" :style="{ height: containerMinHeight }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useCommon } from '@/composables/useCommon'
|
||||
const { containerMinHeight } = useCommon()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.art-table-full-screen {
|
||||
:deep(#table-full-screen) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
@media (max-width: $device-phone) {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
303
src/components/core/tables/ArtTableHeader.vue
Normal file
303
src/components/core/tables/ArtTableHeader.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="table-header">
|
||||
<div class="left">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="btn" @click="refresh">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
|
||||
<ElDropdown @command="handleTableSizeChange">
|
||||
<div class="btn">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-for="item in tableSizeOptions" :key="item.value" class="table-size-btn-item">
|
||||
<ElDropdownItem
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:class="{ 'is-selected': tableSize === item.value }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<ElDropdown v-if="showViewToggle" @command="handleViewChange">
|
||||
<div class="btn">
|
||||
<i class="iconfont-sys" v-html="currentView === 'table' ? '' : ''"></i>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-for="item in viewOptions" :key="item.value" class="table-size-btn-item">
|
||||
<ElDropdownItem
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:class="{ 'is-selected': currentView === item.value }"
|
||||
>
|
||||
<i class="iconfont-sys" style="margin-right: 8px" v-html="item.icon"></i>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<div class="btn" @click="toggleFullScreen">
|
||||
<i class="iconfont-sys">{{ isFullScreen ? '' : '' }}</i>
|
||||
</div>
|
||||
|
||||
<!-- 列设置 -->
|
||||
<ElPopover placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="btn"><i class="iconfont-sys" style="font-size: 17px"></i> </div>
|
||||
</template>
|
||||
<div>
|
||||
<VueDraggable v-model="columns">
|
||||
<div v-for="item in columns" :key="item.prop || item.type" class="column-option">
|
||||
<div class="drag-icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<ElCheckbox v-model="item.checked" :disabled="item.disabled">{{
|
||||
item.label || (item.type === 'selection' ? t('table.selection') : '')
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<!-- 其他设置 -->
|
||||
<ElPopover placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="btn">
|
||||
<i class="iconfont-sys" style="font-size: 17px"></i>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
|
||||
t('table.zebra')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
|
||||
t('table.border')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox v-if="showHeaderBackground" v-model="isHeaderBackground" :value="true">{{
|
||||
t('table.headerBackground')
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TableSizeEnum } from '@/enums/formEnum'
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
import { ElPopover, ElCheckbox } from 'element-plus'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps({
|
||||
// 斑马纹
|
||||
showZebra: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 边框
|
||||
showBorder: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 表头背景
|
||||
showHeaderBackground: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 显示视图切换
|
||||
showViewToggle: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const columns = defineModel<ColumnOption[]>('columns', { required: true })
|
||||
|
||||
// 定义视图模式 model
|
||||
const currentView = defineModel<'table' | 'descriptions'>('currentView', { default: 'table' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'viewChange', view: 'table' | 'descriptions'): void
|
||||
}>()
|
||||
|
||||
interface ColumnOption {
|
||||
label?: string
|
||||
prop?: string
|
||||
type?: string
|
||||
width?: string | number
|
||||
fixed?: boolean | 'left' | 'right'
|
||||
sortable?: boolean
|
||||
filters?: any[]
|
||||
filterMethod?: (value: any, row: any) => boolean
|
||||
filterPlacement?: string
|
||||
disabled?: boolean
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
const tableSizeOptions = [
|
||||
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
|
||||
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
|
||||
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
|
||||
]
|
||||
|
||||
// 视图选项
|
||||
const viewOptions = [
|
||||
{ value: 'table', label: '表格视图', icon: '' },
|
||||
{ value: 'descriptions', label: '卡片视图', icon: '' }
|
||||
]
|
||||
|
||||
const tableStore = useTableStore()
|
||||
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
|
||||
|
||||
const refresh = () => {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 表格大小
|
||||
const handleTableSizeChange = (command: TableSizeEnum) => {
|
||||
useTableStore().setTableSize(command)
|
||||
}
|
||||
|
||||
// 视图切换
|
||||
const handleViewChange = (view: 'table' | 'descriptions') => {
|
||||
currentView.value = view
|
||||
emit('viewChange', view)
|
||||
}
|
||||
|
||||
const isFullScreen = ref(false)
|
||||
|
||||
// 全屏
|
||||
const toggleFullScreen = () => {
|
||||
const el = document.querySelector('#table-full-screen')
|
||||
if (!el) return
|
||||
isFullScreen.value = !isFullScreen.value
|
||||
|
||||
el.classList.toggle('el-full-screen')
|
||||
}
|
||||
|
||||
// 监听ESC键退出全屏
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isFullScreen.value) {
|
||||
toggleFullScreen()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isFullScreen.value) {
|
||||
toggleFullScreen()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.table-size-btn-item) {
|
||||
.el-dropdown-menu__item {
|
||||
margin-bottom: 3px !important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.el-dropdown-menu__item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.is-selected) {
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.8) !important;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 10px;
|
||||
color: var(--art-gray-700);
|
||||
cursor: pointer;
|
||||
background-color: rgba(var(--art-gray-200-rgb), 0.8);
|
||||
border-radius: 6px;
|
||||
transition: color 0.3s;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
color: var(--art-gray-700);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--art-gray-300-rgb), 0.75);
|
||||
|
||||
i {
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.column-option) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.drag-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
color: var(--art-gray-500);
|
||||
cursor: move;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $device-phone) {
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
|
||||
.btn {
|
||||
margin-right: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
src/components/core/text-effect/ArtFestivalTextScroll.vue
Normal file
42
src/components/core/text-effect/ArtFestivalTextScroll.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<!-- 节日文本滚动 -->
|
||||
<template>
|
||||
<div
|
||||
class="festival-text-scroll"
|
||||
:style="{
|
||||
height: showFestivalText ? '48px' : '0'
|
||||
}"
|
||||
>
|
||||
<ArtTextScroll
|
||||
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
|
||||
:text="currentFestivalData?.scrollText || ''"
|
||||
style="margin-bottom: 12px"
|
||||
show-close
|
||||
@close="handleClose"
|
||||
typewriter
|
||||
:speed="100"
|
||||
:typewriter-speed="150"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useCeremony } from '@/composables/useCeremony'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { showFestivalText } = storeToRefs(settingStore)
|
||||
|
||||
const { currentFestivalData } = useCeremony()
|
||||
|
||||
// 处理关闭节日文本
|
||||
const handleClose = () => {
|
||||
settingStore.setShowFestivalText(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.festival-text-scroll {
|
||||
overflow: hidden;
|
||||
transition: height 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
331
src/components/core/text-effect/ArtTextScroll.vue
Normal file
331
src/components/core/text-effect/ArtTextScroll.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<!-- 文字滚动组件,支持5种样式类型,两种滚动方向,可自定义 HTML 内容 -->
|
||||
<template>
|
||||
<div ref="containerRef" class="text-scroll-container" :class="[`text-scroll--${props.type}`]">
|
||||
<div class="left-icon">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
<div class="scroll-wrapper">
|
||||
<div
|
||||
class="text-scroll-content"
|
||||
:class="{ scrolling: shouldScroll }"
|
||||
:style="scrollStyle"
|
||||
ref="scrollContent"
|
||||
>
|
||||
<div class="scroll-item" v-html="sanitizedContent"></div>
|
||||
<div class="scroll-item" v-html="sanitizedContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-icon" @click="handleRightIconClick" v-if="showClose">
|
||||
<i class="iconfont-sys"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
speed?: number
|
||||
direction?: 'left' | 'right'
|
||||
type?: 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||
showClose?: boolean
|
||||
typewriter?: boolean
|
||||
typewriterSpeed?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
speed: 70,
|
||||
direction: 'left',
|
||||
type: 'default',
|
||||
showClose: false,
|
||||
typewriter: false,
|
||||
typewriterSpeed: 100
|
||||
})
|
||||
|
||||
// 状态管理
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(containerRef)
|
||||
const scrollContent = ref<HTMLElement | null>(null)
|
||||
const animationDuration = ref(0)
|
||||
|
||||
// 添加打字机效果相关的响应式变量
|
||||
const currentText = ref('')
|
||||
let typewriterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 添加打字机完成状态
|
||||
const isTypewriterComplete = ref(false)
|
||||
|
||||
// 修改滚动状态计算属性
|
||||
const shouldScroll = computed(() => {
|
||||
if (props.typewriter) {
|
||||
return !isHovered.value && isTypewriterComplete.value
|
||||
}
|
||||
return !isHovered.value
|
||||
})
|
||||
|
||||
// 修改 sanitizedContent 计算属性
|
||||
const sanitizedContent = computed(() => (props.typewriter ? currentText.value : props.text))
|
||||
|
||||
// 修改 scrollStyle 计算属性
|
||||
const scrollStyle = computed(() => ({
|
||||
'--animation-duration': `${animationDuration.value}s`,
|
||||
'--animation-play-state': shouldScroll.value ? 'running' : 'paused',
|
||||
'--animation-direction': props.direction === 'left' ? 'normal' : 'reverse'
|
||||
}))
|
||||
|
||||
// 计算动画持续时间
|
||||
const calculateDuration = () => {
|
||||
if (scrollContent.value) {
|
||||
const contentWidth = scrollContent.value.scrollWidth / 2
|
||||
animationDuration.value = contentWidth / props.speed
|
||||
}
|
||||
}
|
||||
|
||||
// 处理右图标点击事件
|
||||
const handleRightIconClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 修改打字机效果实现
|
||||
const startTypewriter = () => {
|
||||
let index = 0
|
||||
currentText.value = ''
|
||||
isTypewriterComplete.value = false // 重置状态
|
||||
|
||||
const type = () => {
|
||||
if (index < props.text.length) {
|
||||
currentText.value += props.text[index]
|
||||
index++
|
||||
typewriterTimer = setTimeout(type, props.typewriterSpeed)
|
||||
} else {
|
||||
isTypewriterComplete.value = true // 打字完成后设置状态
|
||||
}
|
||||
}
|
||||
|
||||
type()
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
calculateDuration()
|
||||
window.addEventListener('resize', calculateDuration)
|
||||
|
||||
if (props.typewriter) {
|
||||
startTypewriter()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateDuration)
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听文本变化,重新启动打字机效果
|
||||
watch(
|
||||
() => props.text,
|
||||
() => {
|
||||
if (props.typewriter) {
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer)
|
||||
}
|
||||
startTypewriter()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.text-scroll-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border: 1px solid var(--main-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.left-icon,
|
||||
.right-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
|
||||
.left-icon {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-icon {
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 34px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-scroll-content {
|
||||
display: flex;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
white-space: nowrap;
|
||||
animation: scroll linear infinite;
|
||||
animation-duration: var(--animation-duration);
|
||||
animation-play-state: var(--animation-play-state);
|
||||
animation-direction: var(--animation-direction);
|
||||
|
||||
.scroll-item {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary-light-2) !important;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
|
||||
:deep(a) {
|
||||
color: #fd4e4e !important;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加类型样式
|
||||
&.text-scroll--default {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary);
|
||||
|
||||
.left-icon i {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--success {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
border-color: var(--el-color-success);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-success) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--warning {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
border-color: var(--el-color-warning);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-warning) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--danger {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
border-color: var(--el-color-danger);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--info {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
border-color: var(--el-color-info);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-info) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加打字机效果的光标样式
|
||||
.text-scroll-content .scroll-item {
|
||||
&::after {
|
||||
content: '|';
|
||||
opacity: 0;
|
||||
animation: cursor 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursor {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
410
src/components/core/views/ArtDataViewer.vue
Normal file
410
src/components/core/views/ArtDataViewer.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="art-data-viewer">
|
||||
<!-- 视图切换头部 -->
|
||||
<div class="viewer-header" v-if="showViewToggle">
|
||||
<div class="header-left">
|
||||
<slot name="header-left"></slot>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="header-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格视图 -->
|
||||
<div v-if="currentView === 'table'" class="table-view">
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
:row-key="rowKey"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:current-page="pagination.currentPage"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:margin-top="10"
|
||||
@selection-change="handleSelectionChange"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn v-for="col in tableColumns" :key="col.prop || col.type" v-bind="col" />
|
||||
</template>
|
||||
</ArtTable>
|
||||
</div>
|
||||
|
||||
<!-- 描述列表视图 -->
|
||||
<div v-else class="descriptions-view">
|
||||
<div class="descriptions-grid">
|
||||
<ElCard
|
||||
v-for="(item, index) in data"
|
||||
:key="getItemKey(item, index)"
|
||||
shadow="hover"
|
||||
class="description-card"
|
||||
:class="{ selected: isCardSelected(item) }"
|
||||
@click="handleCardClick(item)"
|
||||
>
|
||||
<!-- 卡片左上角选择器 -->
|
||||
<div class="flex-row-sb">
|
||||
<div v-if="showCardSelection" class="card-selector-top-left" @click.stop>
|
||||
<ElCheckbox
|
||||
:model-value="isCardSelected(item)"
|
||||
@change="handleCardSelect(item, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 卡片右上角操作按钮 -->
|
||||
<div v-if="showCardActions" class="card-actions-top-right" @click.stop>
|
||||
<slot name="card-actions" :item="item" :index="index"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElDescriptions
|
||||
:column="descriptionsColumns"
|
||||
:label-width="labelWidth"
|
||||
border
|
||||
class="mt-20"
|
||||
>
|
||||
<ElDescriptionsItem
|
||||
v-for="field in visibleDescriptionsFields"
|
||||
:key="field.prop"
|
||||
:label="field.label"
|
||||
:span="field.span || 1"
|
||||
>
|
||||
<template v-if="field.formatter">
|
||||
<component :is="'div'" v-html="field.formatter(item)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="field-content">{{ getFieldValue(item, field.prop) }}</div>
|
||||
</template>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div v-if="showPagination" class="descriptions-pagination">
|
||||
<ElPagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ElCard,
|
||||
ElDescriptions,
|
||||
ElDescriptionsItem,
|
||||
ElTableColumn,
|
||||
ElPagination,
|
||||
ElCheckbox
|
||||
} from 'element-plus'
|
||||
import ArtTable from '@/components/core/tables/ArtTable.vue'
|
||||
|
||||
// 定义组件Props类型
|
||||
interface FieldConfig {
|
||||
prop: string
|
||||
label: string
|
||||
span?: number
|
||||
formatter?: (row: any) => string
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
prop?: string
|
||||
label?: string
|
||||
type?: string
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
formatter?: (row: any) => any
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface PaginationConfig {
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// 数据相关
|
||||
data: any[]
|
||||
loading?: boolean
|
||||
rowKey?: string
|
||||
|
||||
// 表格配置
|
||||
tableColumns: TableColumn[]
|
||||
|
||||
// 描述列表配置
|
||||
descriptionsFields: FieldConfig[]
|
||||
descriptionsColumns?: number
|
||||
cardTitleField?: string
|
||||
labelWidth?: string // 新增:描述列表标签宽度配置
|
||||
// 字段列配置(用于控制显示哪些字段)
|
||||
fieldColumns?: any[]
|
||||
|
||||
// 分页配置
|
||||
pagination: PaginationConfig
|
||||
showPagination?: boolean
|
||||
|
||||
// 视图配置
|
||||
defaultView?: 'table' | 'descriptions'
|
||||
showViewToggle?: boolean
|
||||
showCardActions?: boolean
|
||||
showCardSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
rowKey: 'id',
|
||||
descriptionsColumns: 2,
|
||||
cardTitleField: 'name',
|
||||
labelWidth: '120px', // 默认标签宽度
|
||||
fieldColumns: () => [],
|
||||
showPagination: true,
|
||||
defaultView: 'table',
|
||||
showViewToggle: true,
|
||||
showCardActions: false,
|
||||
showCardSelection: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectionChange: [selection: any[]]
|
||||
sizeChange: [size: number]
|
||||
currentChange: [current: number]
|
||||
viewChange: [view: string]
|
||||
}>()
|
||||
|
||||
// 当前视图模式
|
||||
const currentView = ref(props.defaultView)
|
||||
|
||||
// 表格引用
|
||||
const tableRef = ref()
|
||||
|
||||
// 卡片视图选择的项目
|
||||
const selectedCards = ref<any[]>([])
|
||||
|
||||
// 计算可见的描述字段
|
||||
const visibleDescriptionsFields = computed(() => {
|
||||
if (!props.fieldColumns || props.fieldColumns.length === 0) {
|
||||
return props.descriptionsFields
|
||||
}
|
||||
|
||||
// 过滤出选中的字段
|
||||
const checkedColumns = props.fieldColumns.filter((col) => col.checked !== false)
|
||||
return props.descriptionsFields.filter((field) =>
|
||||
checkedColumns.some((col) => col.prop === field.prop)
|
||||
)
|
||||
})
|
||||
|
||||
// 监听props的defaultView变化
|
||||
watch(
|
||||
() => props.defaultView,
|
||||
(newView) => {
|
||||
currentView.value = newView
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听视图切换
|
||||
watch(currentView, (newView) => {
|
||||
emit('viewChange', newView)
|
||||
// 清空选择
|
||||
selectedCards.value = []
|
||||
emit('selectionChange', [])
|
||||
})
|
||||
|
||||
// 获取项目的唯一键
|
||||
const getItemKey = (item: any, index: number) => {
|
||||
return item[props.rowKey] || index
|
||||
}
|
||||
|
||||
// 获取卡片标题
|
||||
const getCardTitle = (item: any) => {
|
||||
return item[props.cardTitleField] || `项目 ${item[props.rowKey] || ''}`
|
||||
}
|
||||
|
||||
// 获取字段值
|
||||
const getFieldValue = (item: any, prop: string) => {
|
||||
return item[prop] || '--'
|
||||
}
|
||||
|
||||
// 处理表格选择变化
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
emit('selectionChange', selection)
|
||||
}
|
||||
|
||||
// 处理分页大小变化
|
||||
const handleSizeChange = (size: number) => {
|
||||
emit('sizeChange', size)
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
const handleCurrentChange = (current: number) => {
|
||||
emit('currentChange', current)
|
||||
}
|
||||
|
||||
// 判断卡片是否被选中
|
||||
const isCardSelected = (item: any) => {
|
||||
return selectedCards.value.some((selected) => selected[props.rowKey] === item[props.rowKey])
|
||||
}
|
||||
|
||||
// 处理卡片选择
|
||||
const handleCardSelect = (item: any, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!isCardSelected(item)) {
|
||||
selectedCards.value.push(item)
|
||||
}
|
||||
} else {
|
||||
selectedCards.value = selectedCards.value.filter(
|
||||
(selected) => selected[props.rowKey] !== item[props.rowKey]
|
||||
)
|
||||
}
|
||||
emit('selectionChange', selectedCards.value)
|
||||
}
|
||||
|
||||
// 处理卡片点击
|
||||
const handleCardClick = (item: any) => {
|
||||
if (props.showCardSelection) {
|
||||
const isSelected = isCardSelected(item)
|
||||
handleCardSelect(item, !isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
tableRef,
|
||||
currentView: readonly(currentView),
|
||||
selectedCards: readonly(selectedCards)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.art-data-viewer {
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 0;
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-view {
|
||||
// 表格视图样式继承ArtTable
|
||||
}
|
||||
|
||||
.descriptions-view {
|
||||
overflow: visible;
|
||||
|
||||
.descriptions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.description-card {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&.selected {
|
||||
border: 1px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
// 字段内容样式 - 允许自然换行
|
||||
.field-content {
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 针对描述列表的特殊处理
|
||||
:deep(.el-descriptions) {
|
||||
.el-descriptions__body {
|
||||
.el-descriptions__table {
|
||||
table-layout: auto;
|
||||
|
||||
.el-descriptions__cell {
|
||||
&.is-bordered-label {
|
||||
width: v-bind('props.labelWidth');
|
||||
word-wrap: break-word;
|
||||
vertical-align: top;
|
||||
min-width: v-bind('props.labelWidth');
|
||||
}
|
||||
|
||||
&.is-bordered-content {
|
||||
vertical-align: top;
|
||||
|
||||
.el-descriptions__content {
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.descriptions-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-width: 768px) {
|
||||
.descriptions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.description-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
padding-top: 45px; // 移动端稍微减少顶部内边距
|
||||
}
|
||||
|
||||
:deep(.el-descriptions) {
|
||||
.el-descriptions__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
html.dark & {
|
||||
.descriptions-view {
|
||||
.description-card {
|
||||
border-color: var(--el-border-color-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
src/components/core/views/exception/ArtException.vue
Normal file
91
src/components/core/views/exception/ArtException.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="page-content state-page">
|
||||
<div class="tips">
|
||||
<img :src="data.imgUrl" />
|
||||
<div class="right-wrap">
|
||||
<p>{{ data.desc }}</p>
|
||||
<el-button color="#47A7FF" @click="backHome" v-ripple>{{ data.btnText }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HOME_PAGE } from '@/router/routesAlias'
|
||||
const router = useRouter()
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
type: Object as PropType<{
|
||||
title: string
|
||||
desc: string
|
||||
btnText: string
|
||||
imgUrl: string
|
||||
}>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const backHome = () => {
|
||||
router.push(HOME_PAGE)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.state-page {
|
||||
display: flex;
|
||||
background-color: transparent !important;
|
||||
border: 0 !important;
|
||||
|
||||
.tips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 5vh;
|
||||
margin: auto;
|
||||
|
||||
img {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
width: 300px;
|
||||
margin-left: 100px;
|
||||
|
||||
p {
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-vertical) {
|
||||
.state-page {
|
||||
.tips {
|
||||
display: block;
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin-top: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
src/components/core/views/login/LoginLeftView.vue
Normal file
98
src/components/core/views/login/LoginLeftView.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="login-left-view">
|
||||
<div class="logo">
|
||||
<ArtLogo class="icon" />
|
||||
<h1 class="title">{{ AppConfig.systemInfo.name }}</h1>
|
||||
</div>
|
||||
<img class="left-bg" src="@imgs/login/lf_bg.webp" />
|
||||
<img class="left-img" src="@imgs/login/lf_icon2.webp" />
|
||||
|
||||
<div class="text-wrap">
|
||||
<h1> {{ $t('login.leftView.title') }} </h1>
|
||||
<p> {{ $t('login.leftView.subTitle') }} </p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-left-view {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 50vw;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
background: #f3f4fb;
|
||||
background-size: cover;
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
margin: 3px 0 0 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--art-text-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
.left-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.left-img {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 500px;
|
||||
margin: auto;
|
||||
margin-top: 15vh;
|
||||
}
|
||||
|
||||
.text-wrap {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 400;
|
||||
color: #f9f9f9;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #c4cada;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-notebook) {
|
||||
.left-img {
|
||||
width: 480px;
|
||||
margin-top: 10vh;
|
||||
}
|
||||
|
||||
.text-wrap {
|
||||
bottom: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-pro) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
133
src/components/core/views/result/ArtResultPage.vue
Normal file
133
src/components/core/views/result/ArtResultPage.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="page-content" :class="type">
|
||||
<i class="iconfont-sys icon" v-html="iconCode"></i>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<p class="msg">{{ message }}</p>
|
||||
<div class="res">
|
||||
<slot name="result-content"></slot>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<slot name="buttons">
|
||||
<el-button type="primary" v-ripple>返回修改</el-button>
|
||||
<el-button v-ripple>查看</el-button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtResultPage' })
|
||||
|
||||
interface ResultPageProps {
|
||||
type: 'success' | 'fail'
|
||||
title: string
|
||||
message: string
|
||||
iconCode: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<ResultPageProps>(), {
|
||||
type: 'success',
|
||||
title: '',
|
||||
message: '',
|
||||
iconCode: ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-content {
|
||||
box-sizing: border-box;
|
||||
padding: 15px 100px !important;
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
margin-top: 6vh;
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 20px;
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
:deep(.res) {
|
||||
padding: 22px 30px;
|
||||
margin-top: 30px;
|
||||
text-align: left;
|
||||
background-color: #f8f8f9;
|
||||
border-radius: 5px;
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 15px;
|
||||
color: #808695;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
margin-top: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.success {
|
||||
.icon {
|
||||
color: #19be6b !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fail {
|
||||
.icon {
|
||||
color: #ed4014 !important;
|
||||
}
|
||||
|
||||
:deep(.res) {
|
||||
p {
|
||||
i {
|
||||
color: #ed4014;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.page-content {
|
||||
.res {
|
||||
background: #28282a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $device-phone) {
|
||||
.page-content {
|
||||
padding: 15px 25px !important;
|
||||
|
||||
.icon {
|
||||
margin-top: 4vh;
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 10px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.res {
|
||||
padding: 10px 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user