feat: announcement支持强制弹窗通知

This commit is contained in:
shaw
2026-03-07 15:06:13 +08:00
parent a42a1f08e9
commit 7079edc2d0
25 changed files with 840 additions and 154 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { RouterView, useRouter, useRoute } from 'vue-router'
import { onMounted, watch } from 'vue'
import { onMounted, onBeforeUnmount, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import NavigationProgress from '@/components/common/NavigationProgress.vue'
import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores'
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore } from '@/stores'
import { getSetupStatus } from '@/api/setup'
const router = useRouter()
@@ -11,6 +12,7 @@ const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
const subscriptionStore = useSubscriptionStore()
const announcementStore = useAnnouncementStore()
/**
* Update favicon dynamically
@@ -39,24 +41,55 @@ watch(
{ immediate: true }
)
// Watch for authentication state and manage subscription data
// Watch for authentication state and manage subscription data + announcements
function onVisibilityChange() {
if (document.visibilityState === 'visible' && authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
}
watch(
() => authStore.isAuthenticated,
(isAuthenticated) => {
(isAuthenticated, oldValue) => {
if (isAuthenticated) {
// User logged in: preload subscriptions and start polling
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to preload subscriptions:', error)
})
subscriptionStore.startPolling()
// Announcements: new login vs page refresh restore
if (oldValue === false) {
// New login: delay 3s then force fetch
setTimeout(() => announcementStore.fetchAnnouncements(true), 3000)
} else {
// Page refresh restore (oldValue was undefined)
announcementStore.fetchAnnouncements()
}
// Register visibility change listener
document.addEventListener('visibilitychange', onVisibilityChange)
} else {
// User logged out: clear data and stop polling
subscriptionStore.clear()
announcementStore.reset()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
},
{ immediate: true }
)
// Route change trigger (throttled by store)
router.afterEach(() => {
if (authStore.isAuthenticated) {
announcementStore.fetchAnnouncements()
}
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange)
})
onMounted(async () => {
// Check if setup is needed
try {
@@ -78,4 +111,5 @@ onMounted(async () => {
<NavigationProgress />
<RouterView />
<Toast />
<AnnouncementPopup />
</template>

View File

@@ -314,16 +314,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { announcementsAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import { useAnnouncementStore } from '@/stores/announcements'
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcementStore = useAnnouncementStore()
// Configure marked
marked.setOptions({
@@ -331,17 +333,14 @@ marked.setOptions({
gfm: true,
})
// State
const announcements = ref<UserAnnouncement[]>([])
// Use store state (storeToRefs for reactivity)
const { announcements, loading } = storeToRefs(announcementStore)
const unreadCount = computed(() => announcementStore.unreadCount)
// Local modal state
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 {
@@ -350,24 +349,8 @@ function renderMarkdown(content: string): 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() {
@@ -389,14 +372,7 @@ function closeDetail() {
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()
}
await announcementStore.markAsRead(id)
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
}
@@ -410,19 +386,10 @@ async function markAsReadAndClose(id: number) {
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()
}
})
await announcementStore.markAllAsRead()
appStore.showSuccess(t('announcements.allMarkedAsRead'))
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
@@ -438,22 +405,19 @@ function handleEscape(e: KeyboardEvent) {
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 = ''
watch(
[isModalOpen, detailModalOpen, () => announcementStore.currentPopup],
([modal, detail, popup]) => {
document.body.style.overflow = (modal || detail || popup) ? 'hidden' : ''
}
})
)
</script>
<style scoped>

View File

@@ -0,0 +1,165 @@
<template>
<Teleport to="body">
<Transition name="popup-fade">
<div
v-if="announcementStore.currentPopup"
class="fixed inset-0 z-[120] 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"
>
<div
class="w-full max-w-[680px] 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 warm gradient -->
<div class="relative overflow-hidden border-b border-amber-100/80 bg-gradient-to-br from-amber-50/80 via-orange-50/50 to-yellow-50/30 px-8 py-6 dark:border-dark-700/50 dark:from-amber-900/20 dark:via-orange-900/10 dark:to-yellow-900/5">
<!-- Decorative background -->
<div class="absolute right-0 top-0 h-full w-64 bg-gradient-to-l from-orange-100/30 to-transparent dark:from-orange-900/20"></div>
<div class="absolute -right-8 -top-8 h-32 w-32 rounded-full bg-gradient-to-br from-amber-400/20 to-orange-500/20 blur-3xl"></div>
<div class="absolute -left-4 -bottom-4 h-24 w-24 rounded-full bg-gradient-to-tr from-yellow-400/20 to-amber-500/20 blur-2xl"></div>
<div class="relative z-10">
<!-- Icon and badge -->
<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-amber-500 to-orange-600 text-white shadow-lg shadow-amber-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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</div>
<span class="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 px-2.5 py-1 text-xs font-medium text-white shadow-lg shadow-amber-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>
<!-- Title -->
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
{{ announcementStore.currentPopup.title }}
</h2>
<!-- Time -->
<div class="flex items-center gap-1.5 text-sm text-gray-600 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<time>{{ formatRelativeWithDateTime(announcementStore.currentPopup.created_at) }}</time>
</div>
</div>
</div>
<!-- Body -->
<div class="max-h-[50vh] overflow-y-auto bg-white px-8 py-8 dark:bg-dark-800">
<div class="relative">
<div class="absolute left-0 top-0 bottom-0 w-1 rounded-full bg-gradient-to-b from-amber-500 via-orange-500 to-yellow-500"></div>
<div class="pl-6">
<div
class="markdown-body prose prose-sm max-w-none dark:prose-invert"
v-html="renderedContent"
></div>
</div>
</div>
</div>
<!-- Footer -->
<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-end">
<button
@click="handleDismiss"
class="rounded-xl bg-gradient-to-r from-amber-500 to-orange-600 px-6 py-2.5 text-sm font-medium text-white shadow-lg shadow-amber-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>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { useAnnouncementStore } from '@/stores/announcements'
import { formatRelativeWithDateTime } from '@/utils/format'
const { t } = useI18n()
const announcementStore = useAnnouncementStore()
marked.setOptions({
breaks: true,
gfm: true,
})
const renderedContent = computed(() => {
const content = announcementStore.currentPopup?.content
if (!content) return ''
const html = marked.parse(content) as string
return DOMPurify.sanitize(html)
})
function handleDismiss() {
announcementStore.dismissPopup()
}
// Manage body overflow — only set, never unset (bell component handles restore)
watch(
() => announcementStore.currentPopup,
(popup) => {
if (popup) {
document.body.style.overflow = 'hidden'
}
}
)
</script>
<style scoped>
.popup-fade-enter-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.popup-fade-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);
}
.popup-fade-enter-from,
.popup-fade-leave-to {
opacity: 0;
}
.popup-fade-enter-from > div {
transform: scale(0.94) translateY(-12px);
opacity: 0;
}
.popup-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);
}
</style>

View File

@@ -2704,6 +2704,7 @@ export default {
columns: {
title: 'Title',
status: 'Status',
notifyMode: 'Notify Mode',
targeting: 'Targeting',
timeRange: 'Schedule',
createdAt: 'Created At',
@@ -2714,10 +2715,16 @@ export default {
active: 'Active',
archived: 'Archived'
},
notifyModeLabels: {
silent: 'Silent',
popup: 'Popup'
},
form: {
title: 'Title',
content: 'Content (Markdown supported)',
status: 'Status',
notifyMode: 'Notify Mode',
notifyModeHint: 'Popup mode will show a popup notification to users',
startsAt: 'Starts At',
endsAt: 'Ends At',
startsAtHint: 'Leave empty to start immediately',

View File

@@ -2872,6 +2872,7 @@ export default {
columns: {
title: '标题',
status: '状态',
notifyMode: '通知方式',
targeting: '展示条件',
timeRange: '有效期',
createdAt: '创建时间',
@@ -2882,10 +2883,16 @@ export default {
active: '展示中',
archived: '已归档'
},
notifyModeLabels: {
silent: '静默',
popup: '弹窗'
},
form: {
title: '标题',
content: '内容(支持 Markdown',
status: '状态',
notifyMode: '通知方式',
notifyModeHint: '弹窗模式会自动弹出通知给用户',
startsAt: '开始时间',
endsAt: '结束时间',
startsAtHint: '留空表示立即生效',

View File

@@ -0,0 +1,143 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { announcementsAPI } from '@/api'
import type { UserAnnouncement } from '@/types'
const THROTTLE_MS = 20 * 60 * 1000 // 20 minutes
export const useAnnouncementStore = defineStore('announcements', () => {
// State
const announcements = ref<UserAnnouncement[]>([])
const loading = ref(false)
const lastFetchTime = ref(0)
const popupQueue = ref<UserAnnouncement[]>([])
const currentPopup = ref<UserAnnouncement | null>(null)
// Session-scoped dedup set — not reactive, used as plain lookup only
let shownPopupIds = new Set<number>()
// Getters
const unreadCount = computed(() =>
announcements.value.filter((a) => !a.read_at).length
)
// Actions
async function fetchAnnouncements(force = false) {
const now = Date.now()
if (!force && lastFetchTime.value > 0 && now - lastFetchTime.value < THROTTLE_MS) {
return
}
// Set immediately to prevent concurrent duplicate requests
lastFetchTime.value = now
try {
loading.value = true
const all = await announcementsAPI.list(false)
announcements.value = all.slice(0, 20)
enqueueNewPopups()
} catch (err: any) {
// Revert throttle timestamp on failure so retry is allowed
lastFetchTime.value = 0
console.error('Failed to fetch announcements:', err)
} finally {
loading.value = false
}
}
function enqueueNewPopups() {
const newPopups = announcements.value.filter(
(a) => a.notify_mode === 'popup' && !a.read_at && !shownPopupIds.has(a.id)
)
if (newPopups.length === 0) return
for (const p of newPopups) {
if (!popupQueue.value.some((q) => q.id === p.id)) {
popupQueue.value.push(p)
}
}
if (!currentPopup.value) {
showNextPopup()
}
}
function showNextPopup() {
if (popupQueue.value.length === 0) {
currentPopup.value = null
return
}
currentPopup.value = popupQueue.value.shift()!
shownPopupIds.add(currentPopup.value.id)
}
async function dismissPopup() {
if (!currentPopup.value) return
const id = currentPopup.value.id
currentPopup.value = null
// Mark as read (fire-and-forget, UI already updated)
markAsRead(id)
// Show next popup after a short delay
if (popupQueue.value.length > 0) {
setTimeout(() => showNextPopup(), 300)
}
}
async function markAsRead(id: number) {
try {
await announcementsAPI.markRead(id)
const ann = announcements.value.find((a) => a.id === id)
if (ann) {
ann.read_at = new Date().toISOString()
}
} catch (err: any) {
console.error('Failed to mark announcement as read:', err)
}
}
async function markAllAsRead() {
const unread = announcements.value.filter((a) => !a.read_at)
if (unread.length === 0) return
try {
loading.value = true
await Promise.all(unread.map((a) => announcementsAPI.markRead(a.id)))
announcements.value.forEach((a) => {
if (!a.read_at) {
a.read_at = new Date().toISOString()
}
})
} catch (err: any) {
console.error('Failed to mark all as read:', err)
throw err
} finally {
loading.value = false
}
}
function reset() {
announcements.value = []
lastFetchTime.value = 0
shownPopupIds = new Set()
popupQueue.value = []
currentPopup.value = null
loading.value = false
}
return {
// State
announcements,
loading,
currentPopup,
// Getters
unreadCount,
// Actions
fetchAnnouncements,
dismissPopup,
markAsRead,
markAllAsRead,
reset,
}
})

View File

@@ -8,6 +8,7 @@ export { useAppStore } from './app'
export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements'
// Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'

View File

@@ -155,6 +155,7 @@ export interface UpdateSubscriptionRequest {
// ==================== Announcement Types ====================
export type AnnouncementStatus = 'draft' | 'active' | 'archived'
export type AnnouncementNotifyMode = 'silent' | 'popup'
export type AnnouncementConditionType = 'subscription' | 'balance'
@@ -180,6 +181,7 @@ export interface Announcement {
title: string
content: string
status: AnnouncementStatus
notify_mode: AnnouncementNotifyMode
targeting: AnnouncementTargeting
starts_at?: string
ends_at?: string
@@ -193,6 +195,7 @@ export interface UserAnnouncement {
id: number
title: string
content: string
notify_mode: AnnouncementNotifyMode
starts_at?: string
ends_at?: string
read_at?: string
@@ -204,6 +207,7 @@ export interface CreateAnnouncementRequest {
title: string
content: string
status?: AnnouncementStatus
notify_mode?: AnnouncementNotifyMode
targeting: AnnouncementTargeting
starts_at?: number
ends_at?: number
@@ -213,6 +217,7 @@ export interface UpdateAnnouncementRequest {
title?: string
content?: string
status?: AnnouncementStatus
notify_mode?: AnnouncementNotifyMode
targeting?: AnnouncementTargeting
starts_at?: number
ends_at?: number

View File

@@ -68,6 +68,19 @@
</span>
</template>
<template #cell-notifyMode="{ row }">
<span
:class="[
'badge',
row.notify_mode === 'popup'
? 'badge-warning'
: 'badge-gray'
]"
>
{{ row.notify_mode === 'popup' ? t('admin.announcements.notifyModeLabels.popup') : t('admin.announcements.notifyModeLabels.silent') }}
</span>
</template>
<template #cell-targeting="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ targetingSummary(row.targeting) }}
@@ -163,7 +176,11 @@
<label class="input-label">{{ t('admin.announcements.form.status') }}</label>
<Select v-model="form.status" :options="statusOptions" />
</div>
<div></div>
<div>
<label class="input-label">{{ t('admin.announcements.form.notifyMode') }}</label>
<Select v-model="form.notify_mode" :options="notifyModeOptions" />
<p class="input-hint">{{ t('admin.announcements.form.notifyModeHint') }}</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -271,9 +288,15 @@ const statusOptions = computed(() => [
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') }
])
const notifyModeOptions = computed(() => [
{ value: 'silent', label: t('admin.announcements.notifyModeLabels.silent') },
{ value: 'popup', label: t('admin.announcements.notifyModeLabels.popup') }
])
const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') },
{ key: 'status', label: t('admin.announcements.columns.status') },
{ key: 'notifyMode', label: t('admin.announcements.columns.notifyMode') },
{ key: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
@@ -357,6 +380,7 @@ const form = reactive({
title: '',
content: '',
status: 'draft',
notify_mode: 'silent',
starts_at_str: '',
ends_at_str: '',
targeting: { any_of: [] } as AnnouncementTargeting
@@ -378,6 +402,7 @@ function resetForm() {
form.title = ''
form.content = ''
form.status = 'draft'
form.notify_mode = 'silent'
form.starts_at_str = ''
form.ends_at_str = ''
form.targeting = { any_of: [] }
@@ -387,6 +412,7 @@ function fillFormFromAnnouncement(a: Announcement) {
form.title = a.title
form.content = a.content
form.status = a.status
form.notify_mode = a.notify_mode || 'silent'
// Backend returns RFC3339 strings
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
@@ -420,6 +446,7 @@ function buildCreatePayload() {
title: form.title,
content: form.content,
status: form.status as any,
notify_mode: form.notify_mode as any,
targeting: form.targeting,
starts_at: startsAt ?? undefined,
ends_at: endsAt ?? undefined
@@ -432,6 +459,7 @@ function buildUpdatePayload(original: Announcement) {
if (form.title !== original.title) payload.title = form.title
if (form.content !== original.content) payload.content = form.content
if (form.status !== original.status) payload.status = form.status
if (form.notify_mode !== (original.notify_mode || 'silent')) payload.notify_mode = form.notify_mode
// starts_at / ends_at: distinguish unchanged vs clear(0) vs set
const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null