feat: announcement支持强制弹窗通知
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
165
frontend/src/components/common/AnnouncementPopup.vue
Normal file
165
frontend/src/components/common/AnnouncementPopup.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '留空表示立即生效',
|
||||
|
||||
143
frontend/src/stores/announcements.ts
Normal file
143
frontend/src/stores/announcements.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user