测试版

This commit is contained in:
2026-02-11 14:49:18 +08:00
parent bef5c2f437
commit 92780ef52e
6 changed files with 348 additions and 0 deletions

View File

@@ -4,6 +4,9 @@
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
<template v-else>
<!-- 滚动通知栏登录页和工作台都显示 -->
<NoticeBar />
<!-- 登录页面 -->
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
@@ -38,6 +41,8 @@ import ConfigPage from './pages/ConfigPage.vue'
import UpdateChecker from './pages/UpdateChecker.vue'
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
import UpdateNotification from './components/UpdateNotification.vue'
import NoticeBar from './components/NoticeBar.vue'
import { useNoticeStore } from './stores/noticeStore'
// Constants
const USER_KEY = 'user_data'
@@ -57,6 +62,10 @@ const automationLogs = ref([])
const isElectronEnv = isElectron()
const isDev = window.location.port === '5173'
// 公告通知
const noticeStore = useNoticeStore()
noticeStore.fetchNotices()
// Lifecycle
onMounted(() => {
// Set Title

6
src/api/notice.js Normal file
View File

@@ -0,0 +1,6 @@
import { getAxios } from '@/utils/axios.js'
// 获取当前生效的公告列表
export function getActiveNotices() {
return getAxios({ url: '/api/notice/active' })
}

View File

@@ -0,0 +1,249 @@
<template>
<div v-if="activeNotices.length > 0"
:class="['notice-bar', `notice-bar--${currentNotice.type || 'info'}`]">
<!-- 图标 -->
<span class="material-icons-round notice-bar__icon">campaign</span>
<!-- 滚动内容区域 -->
<div class="notice-bar__content" ref="wrapRef">
<div class="notice-bar__text" ref="textRef" :style="animationStyle">
{{ currentNotice.content }}
</div>
</div>
<!-- 多条公告时显示计数 -->
<span v-if="activeNotices.length > 1" class="notice-bar__count">
{{ currentIndex + 1 }}/{{ activeNotices.length }}
</span>
<!-- 关闭按钮 -->
<button v-if="closable" class="notice-bar__close" @click="handleClose"
:title="t('notice.close')">
<span class="material-icons-round text-base">close</span>
</button>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNoticeStore } from '@/stores/noticeStore'
const props = defineProps({
speed: { type: Number, default: 50 }, // 滚动速度 px/s
closable: { type: Boolean, default: true },
})
const { t } = useI18n()
const noticeStore = useNoticeStore()
const activeNotices = computed(() => noticeStore.activeNotices)
// 当前显示的公告索引(多条轮播)
const currentIndex = ref(0)
const currentNotice = computed(() => activeNotices.value[currentIndex.value] || {})
// 滚动动画
const wrapRef = ref(null)
const textRef = ref(null)
const animationDuration = ref(10)
const needScroll = ref(false)
const animationStyle = computed(() => {
if (!needScroll.value) return {}
return {
animationDuration: `${animationDuration.value}s`,
}
})
// 计算文本是否需要滚动
const calculateScroll = async () => {
await nextTick()
if (!wrapRef.value || !textRef.value) return
const wrapWidth = wrapRef.value.offsetWidth
const textWidth = textRef.value.scrollWidth
if (textWidth > wrapWidth) {
needScroll.value = true
// 基于文本宽度和速度计算持续时间
animationDuration.value = (textWidth + wrapWidth) / props.speed
} else {
needScroll.value = false
}
}
// 多条公告自动轮播
let rotateTimer = null
const startRotate = () => {
stopRotate()
if (activeNotices.value.length <= 1) return
rotateTimer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % activeNotices.value.length
}, 8000) // 每 8 秒切换
}
const stopRotate = () => {
if (rotateTimer) {
clearInterval(rotateTimer)
rotateTimer = null
}
}
// 关闭当前公告
const handleClose = () => {
const notice = currentNotice.value
if (notice && notice.id) {
noticeStore.dismissNotice(notice.id)
// 调整索引
if (currentIndex.value >= activeNotices.value.length) {
currentIndex.value = 0
}
}
}
// 监听公告变化重新计算
watch(currentNotice, () => {
needScroll.value = false
calculateScroll()
}, { flush: 'post' })
watch(() => activeNotices.value.length, (len) => {
if (len > 1) {
startRotate()
} else {
stopRotate()
}
})
onMounted(() => {
calculateScroll()
if (activeNotices.value.length > 1) {
startRotate()
}
})
onUnmounted(() => {
stopRotate()
})
</script>
<style scoped>
.notice-bar {
display: flex;
align-items: center;
padding: 0 16px;
height: 44px;
font-size: 15px;
line-height: 44px;
flex-shrink: 0;
}
/* 类型样式 */
.notice-bar--info {
background-color: #eff6ff;
color: #1e40af;
}
.notice-bar--info .notice-bar__icon {
color: #3b82f6;
}
.notice-bar--warning {
background-color: #fffbeb;
color: #92400e;
}
.notice-bar--warning .notice-bar__icon {
color: #f59e0b;
}
.notice-bar--danger {
background-color: rgb(253, 220, 220);
color: #f15252;
}
.notice-bar--danger .notice-bar__icon {
color: #ff0000;
}
.notice-bar--urgent {
background-color: #fef2f2;
color: #991b1b;
}
.notice-bar--urgent .notice-bar__icon {
color: #ef4444;
}
/* 图标 */
.notice-bar__icon {
font-size: 22px;
margin-right: 10px;
flex-shrink: 0;
}
/* 内容区域 */
.notice-bar__content {
flex: 1;
overflow: hidden;
white-space: nowrap;
position: relative;
}
.notice-bar__text {
display: inline-block;
white-space: nowrap;
}
/* 需要滚动时添加动画类 */
.notice-bar__content .notice-bar__text {
animation: none;
}
.notice-bar__text[style*="animationDuration"] {
animation-name: noticeScroll;
animation-timing-function: linear;
animation-iteration-count: infinite;
padding-left: 100%;
}
@keyframes noticeScroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
/* 计数 */
.notice-bar__count {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
flex-shrink: 0;
}
/* 关闭按钮 */
.notice-bar__close {
display: flex;
align-items: center;
justify-content: center;
margin-left: 10px;
padding: 4px;
border: none;
background: none;
cursor: pointer;
opacity: 0.6;
color: inherit;
border-radius: 4px;
flex-shrink: 0;
transition: opacity 0.2s;
}
.notice-bar__close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.06);
}
</style>

View File

@@ -206,6 +206,9 @@ export default {
TC: "Turks and Caicos Islands", TD: "Chad", TF: "French Southern Territories", TG: "Togo", TH: "Thailand", TJ: "Tajikistan", TK: "Tokelau", TL: "Timor-Leste", TM: "Turkmenistan", TN: "Tunisia", TO: "Tonga", TR: "Turkey", TT: "Trinidad and Tobago", TV: "Tuvalu", TW: "Taiwan", TZ: "Tanzania", UA: "Ukraine", UG: "Uganda", UM: "United States Minor Outlying Islands", US: "United States", UY: "Uruguay", UZ: "Uzbekistan",
VA: "Vatican City", VC: "Saint Vincent and the Grenadines", VE: "Venezuela", VG: "British Virgin Islands", VI: "U.S. Virgin Islands", VN: "Vietnam", VN1: "Vietnam", VU: "Vanuatu", WS: "Samoa", YE: "Yemen", YT: "Mayotte", ZA: "South Africa", ZM: "Zambia", ZW: "Zimbabwe"
},
notice: {
close: 'Close notice',
},
// PK Mini module translations
pkMini: {
// Navigation

View File

@@ -186,6 +186,9 @@ export default {
countries: {
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
},
notice: {
close: '关闭通知',
},
// PK Mini 模块翻译
pkMini: {
// 导航

78
src/stores/noticeStore.js Normal file
View File

@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getActiveNotices } from '@/api/notice'
export const useNoticeStore = defineStore('notice', () => {
// 状态
const notices = ref([])
const isLoading = ref(false)
const dismissedIds = ref([]) // 当前会话已关闭的公告 ID
const lastFetchTime = ref(null)
const useMock = ref(true) // 后台接口就绪后改为 false
// 过滤已关闭公告后的有效列表
const activeNotices = computed(() =>
notices.value.filter(n => !dismissedIds.value.includes(n.id))
)
// 是否有可显示的公告
const hasNotices = computed(() => activeNotices.value.length > 0)
/**
* 从后台拉取公告
*/
const fetchNotices = async () => {
if (isLoading.value) return
if (useMock.value) {
loadMockNotices()
return
}
isLoading.value = true
try {
const res = await getActiveNotices()
if (res && res.data) {
notices.value = res.data
lastFetchTime.value = Date.now()
}
} catch (error) {
console.error('[NoticeStore] 获取公告失败:', error)
} finally {
isLoading.value = false
}
}
/**
* 关闭某条公告(仅当前会话生效)
*/
const dismissNotice = (id) => {
if (!dismissedIds.value.includes(id)) {
dismissedIds.value.push(id)
}
}
/**
* 加载 Mock 数据(后台接口未就绪时使用)
*/
const loadMockNotices = () => {
notices.value = [
{ id: 1, content: '欢迎使用 Yolo 系统,如有问题请联系管理员。', type: 'info' },
{ id: 2, content: '系统将于本周六凌晨 2:00-4:00 进行维护升级,届时服务将暂停,请提前做好安排。', type: 'warning' },
]
lastFetchTime.value = Date.now()
}
return {
// 状态
notices,
activeNotices,
hasNotices,
isLoading,
useMock,
// 方法
fetchNotices,
dismissNotice,
loadMockNotices,
}
})