refactor(ui):重构通知提示
This commit is contained in:
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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ 基础样式 ============ */
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user