mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
feat(admin): add announcement preview action
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="popup-fade">
|
<Transition name="popup-fade">
|
||||||
<div
|
<div
|
||||||
v-if="announcementStore.currentPopup"
|
v-if="displayedAnnouncement"
|
||||||
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"
|
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
|
<div
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||||
{{ announcementStore.currentPopup.title }}
|
{{ displayedAnnouncement.title }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Time -->
|
<!-- Time -->
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<time>{{ formatRelativeWithDateTime(announcementStore.currentPopup.created_at) }}</time>
|
<time>{{ formatRelativeWithDateTime(displayedAnnouncement.created_at) }}</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,13 +66,17 @@
|
|||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
@click="handleDismiss"
|
@click="handleDismiss"
|
||||||
|
data-testid="announcement-popup-dismiss"
|
||||||
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"
|
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">
|
<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">
|
<svg v-if="preview" 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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('announcements.markRead') }}
|
{{ preview ? t('common.close') : t('announcements.markRead') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,16 +88,34 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { useAnnouncementStore } from '@/stores/announcements'
|
import { useAnnouncementStore } from '@/stores/announcements'
|
||||||
import { formatRelativeWithDateTime } from '@/utils/format'
|
import { formatRelativeWithDateTime } from '@/utils/format'
|
||||||
|
import type { Announcement, UserAnnouncement } from '@/types'
|
||||||
import '@/styles/announcement-markdown.css'
|
import '@/styles/announcement-markdown.css'
|
||||||
|
|
||||||
|
type PreviewAnnouncement = Pick<Announcement | UserAnnouncement, 'title' | 'content' | 'created_at'>
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
announcement?: PreviewAnnouncement | null
|
||||||
|
preview?: boolean
|
||||||
|
}>(), {
|
||||||
|
announcement: null,
|
||||||
|
preview: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const announcementStore = useAnnouncementStore()
|
const announcementStore = useAnnouncementStore()
|
||||||
|
const displayedAnnouncement = computed(() => (
|
||||||
|
props.preview ? props.announcement : announcementStore.currentPopup
|
||||||
|
))
|
||||||
|
|
||||||
marked.setOptions({
|
marked.setOptions({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
@@ -101,25 +123,38 @@ marked.setOptions({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
const content = announcementStore.currentPopup?.content
|
const content = displayedAnnouncement.value?.content
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
const html = marked.parse(content) as string
|
const html = marked.parse(content) as string
|
||||||
return DOMPurify.sanitize(html)
|
return DOMPurify.sanitize(html)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleDismiss() {
|
function handleDismiss() {
|
||||||
|
if (props.preview) {
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
announcementStore.dismissPopup()
|
announcementStore.dismissPopup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage body overflow — only set, never unset (bell component handles restore)
|
// Manage body overflow — only set, never unset (bell component handles restore)
|
||||||
watch(
|
watch(
|
||||||
() => announcementStore.currentPopup,
|
displayedAnnouncement,
|
||||||
(popup) => {
|
(popup) => {
|
||||||
if (popup) {
|
if (popup) {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else if (props.preview) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (props.preview) {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ vi.mock('vue-i18n', async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const announcement = {
|
||||||
|
id: 1,
|
||||||
|
title: 'Preview announcement',
|
||||||
|
content: '## Preview heading\n\n<div>HTML content</div><script>window.__xss = true</script>',
|
||||||
|
status: 'draft' as const,
|
||||||
|
notify_mode: 'popup' as const,
|
||||||
|
targeting: { any_of: [] },
|
||||||
|
created_at: '2026-07-24T07:30:00Z',
|
||||||
|
updated_at: '2026-07-24T07:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
describe('AnnouncementPopup', () => {
|
describe('AnnouncementPopup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
@@ -69,4 +80,50 @@ describe('AnnouncementPopup', () => {
|
|||||||
expect(announcementMarkdownStyles).toContain(`.markdown-body ${element}`)
|
expect(announcementMarkdownStyles).toContain(`.markdown-body ${element}`)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it('previews an admin announcement without marking it as read', async () => {
|
||||||
|
const store = useAnnouncementStore()
|
||||||
|
const dismissPopup = vi.spyOn(store, 'dismissPopup')
|
||||||
|
const wrapper = mount(AnnouncementPopup, {
|
||||||
|
props: {
|
||||||
|
announcement,
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Preview announcement')
|
||||||
|
expect(document.body.querySelector('.markdown-body h2')?.textContent).toBe('Preview heading')
|
||||||
|
expect(document.body.querySelector('.markdown-body script')).toBeNull()
|
||||||
|
expect(document.body.textContent).toContain('common.close')
|
||||||
|
|
||||||
|
const dismissButton = document.body.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="announcement-popup-dismiss"]',
|
||||||
|
)
|
||||||
|
dismissButton?.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
expect(dismissPopup).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await wrapper.setProps({ announcement: null })
|
||||||
|
expect(document.body.style.overflow).toBe('')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the existing user popup dismissal behavior', async () => {
|
||||||
|
const store = useAnnouncementStore()
|
||||||
|
store.currentPopup = announcement
|
||||||
|
const dismissPopup = vi.spyOn(store, 'dismissPopup').mockResolvedValue()
|
||||||
|
const wrapper = mount(AnnouncementPopup)
|
||||||
|
|
||||||
|
const dismissButton = document.body.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="announcement-popup-dismiss"]',
|
||||||
|
)
|
||||||
|
dismissButton?.click()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(dismissPopup).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ export default {
|
|||||||
timeImmediate: 'Immediate',
|
timeImmediate: 'Immediate',
|
||||||
timeNever: 'Never',
|
timeNever: 'Never',
|
||||||
readStatus: 'Read Status',
|
readStatus: 'Read Status',
|
||||||
|
preview: 'Preview',
|
||||||
eligible: 'Eligible',
|
eligible: 'Eligible',
|
||||||
readAt: 'Read at',
|
readAt: 'Read at',
|
||||||
unread: 'Unread',
|
unread: 'Unread',
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ export default {
|
|||||||
timeImmediate: '立即',
|
timeImmediate: '立即',
|
||||||
timeNever: '永久',
|
timeNever: '永久',
|
||||||
readStatus: '已读情况',
|
readStatus: '已读情况',
|
||||||
|
preview: '预览',
|
||||||
eligible: '符合条件',
|
eligible: '符合条件',
|
||||||
readAt: '已读时间',
|
readAt: '已读时间',
|
||||||
unread: '未读',
|
unread: '未读',
|
||||||
|
|||||||
@@ -114,12 +114,19 @@
|
|||||||
|
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center space-x-1">
|
<div class="flex items-center space-x-1">
|
||||||
|
<button
|
||||||
|
@click="openPreview(row)"
|
||||||
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
|
:title="t('admin.announcements.preview')"
|
||||||
|
>
|
||||||
|
<Icon name="eye" size="sm" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="openReadStatus(row)"
|
@click="openReadStatus(row)"
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
:title="t('admin.announcements.readStatus')"
|
:title="t('admin.announcements.readStatus')"
|
||||||
>
|
>
|
||||||
<Icon name="eye" size="sm" />
|
<Icon name="chartBar" size="sm" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="openEditDialog(row)"
|
@click="openEditDialog(row)"
|
||||||
@@ -240,6 +247,12 @@
|
|||||||
:announcement-id="readStatusAnnouncementId"
|
:announcement-id="readStatusAnnouncementId"
|
||||||
@close="showReadStatusDialog = false"
|
@close="showReadStatusDialog = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AnnouncementPopup
|
||||||
|
:announcement="previewAnnouncement"
|
||||||
|
preview
|
||||||
|
@close="previewAnnouncement = null"
|
||||||
|
/>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -265,6 +278,7 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
|
|
||||||
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
||||||
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
||||||
|
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -588,6 +602,11 @@ async function confirmDelete() {
|
|||||||
// ===== Read status =====
|
// ===== Read status =====
|
||||||
const showReadStatusDialog = ref(false)
|
const showReadStatusDialog = ref(false)
|
||||||
const readStatusAnnouncementId = ref<number | null>(null)
|
const readStatusAnnouncementId = ref<number | null>(null)
|
||||||
|
const previewAnnouncement = ref<Announcement | null>(null)
|
||||||
|
|
||||||
|
function openPreview(row: Announcement) {
|
||||||
|
previewAnnouncement.value = row
|
||||||
|
}
|
||||||
|
|
||||||
function openReadStatus(row: Announcement) {
|
function openReadStatus(row: Announcement) {
|
||||||
readStatusAnnouncementId.value = row.id
|
readStatusAnnouncementId.value = row.id
|
||||||
|
|||||||
Reference in New Issue
Block a user