测试版
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
|
<UpdateChecker v-if="!isDev && isElectronEnv && !updateReady" @ready="updateReady = true" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 滚动通知栏(登录页和工作台都显示) -->
|
||||||
|
<NoticeBar />
|
||||||
|
|
||||||
<!-- 登录页面 -->
|
<!-- 登录页面 -->
|
||||||
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
|
<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 UpdateChecker from './pages/UpdateChecker.vue'
|
||||||
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
|
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
|
||||||
import UpdateNotification from './components/UpdateNotification.vue'
|
import UpdateNotification from './components/UpdateNotification.vue'
|
||||||
|
import NoticeBar from './components/NoticeBar.vue'
|
||||||
|
import { useNoticeStore } from './stores/noticeStore'
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const USER_KEY = 'user_data'
|
const USER_KEY = 'user_data'
|
||||||
@@ -57,6 +62,10 @@ const automationLogs = ref([])
|
|||||||
const isElectronEnv = isElectron()
|
const isElectronEnv = isElectron()
|
||||||
const isDev = window.location.port === '5173'
|
const isDev = window.location.port === '5173'
|
||||||
|
|
||||||
|
// 公告通知
|
||||||
|
const noticeStore = useNoticeStore()
|
||||||
|
noticeStore.fetchNotices()
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Set Title
|
// 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",
|
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"
|
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
|
// PK Mini module translations
|
||||||
pkMini: {
|
pkMini: {
|
||||||
// Navigation
|
// Navigation
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ export default {
|
|||||||
countries: {
|
countries: {
|
||||||
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
|
AD: "安道尔", AE: "阿拉伯联合酋长国", AF: "阿富汗", AG: "安提瓜和巴布达", AI: "安圭拉", AL: "阿尔巴尼亚", AM: "亚美尼亚", AO: "安哥拉", AQ: "南极洲", AR: "阿根廷", AS: "美属萨摩亚", AT: "奥地利", AU: "澳大利亚", AU1: "澳大利亚", AW: "阿鲁巴", AX: "奥兰群岛", AZ: "阿塞拜疆",
|
||||||
},
|
},
|
||||||
|
notice: {
|
||||||
|
close: '关闭通知',
|
||||||
|
},
|
||||||
// PK Mini 模块翻译
|
// PK Mini 模块翻译
|
||||||
pkMini: {
|
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