Commit 7079edc2 authored by shaw's avatar shaw
Browse files

feat: announcement支持强制弹窗通知

parent a42a1f08
...@@ -2872,6 +2872,7 @@ export default { ...@@ -2872,6 +2872,7 @@ export default {
columns: { columns: {
title: '标题', title: '标题',
status: '状态', status: '状态',
notifyMode: '通知方式',
targeting: '展示条件', targeting: '展示条件',
timeRange: '有效期', timeRange: '有效期',
createdAt: '创建时间', createdAt: '创建时间',
...@@ -2882,10 +2883,16 @@ export default { ...@@ -2882,10 +2883,16 @@ export default {
active: '展示中', active: '展示中',
archived: '已归档' archived: '已归档'
}, },
notifyModeLabels: {
silent: '静默',
popup: '弹窗'
},
form: { form: {
title: '标题', title: '标题',
content: '内容(支持 Markdown)', content: '内容(支持 Markdown)',
status: '状态', status: '状态',
notifyMode: '通知方式',
notifyModeHint: '弹窗模式会自动弹出通知给用户',
startsAt: '开始时间', startsAt: '开始时间',
endsAt: '结束时间', endsAt: '结束时间',
startsAtHint: '留空表示立即生效', startsAtHint: '留空表示立即生效',
......
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,
}
})
...@@ -8,6 +8,7 @@ export { useAppStore } from './app' ...@@ -8,6 +8,7 @@ export { useAppStore } from './app'
export { useAdminSettingsStore } from './adminSettings' export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions' export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding' export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
......
...@@ -155,6 +155,7 @@ export interface UpdateSubscriptionRequest { ...@@ -155,6 +155,7 @@ export interface UpdateSubscriptionRequest {
// ==================== Announcement Types ==================== // ==================== Announcement Types ====================
export type AnnouncementStatus = 'draft' | 'active' | 'archived' export type AnnouncementStatus = 'draft' | 'active' | 'archived'
export type AnnouncementNotifyMode = 'silent' | 'popup'
export type AnnouncementConditionType = 'subscription' | 'balance' export type AnnouncementConditionType = 'subscription' | 'balance'
...@@ -180,6 +181,7 @@ export interface Announcement { ...@@ -180,6 +181,7 @@ export interface Announcement {
title: string title: string
content: string content: string
status: AnnouncementStatus status: AnnouncementStatus
notify_mode: AnnouncementNotifyMode
targeting: AnnouncementTargeting targeting: AnnouncementTargeting
starts_at?: string starts_at?: string
ends_at?: string ends_at?: string
...@@ -193,6 +195,7 @@ export interface UserAnnouncement { ...@@ -193,6 +195,7 @@ export interface UserAnnouncement {
id: number id: number
title: string title: string
content: string content: string
notify_mode: AnnouncementNotifyMode
starts_at?: string starts_at?: string
ends_at?: string ends_at?: string
read_at?: string read_at?: string
...@@ -204,6 +207,7 @@ export interface CreateAnnouncementRequest { ...@@ -204,6 +207,7 @@ export interface CreateAnnouncementRequest {
title: string title: string
content: string content: string
status?: AnnouncementStatus status?: AnnouncementStatus
notify_mode?: AnnouncementNotifyMode
targeting: AnnouncementTargeting targeting: AnnouncementTargeting
starts_at?: number starts_at?: number
ends_at?: number ends_at?: number
...@@ -213,6 +217,7 @@ export interface UpdateAnnouncementRequest { ...@@ -213,6 +217,7 @@ export interface UpdateAnnouncementRequest {
title?: string title?: string
content?: string content?: string
status?: AnnouncementStatus status?: AnnouncementStatus
notify_mode?: AnnouncementNotifyMode
targeting?: AnnouncementTargeting targeting?: AnnouncementTargeting
starts_at?: number starts_at?: number
ends_at?: number ends_at?: number
......
...@@ -68,6 +68,19 @@ ...@@ -68,6 +68,19 @@
</span> </span>
</template> </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 }"> <template #cell-targeting="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-300"> <span class="text-sm text-gray-600 dark:text-gray-300">
{{ targetingSummary(row.targeting) }} {{ targetingSummary(row.targeting) }}
...@@ -163,7 +176,11 @@ ...@@ -163,7 +176,11 @@
<label class="input-label">{{ t('admin.announcements.form.status') }}</label> <label class="input-label">{{ t('admin.announcements.form.status') }}</label>
<Select v-model="form.status" :options="statusOptions" /> <Select v-model="form.status" :options="statusOptions" />
</div> </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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
...@@ -271,9 +288,15 @@ const statusOptions = computed(() => [ ...@@ -271,9 +288,15 @@ const statusOptions = computed(() => [
{ value: 'archived', label: t('admin.announcements.statusLabels.archived') } { 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[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'title', label: t('admin.announcements.columns.title') }, { key: 'title', label: t('admin.announcements.columns.title') },
{ key: 'status', label: t('admin.announcements.columns.status') }, { 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: 'targeting', label: t('admin.announcements.columns.targeting') },
{ key: 'timeRange', label: t('admin.announcements.columns.timeRange') }, { key: 'timeRange', label: t('admin.announcements.columns.timeRange') },
{ key: 'createdAt', label: t('admin.announcements.columns.createdAt') }, { key: 'createdAt', label: t('admin.announcements.columns.createdAt') },
...@@ -357,6 +380,7 @@ const form = reactive({ ...@@ -357,6 +380,7 @@ const form = reactive({
title: '', title: '',
content: '', content: '',
status: 'draft', status: 'draft',
notify_mode: 'silent',
starts_at_str: '', starts_at_str: '',
ends_at_str: '', ends_at_str: '',
targeting: { any_of: [] } as AnnouncementTargeting targeting: { any_of: [] } as AnnouncementTargeting
...@@ -378,6 +402,7 @@ function resetForm() { ...@@ -378,6 +402,7 @@ function resetForm() {
form.title = '' form.title = ''
form.content = '' form.content = ''
form.status = 'draft' form.status = 'draft'
form.notify_mode = 'silent'
form.starts_at_str = '' form.starts_at_str = ''
form.ends_at_str = '' form.ends_at_str = ''
form.targeting = { any_of: [] } form.targeting = { any_of: [] }
...@@ -387,6 +412,7 @@ function fillFormFromAnnouncement(a: Announcement) { ...@@ -387,6 +412,7 @@ function fillFormFromAnnouncement(a: Announcement) {
form.title = a.title form.title = a.title
form.content = a.content form.content = a.content
form.status = a.status form.status = a.status
form.notify_mode = a.notify_mode || 'silent'
// Backend returns RFC3339 strings // Backend returns RFC3339 strings
form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : '' form.starts_at_str = a.starts_at ? formatDateTimeLocalInput(Math.floor(new Date(a.starts_at).getTime() / 1000)) : ''
...@@ -420,6 +446,7 @@ function buildCreatePayload() { ...@@ -420,6 +446,7 @@ function buildCreatePayload() {
title: form.title, title: form.title,
content: form.content, content: form.content,
status: form.status as any, status: form.status as any,
notify_mode: form.notify_mode as any,
targeting: form.targeting, targeting: form.targeting,
starts_at: startsAt ?? undefined, starts_at: startsAt ?? undefined,
ends_at: endsAt ?? undefined ends_at: endsAt ?? undefined
...@@ -432,6 +459,7 @@ function buildUpdatePayload(original: Announcement) { ...@@ -432,6 +459,7 @@ function buildUpdatePayload(original: Announcement) {
if (form.title !== original.title) payload.title = form.title if (form.title !== original.title) payload.title = form.title
if (form.content !== original.content) payload.content = form.content if (form.content !== original.content) payload.content = form.content
if (form.status !== original.status) payload.status = form.status 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 // 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 const originalStarts = original.starts_at ? Math.floor(new Date(original.starts_at).getTime() / 1000) : null
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment