feat: 重新设计公告系统为Header铃铛通知
- 新增 AnnouncementBell 组件,支持 Modal 弹窗和 Markdown 渲染 - 移除 Dashboard 横幅和独立公告页面 - 铃铛位置在 Header 文档按钮左侧,显示未读红点 - 支持点击查看详情、标记已读、全部已读等操作 - 完善国际化,移除所有硬编码中文 - 修复 AnnouncementTargetingEditor watch 循环问题
This commit is contained in:
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
626
frontend/src/components/common/AnnouncementBell.vue
Normal file
@@ -0,0 +1,626 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 铃铛按钮 -->
|
||||
<button
|
||||
@click="openModal"
|
||||
class="relative flex h-9 w-9 items-center justify-center rounded-lg text-gray-600 transition-all hover:bg-gray-100 hover:scale-105 dark:text-gray-400 dark:hover:bg-dark-800"
|
||||
:class="{ 'text-blue-600 dark:text-blue-400': unreadCount > 0 }"
|
||||
:aria-label="t('announcements.title')"
|
||||
>
|
||||
<Icon name="bell" size="md" />
|
||||
<!-- 未读红点 -->
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute right-1 top-1 flex h-2 w-2"
|
||||
>
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 公告列表 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="isModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[8vh] backdrop-blur-md"
|
||||
@click="closeModal"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[620px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Gradient -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100/80 bg-gradient-to-br from-blue-50/50 to-indigo-50/30 px-6 py-5 dark:border-dark-700/50 dark:from-blue-900/10 dark:to-indigo-900/5">
|
||||
<div class="relative z-10 flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<Icon name="bell" size="sm" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('announcements.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p v-if="unreadCount > 0" class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium text-blue-600 dark:text-blue-400">{{ unreadCount }}</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
@click="markAllAsRead"
|
||||
:disabled="loading"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-xs font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:bg-blue-700 hover:shadow-xl disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{{ t('announcements.markAllRead') }}
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Decorative gradient -->
|
||||
<div class="absolute right-0 top-0 h-full w-48 bg-gradient-to-l from-indigo-100/20 to-transparent dark:from-indigo-900/10"></div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="max-h-[65vh] overflow-y-auto">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="relative">
|
||||
<div class="h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-blue-600 dark:border-dark-600 dark:border-t-blue-400"></div>
|
||||
<div class="absolute inset-0 h-12 w-12 animate-pulse rounded-full border-4 border-blue-400/30"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcements List -->
|
||||
<div v-else-if="announcements.length > 0">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="group relative flex items-center gap-4 border-b border-gray-100 px-6 py-4 transition-all hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-700/30"
|
||||
:class="{ 'bg-blue-50/30 dark:bg-blue-900/5': !item.read_at }"
|
||||
style="min-height: 72px"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center">
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="relative flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<!-- Pulse ring -->
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-xl bg-blue-400 opacity-75"></span>
|
||||
<!-- Icon -->
|
||||
<svg class="relative z-10 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<time class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatRelativeTime(item.created_at) }}
|
||||
</time>
|
||||
<span
|
||||
v-if="!item.read_at"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
>
|
||||
<span class="relative flex h-1.5 w-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-500 opacity-75"></span>
|
||||
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-blue-600"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform group-hover:translate-x-1 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unread indicator bar -->
|
||||
<div
|
||||
v-if="!item.read_at"
|
||||
class="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-blue-500 to-indigo-600"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-16">
|
||||
<div class="relative mb-4">
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600">
|
||||
<Icon name="inbox" size="xl" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<div class="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-white">
|
||||
<svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('announcements.empty') }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('announcements.emptyDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 公告详情 Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="detailModalOpen && selectedAnnouncement"
|
||||
class="fixed inset-0 z-[110] flex items-start justify-center overflow-y-auto bg-gradient-to-br from-black/70 via-black/60 to-black/70 p-4 pt-[6vh] backdrop-blur-md"
|
||||
@click="closeDetail"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-[780px] overflow-hidden rounded-3xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Header with Decorative Elements -->
|
||||
<div class="relative overflow-hidden border-b border-gray-100 bg-gradient-to-br from-blue-50/80 via-indigo-50/50 to-purple-50/30 px-8 py-6 dark:border-dark-700 dark:from-blue-900/20 dark:via-indigo-900/10 dark:to-purple-900/5">
|
||||
<!-- Decorative background elements -->
|
||||
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-indigo-100/30 to-transparent dark:from-indigo-900/20"></div>
|
||||
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-blue-400/20 to-indigo-500/20 blur-3xl"></div>
|
||||
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-purple-400/20 to-pink-500/20 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10 flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Icon and Category -->
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-lg shadow-blue-500/30">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="rounded-lg bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{{ t('announcements.title') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-blue-500/30"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75"></span>
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full bg-white"></span>
|
||||
</span>
|
||||
{{ t('announcements.unread') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-3 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ selectedAnnouncement.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<time>{{ formatRelativeWithDateTime(selectedAnnouncement.created_at) }}</time>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.read') : t('announcements.unread') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-white/50 text-gray-500 backdrop-blur-sm transition-all hover:bg-white hover:text-gray-700 hover:shadow-lg dark:bg-dark-700/50 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body with Enhanced Markdown -->
|
||||
<div class="max-h-[60vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
|
||||
<!-- Content with decorative border -->
|
||||
<div class="relative">
|
||||
<!-- Decorative left border -->
|
||||
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-blue-500 via-indigo-500 to-purple-500"></div>
|
||||
|
||||
<div class="pl-6">
|
||||
<div
|
||||
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
|
||||
v-html="renderMarkdown(selectedAnnouncement.content)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with Actions -->
|
||||
<div class="border-t border-gray-100 bg-gray-50/50 px-8 py-5 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ selectedAnnouncement.read_at ? t('announcements.readStatus') : t('announcements.markReadHint') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="closeDetail"
|
||||
class="rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!selectedAnnouncement.read_at"
|
||||
@click="markAsReadAndClose(selectedAnnouncement.id)"
|
||||
class="rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-blue-500/30 transition-all hover:shadow-xl hover:scale-105"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { announcementsAPI } from '@/api'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Configure marked
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
// State
|
||||
const announcements = ref<UserAnnouncement[]>([])
|
||||
const isModalOpen = ref(false)
|
||||
const detailModalOpen = ref(false)
|
||||
const selectedAnnouncement = ref<UserAnnouncement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Computed
|
||||
const unreadCount = computed(() =>
|
||||
announcements.value.filter((a) => !a.read_at).length
|
||||
)
|
||||
|
||||
// Methods
|
||||
function renderMarkdown(content: string): string {
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
}
|
||||
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
loading.value = true
|
||||
const allAnnouncements = await announcementsAPI.list(false)
|
||||
announcements.value = allAnnouncements.slice(0, 20)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load announcements:', err)
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
isModalOpen.value = true
|
||||
if (announcements.value.length === 0) {
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isModalOpen.value = false
|
||||
}
|
||||
|
||||
function openDetail(announcement: UserAnnouncement) {
|
||||
selectedAnnouncement.value = announcement
|
||||
detailModalOpen.value = true
|
||||
if (!announcement.read_at) {
|
||||
markAsRead(announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailModalOpen.value = false
|
||||
selectedAnnouncement.value = null
|
||||
}
|
||||
|
||||
async function markAsRead(id: number) {
|
||||
try {
|
||||
await announcementsAPI.markRead(id)
|
||||
const announcement = announcements.value.find((a) => a.id === id)
|
||||
if (announcement) {
|
||||
announcement.read_at = new Date().toISOString()
|
||||
}
|
||||
if (selectedAnnouncement.value?.id === id) {
|
||||
selectedAnnouncement.value.read_at = new Date().toISOString()
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsReadAndClose(id: number) {
|
||||
await markAsRead(id)
|
||||
appStore.showSuccess(t('announcements.markedAsRead'))
|
||||
closeDetail()
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
try {
|
||||
loading.value = true
|
||||
const unreadAnnouncements = announcements.value.filter((a) => !a.read_at)
|
||||
await Promise.all(unreadAnnouncements.map((a) => announcementsAPI.markRead(a.id)))
|
||||
announcements.value.forEach((a) => {
|
||||
if (!a.read_at) {
|
||||
a.read_at = new Date().toISOString()
|
||||
}
|
||||
})
|
||||
appStore.showSuccess(t('announcements.allMarkedAsRead'))
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || t('common.unknownError'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (detailModalOpen.value) {
|
||||
closeDetail()
|
||||
} else if (isModalOpen.value) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
loadAnnouncements()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
// Restore body overflow in case component is unmounted while modals are open
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
watch([isModalOpen, detailModalOpen], ([modal, detail]) => {
|
||||
if (modal || detail) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Modal Animations */
|
||||
.modal-fade-enter-active {
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from > div {
|
||||
transform: scale(0.94) translateY(-12px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-leave-to > div {
|
||||
transform: scale(0.96) translateY(-8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #cbd5e1, #94a3b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, #4b5563, #374151);
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
.dark .overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(to bottom, #6b7280, #4b5563);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Enhanced Markdown Styles */
|
||||
.markdown-body {
|
||||
@apply text-[15px] leading-[1.75];
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
@apply mb-6 mt-8 border-b border-gray-200 pb-3 text-3xl font-bold text-gray-900 dark:border-dark-600 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
@apply mb-4 mt-7 border-b border-gray-100 pb-2 text-2xl font-bold text-gray-900 dark:border-dark-700 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
@apply mb-3 mt-6 text-xl font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body h4 {
|
||||
@apply mb-2 mt-5 text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
@apply font-medium text-blue-600 underline decoration-blue-600/30 decoration-2 underline-offset-2 transition-all hover:decoration-blue-600 dark:text-blue-400 dark:decoration-blue-400/30 dark:hover:decoration-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
@apply mb-4 ml-6 space-y-2;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
@apply leading-relaxed;
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
.markdown-body li::marker {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
@apply relative my-5 border-l-4 border-blue-500 bg-blue-50/50 py-3 pl-5 pr-4 italic text-gray-700 dark:border-blue-400 dark:bg-blue-900/10 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.markdown-body blockquote::before {
|
||||
content: '"';
|
||||
@apply absolute -left-1 top-0 text-5xl font-serif text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 text-[13px] font-mono text-pink-600 dark:bg-dark-700 dark:text-pink-400;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
@apply my-5 overflow-x-auto rounded-xl border border-gray-200 bg-gray-50 p-5 dark:border-dark-600 dark:bg-dark-900/50;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
@apply bg-transparent p-0 text-[13px] text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
@apply my-8 border-0 border-t-2 border-gray-200 dark:border-dark-700;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
@apply mb-5 w-full overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
@apply border-r border-b border-gray-200 px-4 py-3 text-left dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body th:last-child,
|
||||
.markdown-body td:last-child {
|
||||
@apply border-r-0;
|
||||
}
|
||||
|
||||
.markdown-body tr:last-child td {
|
||||
@apply border-b-0;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
@apply bg-gradient-to-br from-blue-50 to-indigo-50 font-semibold text-gray-900 dark:from-blue-900/20 dark:to-indigo-900/10 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body tbody tr {
|
||||
@apply transition-colors hover:bg-gray-50 dark:hover:bg-dark-700/30;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
@apply my-5 max-w-full rounded-xl border border-gray-200 shadow-md dark:border-dark-600;
|
||||
}
|
||||
|
||||
.markdown-body strong {
|
||||
@apply font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.markdown-body em {
|
||||
@apply italic text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user