测试版
This commit is contained in:
@@ -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
6
src/api/notice.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getAxios } from '@/utils/axios.js'
|
||||
|
||||
// 获取当前生效的公告列表
|
||||
export function getActiveNotices() {
|
||||
return getAxios({ url: '/api/notice/active' })
|
||||
}
|
||||
249
src/components/NoticeBar.vue
Normal file
249
src/components/NoticeBar.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
78
src/stores/noticeStore.js
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user