Merge branch 'develop'

This commit is contained in:
浅念
2025-07-21 11:16:16 +08:00
parent 617c96dd1d
commit 2a82a7ba95
381 changed files with 106417 additions and 38 deletions
@@ -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>
@@ -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;
}
}
@@ -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">&#xe634;</i>
<i class="iconfont-sys">&#xe809;</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>
@@ -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">&#xe81a;</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: '&#xe721;',
iconColor: '#377dff',
path: RoutesAlias.Dashboard
},
{
name: '分析页',
description: '数据分析与可视化',
icon: '&#xe812;',
iconColor: '#ff3b30',
path: RoutesAlias.Analysis
},
{
name: '礼花效果',
description: '动画特效展示',
icon: '&#xe7ed;',
iconColor: '#7A7FFF',
path: RoutesAlias.Fireworks
},
{
name: '聊天',
description: '即时通讯功能',
icon: '&#xe70a;',
iconColor: '#13DEB9',
path: RoutesAlias.Chat
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: '&#xe788;',
iconColor: '#ffb100',
path: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: '&#xe86e;',
iconColor: '#ff6b6b',
path: WEB_LINKS.COMMUNITY
},
{
name: '更新日志',
description: '版本更新与变更记录',
icon: '&#xe81c;',
iconColor: '#38C0FC',
path: RoutesAlias.ChangeLog
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: '&#xe6b4;',
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>
@@ -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>
@@ -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>
@@ -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;
}
}
}
}
}
@@ -0,0 +1,385 @@
<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">&#xe6ba;</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()"> &#xe6b3; </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">&#xe710;</i>
<span>{{ $t('topBar.search.title') }}</span>
</div>
<div class="search-keydown">
<i class="iconfont-sys" v-if="isWindows">&#xeeac;</i>
<i class="iconfont-sys" v-else>&#xe9ab;</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 ? '&#xe62d;' : '&#xe8ce;' }}</i>
</div>
</div>
<!-- 通知 -->
<!-- <div class="btn-box notice-btn" @click="visibleNotice">
<div class="btn notice-button">
<i class="iconfont-sys notice-btn">&#xe6c2;</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">&#xe89a;</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">&#xe611;</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">&#xe621;</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">&#xe6d0;</i>
</div>
</template>
<template #default>
<p
>{{ $t('topBar.guide.title')
}}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span
>、 <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span
>{{ $t('topBar.guide.description') }}
</p>
</template>
</el-popover>
</div> -->
<!-- 切换主题 -->
<div class="btn-box" @click="themeAnimation">
<div class="btn theme-btn">
<i class="iconfont-sys">{{ isDark ? '&#xe6b5;' : '&#xe725;' }}</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>
<img class="cover" src="@imgs/user/avatar.webp" />
</template>
<template #default>
<div class="user-menu-box">
<!-- <div class="user-head">
<img class="cover" src="@imgs/user/avatar.webp" style="float: left" />
<div class="user-wrap">
<span class="name">{{ userInfo.user.id }}</span>
<span class="roles">
<template v-for="(role, idx) in userInfo.user.roles" :key="role">
<span>{{ role }}</span><span v-if="idx < userInfo.user.roles.length - 1">, </span>
</template>
</span>
<span class="phone">{{ userInfo.user.phone }}</span>
<span class="email">{{ userInfo.user.email }}</span>
</div>
</div> -->
<ul class="user-menu">
<!-- <li @click="goPage('/system/user-center')">
<i class="menu-icon iconfont-sys">&#xe734;</i>
<span class="menu-txt">{{ $t('topBar.user.userCenter') }}</span>
</li>
<li @click="toDocs()">
<i class="menu-icon iconfont-sys" style="font-size: 15px">&#xe828;</i>
<span class="menu-txt">{{ $t('topBar.user.docs') }}</span>
</li>
<li @click="toGithub()">
<i class="menu-icon iconfont-sys">&#xe8d6;</i>
<span class="menu-txt">{{ $t('topBar.user.github') }}</span>
</li> -->
<li @click="lockScreen()">
<i class="menu-icon iconfont-sys">&#xe817;</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
})
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 toDocs = () => {
window.open(WEB_LINKS.DOCS)
}
const toGithub = () => {
window.open(WEB_LINKS.GITHUB)
}
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>
@@ -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;
}
}
}
}
}
}
@@ -0,0 +1,462 @@
@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;
.cover {
width: 40px;
height: 40px;
margin: 0 10px 0 0;
overflow: hidden;
background: #eee;
border-radius: 50%;
}
.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;
}
.cover {
width: 34px;
height: 34px;
margin: 0 10px 0 0;
overflow: hidden;
cursor: pointer;
background: #eee;
border-radius: 50%;
}
}
}
}
}
@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);
}
}
@@ -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,77 @@
<!-- 水平菜单 -->
<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(() => String(route.meta.activePath || 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>
@@ -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 = String(route.meta.activePath || 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>
@@ -0,0 +1,281 @@
<!-- 左侧菜单 或 双列菜单 -->
<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">&#xe798;</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 }"
>
{{ 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(() => String(route.meta.activePath || 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>
@@ -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">&#xe8d7;</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">&#xe8d7;</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">&#xe8d7;</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: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
}
}
const getNoticeStyle = (type: string) => {
const defaultStyle = {
icon: '&#xe747;',
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>
@@ -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;
}
}
@@ -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>
@@ -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: '&#xe694;'
},
{
value: ContainerWidthEnum.BOXED,
label: t('setting.container.list[1]'),
icon: '&#xe6de;'
}
])
// 盒子样式选项
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
}
}
@@ -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>
@@ -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">&#xe616;</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')">&#xe7dc;</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>
@@ -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/art-menu-right/index.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: '&#xe6b3;',
disabled: !isCurrentTab
},
{
key: 'fixed',
label: currentTab?.fixedTab ? t('worktab.btn.unfixed') : t('worktab.btn.fixed'),
icon: '&#xe644;',
disabled: false,
showLine: true
},
{
key: 'left',
label: t('worktab.btn.closeLeft'),
icon: '&#xe866;',
disabled: clickedIndex === 0 || areAllLeftTabsFixed
},
{
key: 'right',
label: t('worktab.btn.closeRight'),
icon: '&#xe865;',
disabled: isLastTab || areAllRightTabsFixed
},
{
key: 'other',
label: t('worktab.btn.closeOther'),
icon: '&#xe83a;',
disabled: isOneTab || areAllOtherTabsFixed
},
{
key: 'all',
label: t('worktab.btn.closeAll'),
icon: '&#xe71a;',
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>
@@ -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;
}
}