refactor(ui):重构通知提示

This commit is contained in:
qian committed 2026-09-24 21:44:35 +08:00
1 parent c40de7b0e8
commit e3af4f240f
4 files changed
+16 -334

No files matched your search

@@ -1,292 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { marked } from 'marked'
import Icon from '@/components/common/Icon.vue'
// Configure marked to allow HTML
marked.setOptions({
breaks: true,
gfm: true,
})
function renderMarkdown(content: string): string {
return marked.parse(content) as string
}
const props = defineProps<{
announcements: {
id: number
title: string
type: string
content: string
is_pinned: number
created_at: string
}[]
}>()
const currentIndex = ref(0)
const showDetail = ref(false)
const STORAGE_KEY = 'tenant_dismissed_announcements'
function loadDismissedIds(): Set<number> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return new Set(JSON.parse(raw))
} catch { /* ignore */ }
return new Set()
}
function saveDismissedIds(ids: Set<number>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
} catch { /* ignore */ }
}
const dismissedIds = ref<Set<number>>(loadDismissedIds())
let autoTimer: ReturnType<typeof setInterval> | null = null
const visibleItems = computed(() => {
const activeIds = new Set(props.announcements.map(a => a.id))
// 清理已不存在的 dismissed ID
const changed = [...dismissedIds.value].some(id => !activeIds.has(id))
if (changed) {
for (const id of [...dismissedIds.value]) {
if (!activeIds.has(id)) dismissedIds.value.delete(id)
}
saveDismissedIds(dismissedIds.value)
}
return props.announcements.filter(a => !dismissedIds.value.has(a.id))
})
const currentItem = computed(() => {
return visibleItems.value[currentIndex.value] || null
})
const hasMultiple = computed(() => visibleItems.value.length > 1)
const typeConfig: Record<string, { bg: string; border: string; icon: string }> = {
info: { bg: 'bg-primary-500/90', border: 'border-primary-400', icon: 'infoCircle' },
warning: { bg: 'bg-amber-500/90', border: 'border-amber-400', icon: 'exclamationTriangle' },
important: { bg: 'bg-red-500/90', border: 'border-red-400', icon: 'xCircle' },
}
function getTypeConfig(type: string) {
return typeConfig[type] || typeConfig.info
}
function dismissCurrent() {
if (!currentItem.value) return
if (currentItem.value.type === 'important') return
dismissedIds.value.add(currentItem.value.id)
saveDismissedIds(dismissedIds.value)
if (currentIndex.value >= visibleItems.value.length) {
currentIndex.value = 0
}
}
function next() {
if (visibleItems.value.length <= 1) return
currentIndex.value = (currentIndex.value + 1) % visibleItems.value.length
}
function prev() {
if (visibleItems.value.length <= 1) return
currentIndex.value = (currentIndex.value - 1 + visibleItems.value.length) % visibleItems.value.length
}
function openDetail() {
if (!currentItem.value) return
showDetail.value = true
}
function startAutoRotate() {
stopAutoRotate()
if (visibleItems.value.length > 1) {
autoTimer = setInterval(() => {
next()
}, 6000)
}
}
function stopAutoRotate() {
if (autoTimer) {
clearInterval(autoTimer)
autoTimer = null
}
}
watch(() => props.announcements, () => {
currentIndex.value = 0
startAutoRotate()
})
onMounted(() => {
startAutoRotate()
})
onBeforeUnmount(() => {
stopAutoRotate()
})
</script>
<template>
<Transition name="slide-down">
<div v-if="currentItem" :class="['border-b', getTypeConfig(currentItem.type).bg, getTypeConfig(currentItem.type).border]">
<div class="flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-white">
<Icon :name="getTypeConfig(currentItem.type).icon" size="sm" class="flex-shrink-0" />
<!-- Navigation (prev) -->
<button v-if="hasMultiple" @click="prev" class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors">
<Icon name="chevronLeft" size="xs" />
</button>
<!-- Content -->
<button class="flex-1 text-center truncate hover:underline cursor-pointer" @click="openDetail">
<span :class="{ 'font-semibold': currentItem.is_pinned }">{{ currentItem.title }}</span>
<span v-if="hasMultiple" class="ml-2 text-white/70">{{ currentIndex + 1 }}/{{ visibleItems.length }}</span>
</button>
<!-- Navigation (next) -->
<button v-if="hasMultiple" @click="next" class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors">
<Icon name="chevronRight" size="xs" />
</button>
<!-- Dismiss -->
<button
v-if="currentItem.type !== 'important'"
@click="dismissCurrent"
class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors"
>
<Icon name="x" size="sm" />
</button>
</div>
</div>
</Transition>
<!-- Detail Modal -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDetail && currentItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="showDetail = false">
<div class="w-full max-w-3xl rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<!-- Header -->
<div class="flex items-start gap-3 px-6 py-4 border-b border-gray-100">
<div :class="['flex-shrink-0 mt-0.5 h-8 w-8 rounded-lg flex items-center justify-center', getTypeConfig(currentItem.type).bg]">
<Icon :name="getTypeConfig(currentItem.type).icon" size="sm" class="text-white" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900">{{ currentItem.title }}</h3>
<p class="text-xs text-gray-500 mt-0.5">{{ currentItem.created_at }}</p>
</div>
<button @click="showDetail = false" class="flex-shrink-0 rounded-lg p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<Icon name="x" size="md" />
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 max-h-[60vh] overflow-y-auto">
<div class="announcement-content prose prose-sm max-w-none text-gray-700" v-html="renderMarkdown(currentItem.content)"></div>
</div>
<!-- Footer -->
<div class="px-6 py-3 border-t border-gray-100 flex justify-end">
<button @click="showDetail = false" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease-out;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
.fade-enter-active { transition: opacity 0.2s ease-out; }
.fade-leave-active { transition: opacity 0.15s ease-in; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.animate-scale-in {
animation: scaleIn 0.25s ease-out;
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
<style>
.announcement-content {
font-size: 14px;
line-height: 1.75;
color: #374151;
}
.announcement-content h1 { font-size: 1.5em; font-weight: 700; margin: 0.8em 0 0.4em; }
.announcement-content h2 { font-size: 1.3em; font-weight: 600; margin: 0.8em 0 0.4em; }
.announcement-content h3 { font-size: 1.15em; font-weight: 600; margin: 0.6em 0 0.3em; }
.announcement-content p { margin: 0.5em 0; }
.announcement-content ul, .announcement-content ol { padding-left: 1.5em; margin: 0.5em 0; }
.announcement-content li { margin: 0.2em 0; }
.announcement-content blockquote {
border-left: 3px solid var(--color-primary-500);
padding: 0.5em 1em;
margin: 0.5em 0;
background: var(--color-primary-50);
border-radius: 0 8px 8px 0;
}
.announcement-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
background: #f3f4f6;
padding: 0.15em 0.4em;
border-radius: 4px;
}
.announcement-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1em;
border-radius: 12px;
overflow-x: auto;
margin: 0.5em 0;
}
.announcement-content pre code {
background: transparent;
padding: 0;
color: inherit;
}
.announcement-content a {
color: #6d80f0;
text-decoration: underline;
}
.announcement-content img {
max-width: 100%;
border-radius: 8px;
margin: 0.5em 0;
}
.announcement-content table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
}
.announcement-content th, .announcement-content td {
border: 1px solid #e5e7eb;
padding: 0.5em 0.75em;
text-align: left;
}
.announcement-content th {
background: #f9fafb;
font-weight: 600;
}
.announcement-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1em 0;
}
.announcement-content strong { font-weight: 600; }
.announcement-content em { font-style: italic; }
</style>
@@ -1,12 +1,5 @@
<template>
<div class="auth-layout relative min-h-[100dvh] overflow-x-hidden">
<!-- Announcement Banner (teleported to body, fixed top) -->
<Teleport to="body">
<div v-if="announcements.length" class="fixed top-0 left-0 right-0 z-50">
<AnnouncementBanner :announcements="announcements" />
</div>
</Teleport>
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
<div class="relative z-10 flex min-h-[100dvh] flex-col">
@@ -45,35 +38,11 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import AnnouncementBanner from '../common/AnnouncementBanner.vue'
import request from '@/utils/request'
import { computed } from 'vue'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { createPoller } from '@/composables/usePolling'
const { settings: publicSettings } = usePublicSettings()
const siteName = computed(() => publicSettings.value.site_name || 'Team-API')
const announcements = ref<any[]>([])
// 登录页公告轮询:页面隐藏时暂停,恢复可见时按剩余时间续排
const announcementPoller = createPoller(fetchAnnouncements, 30 * 60 * 1000)
async function fetchAnnouncements() {
try {
const res = await request.get('/settings/announcements', { params: { position: 'login' }, _suppressErrorMsg: true } as any)
announcements.value = res.data?.data?.list || []
} catch {
// silently ignore
}
}
onMounted(() => {
fetchAnnouncements()
announcementPoller.start()
})
onBeforeUnmount(() => {
announcementPoller.stop()
})
</script>
<style scoped>
+10
View File
@@ -57,6 +57,7 @@
--animate-scale-in: scale-in 0.2s ease-out;
--animate-shimmer: shimmer 2s linear infinite;
--animate-glow: glow 2s ease-in-out infinite alternate;
--animate-swing: swing 2.4s ease-in-out infinite;
@keyframes fade-in {
from { opacity: 0; }
@@ -86,6 +87,15 @@
from { box-shadow: 0 0 20px rgba(20, 184, 166, 0.22); }
to { box-shadow: 0 0 30px rgba(20, 184, 166, 0.38); }
}
/* 喇叭图标摇摆:静止过半周期后快速晃动几下,用于未读公告提醒 */
@keyframes swing {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-14deg); }
66% { transform: rotate(11deg); }
72% { transform: rotate(-8deg); }
78% { transform: rotate(5deg); }
84% { transform: rotate(0deg); }
}
}
/* ============ 基础样式 ============ */
+5 -10
View File
@@ -11,7 +11,6 @@ import { formatBilling } from '@/composables/useCurrency'
import { toast } from '@/utils/toast'
import Icon from '@/components/common/Icon.vue'
import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue'
import AnnouncementBanner from '@/components/common/AnnouncementBanner.vue'
import RouteErrorBoundary from '@/components/common/RouteErrorBoundary.vue'
import { marked } from 'marked'
import request from '@/utils/request'
@@ -264,7 +263,6 @@ onBeforeUnmount(() => {
<div class="tenant-layout min-h-screen overflow-x-clip">
<!-- Maintenance Banner -->
<MaintenanceBanner />
<AnnouncementBanner v-if="consoleAnnouncements.length" :announcements="consoleAnnouncements" />
<!-- Background Decoration -->
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
@@ -410,17 +408,13 @@ onBeforeUnmount(() => {
class="topbar-icon-button relative flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/65 text-slate-400 shadow-sm transition-all hover:-translate-y-0.5 hover:bg-white hover:text-primary-600"
title="平台公告"
>
<Icon name="megaphone" size="md" />
<Icon name="megaphone" size="md" :class="{ 'motion-safe:animate-swing': announceUnreadCount > 0 }" />
<!-- 存在未读公告时喇叭摇摆 + 红点标记 -->
<span
v-if="announceUnreadCount > 0"
class="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-white"
></span>
</button>
<!-- Pulse animation layer -->
<div
v-if="announceUnreadCount > 0"
class="absolute inset-0 rounded-xl animate-pulse-soft pointer-events-none"
></div>
<!-- Announcement Dropdown Panel -->
<transition name="fade">
@@ -583,7 +577,7 @@ onBeforeUnmount(() => {
<Teleport to="body">
<Transition name="fade">
<div v-if="announceDetailItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="closeAnnouncementDetail">
<div class="w-full max-w-3xl rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<div class="w-full max-w-3xl flex min-h-[24rem] flex-col rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<div class="flex items-start gap-3 px-6 py-4 border-b border-gray-100">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900">{{ announceDetailItem.title }}</h3>
@@ -593,7 +587,8 @@ onBeforeUnmount(() => {
<Icon name="x" size="md" />
</button>
</div>
<div class="px-6 py-5 max-h-[60vh] overflow-y-auto">
<!-- 内容区 flex-1 撑满弹窗最小高度,长内容超过 60vh 后内部滚动 -->
<div class="flex-1 min-h-0 px-6 py-5 max-h-[60vh] overflow-y-auto">
<div class="announcement-content prose prose-sm max-w-none text-gray-700" v-html="renderMarkdown(announceDetailItem.content)"></div>
</div>
<div class="px-6 py-3 border-t border-gray-100 flex justify-end">