mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 08:22:34 +00:00
Merge remote-tracking branch 'origin/main' into feature/openai-live-gateway
This commit is contained in:
@@ -322,6 +322,7 @@ import { useAnnouncementStore } from '@/stores/announcements'
|
||||
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
|
||||
import type { UserAnnouncement } from '@/types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import '@/styles/announcement-markdown.css'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -471,120 +472,3 @@ watch(
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Teleport to="body">
|
||||
<Transition name="popup-fade">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-2 text-2xl font-bold leading-tight text-gray-900 dark:text-white">
|
||||
{{ announcementStore.currentPopup.title }}
|
||||
{{ displayedAnnouncement.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Time -->
|
||||
@@ -43,7 +43,7 @@
|
||||
<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>
|
||||
<time>{{ formatRelativeWithDateTime(displayedAnnouncement.created_at) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,13 +66,17 @@
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
@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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
{{ t('announcements.markRead') }}
|
||||
{{ preview ? t('common.close') : t('announcements.markRead') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -84,15 +88,34 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, 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'
|
||||
import type { Announcement, UserAnnouncement } from '@/types'
|
||||
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 announcementStore = useAnnouncementStore()
|
||||
const displayedAnnouncement = computed(() => (
|
||||
props.preview ? props.announcement : announcementStore.currentPopup
|
||||
))
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
@@ -100,25 +123,38 @@ marked.setOptions({
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
const content = announcementStore.currentPopup?.content
|
||||
const content = displayedAnnouncement.value?.content
|
||||
if (!content) return ''
|
||||
const html = marked.parse(content) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
})
|
||||
|
||||
function handleDismiss() {
|
||||
if (props.preview) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
announcementStore.dismissPopup()
|
||||
}
|
||||
|
||||
// Manage body overflow — only set, never unset (bell component handles restore)
|
||||
watch(
|
||||
() => announcementStore.currentPopup,
|
||||
displayedAnnouncement,
|
||||
(popup) => {
|
||||
if (popup) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else if (props.preview) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.preview) {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import AnnouncementPopup from '../AnnouncementPopup.vue'
|
||||
import { useAnnouncementStore } from '@/stores/announcements'
|
||||
|
||||
const announcementMarkdownStyles = readFileSync(
|
||||
resolve(process.cwd(), 'src/styles/announcement-markdown.css'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
it('renders mixed Markdown and HTML inside the shared styled container', async () => {
|
||||
const store = useAnnouncementStore()
|
||||
store.currentPopup = {
|
||||
id: 1,
|
||||
title: 'Mixed content announcement',
|
||||
content: [
|
||||
'## Markdown heading',
|
||||
'',
|
||||
'<div><h3>HTML heading</h3><ul><li>HTML list item</li></ul></div>',
|
||||
'',
|
||||
'<table><thead><tr><th>Status</th></tr></thead><tbody><tr><td>OK</td></tr></tbody></table>',
|
||||
'<script>window.__announcementXss = true</script>',
|
||||
].join('\n'),
|
||||
notify_mode: 'popup',
|
||||
created_at: '2026-07-24T07:30:00Z',
|
||||
updated_at: '2026-07-24T07:30:00Z',
|
||||
}
|
||||
|
||||
const wrapper = mount(AnnouncementPopup)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const content = document.body.querySelector('.markdown-body')
|
||||
expect(content?.querySelector('h2')?.textContent).toBe('Markdown heading')
|
||||
expect(content?.querySelector('h3')?.textContent).toBe('HTML heading')
|
||||
expect(content?.querySelector('li')?.textContent).toBe('HTML list item')
|
||||
expect(content?.querySelector('table td')?.textContent).toBe('OK')
|
||||
expect(content?.querySelector('script')).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['h2', 'h3', 'ul', 'li', 'blockquote', 'table', 'th', 'td', 'code'])(
|
||||
'loads a shared style rule for mixed-content <%s> elements',
|
||||
(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',
|
||||
timeNever: 'Never',
|
||||
readStatus: 'Read Status',
|
||||
preview: 'Preview',
|
||||
eligible: 'Eligible',
|
||||
readAt: 'Read at',
|
||||
unread: 'Unread',
|
||||
|
||||
@@ -430,6 +430,7 @@ export default {
|
||||
timeImmediate: '立即',
|
||||
timeNever: '永久',
|
||||
readStatus: '已读情况',
|
||||
preview: '预览',
|
||||
eligible: '符合条件',
|
||||
readAt: '已读时间',
|
||||
unread: '未读',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Shared rich-text styles for announcement popup and bell detail content. */
|
||||
.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 pl-2 leading-relaxed;
|
||||
}
|
||||
|
||||
.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 font-serif text-5xl text-blue-500/20 dark:text-blue-400/20;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
@apply rounded-lg bg-gray-100 px-2 py-1 font-mono text-[13px] 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-b border-r 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;
|
||||
}
|
||||
@@ -114,12 +114,19 @@
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<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
|
||||
@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"
|
||||
:title="t('admin.announcements.readStatus')"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
<Icon name="chartBar" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditDialog(row)"
|
||||
@@ -240,6 +247,12 @@
|
||||
:announcement-id="readStatusAnnouncementId"
|
||||
@close="showReadStatusDialog = false"
|
||||
/>
|
||||
|
||||
<AnnouncementPopup
|
||||
:announcement="previewAnnouncement"
|
||||
preview
|
||||
@close="previewAnnouncement = null"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -265,6 +278,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
import AnnouncementTargetingEditor from '@/components/admin/announcements/AnnouncementTargetingEditor.vue'
|
||||
import AnnouncementReadStatusDialog from '@/components/admin/announcements/AnnouncementReadStatusDialog.vue'
|
||||
import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -588,6 +602,11 @@ async function confirmDelete() {
|
||||
// ===== Read status =====
|
||||
const showReadStatusDialog = ref(false)
|
||||
const readStatusAnnouncementId = ref<number | null>(null)
|
||||
const previewAnnouncement = ref<Announcement | null>(null)
|
||||
|
||||
function openPreview(row: Announcement) {
|
||||
previewAnnouncement.value = row
|
||||
}
|
||||
|
||||
function openReadStatus(row: Announcement) {
|
||||
readStatusAnnouncementId.value = row.id
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.yourCode') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<code class="flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ detail.aff_code }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyCode">
|
||||
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
|
||||
<code class="min-w-0 break-all text-sm font-semibold text-gray-900 dark:text-white sm:flex-1 sm:truncate">{{ detail.aff_code }}</code>
|
||||
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyCode">
|
||||
<Icon name="copy" size="sm" />
|
||||
<span>{{ t('affiliate.copyCode') }}</span>
|
||||
</button>
|
||||
@@ -62,9 +62,9 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.inviteLink') }}</p>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<code class="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">{{ inviteLink }}</code>
|
||||
<button class="btn btn-secondary btn-sm" @click="copyInviteLink">
|
||||
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
|
||||
<code class="min-w-0 break-all text-sm text-gray-700 dark:text-gray-300 sm:flex-1 sm:truncate">{{ inviteLink }}</code>
|
||||
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyInviteLink">
|
||||
<Icon name="copy" size="sm" />
|
||||
<span>{{ t('affiliate.copyLink') }}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AffiliateView from '../AffiliateView.vue'
|
||||
|
||||
const { copyToClipboard, getAffiliateDetail } = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
getAffiliateDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/user', () => ({
|
||||
default: {
|
||||
getAffiliateDetail,
|
||||
transferAffiliateQuota: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
refreshUser: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({ copyToClipboard }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AffiliateView', () => {
|
||||
const affiliateCode = 'affiliate-code-that-is-long-enough-to-overflow-a-mobile-viewport'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
copyToClipboard.mockResolvedValue(true)
|
||||
getAffiliateDetail.mockResolvedValue({
|
||||
user_id: 1,
|
||||
aff_code: affiliateCode,
|
||||
inviter_id: null,
|
||||
aff_count: 0,
|
||||
aff_quota: 0,
|
||||
aff_frozen_quota: 0,
|
||||
aff_history_quota: 0,
|
||||
effective_rebate_rate_percent: 10,
|
||||
invitees: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks long values and copy controls on mobile while retaining desktop rows', async () => {
|
||||
const wrapper = mount(AffiliateView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<main><slot /></main>' },
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const values = wrapper.findAll('code')
|
||||
expect(values).toHaveLength(2)
|
||||
for (const value of values) {
|
||||
expect(value.classes()).toEqual(expect.arrayContaining([
|
||||
'min-w-0',
|
||||
'break-all',
|
||||
'sm:flex-1',
|
||||
'sm:truncate',
|
||||
]))
|
||||
expect(Array.from(value.element.parentElement?.classList ?? [])).toEqual(expect.arrayContaining([
|
||||
'flex-col',
|
||||
'items-stretch',
|
||||
'sm:flex-row',
|
||||
'sm:items-center',
|
||||
]))
|
||||
}
|
||||
|
||||
const copyButtons = wrapper.findAll('button').filter((button) =>
|
||||
['affiliate.copyCode', 'affiliate.copyLink'].includes(button.text()),
|
||||
)
|
||||
expect(copyButtons).toHaveLength(2)
|
||||
for (const button of copyButtons) {
|
||||
expect(button.classes()).toEqual(expect.arrayContaining([
|
||||
'w-full',
|
||||
'sm:w-auto',
|
||||
'sm:shrink-0',
|
||||
]))
|
||||
}
|
||||
|
||||
await copyButtons[0].trigger('click')
|
||||
await copyButtons[1].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(copyToClipboard).toHaveBeenNthCalledWith(1, affiliateCode, 'affiliate.codeCopied')
|
||||
expect(copyToClipboard).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`${window.location.origin}/register?aff=${encodeURIComponent(affiliateCode)}`,
|
||||
'affiliate.linkCopied',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user