Commit bbdc8663 authored by shaw's avatar shaw
Browse files

feat: 重新设计公告系统为Header铃铛通知

- 新增 AnnouncementBell 组件,支持 Modal 弹窗和 Markdown 渲染
- 移除 Dashboard 横幅和独立公告页面
- 铃铛位置在 Header 文档按钮左侧,显示未读红点
- 支持点击查看详情、标记已读、全部已读等操作
- 完善国际化,移除所有硬编码中文
- 修复 AnnouncementTargetingEditor watch 循环问题
parent d3062b2e
This diff is collapsed.
...@@ -19,8 +19,10 @@ ...@@ -19,8 +19,10 @@
"@vueuse/core": "^10.7.0", "@vueuse/core": "^10.7.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"dompurify": "^3.3.1",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"marked": "^17.0.1",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"vue": "^3.4.0", "vue": "^3.4.0",
...@@ -30,6 +32,7 @@ ...@@ -30,6 +32,7 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
......
...@@ -20,12 +20,18 @@ importers: ...@@ -20,12 +20,18 @@ importers:
chart.js: chart.js:
specifier: ^4.4.1 specifier: ^4.4.1
version: 4.5.1 version: 4.5.1
dompurify:
specifier: ^3.3.1
version: 3.3.1
driver.js: driver.js:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
file-saver: file-saver:
specifier: ^2.0.5 specifier: ^2.0.5
version: 2.0.5 version: 2.0.5
marked:
specifier: ^17.0.1
version: 17.0.1
pinia: pinia:
specifier: ^2.1.7 specifier: ^2.1.7
version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3)) version: 2.3.1(typescript@5.6.3)(vue@3.5.26(typescript@5.6.3))
...@@ -48,6 +54,9 @@ importers: ...@@ -48,6 +54,9 @@ importers:
specifier: ^0.18.5 specifier: ^0.18.5
version: 0.18.5 version: 0.18.5
devDependencies: devDependencies:
'@types/dompurify':
specifier: ^3.0.5
version: 3.2.0
'@types/file-saver': '@types/file-saver':
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7 version: 2.0.7
...@@ -1460,6 +1469,10 @@ packages: ...@@ -1460,6 +1469,10 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dompurify@3.2.0':
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
...@@ -5901,6 +5914,10 @@ snapshots: ...@@ -5901,6 +5914,10 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dompurify@3.2.0':
dependencies:
dompurify: 3.3.1
'@types/estree-jsx@1.0.5': '@types/estree-jsx@1.0.5':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
......
...@@ -323,6 +323,7 @@ function ensureSelectionPath(groupIndex: number, condIndex: number) { ...@@ -323,6 +323,7 @@ function ensureSelectionPath(groupIndex: number, condIndex: number) {
if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = [] if (!subscriptionSelections[groupIndex][condIndex]) subscriptionSelections[groupIndex][condIndex] = []
} }
// Sync from modelValue to subscriptionSelections (one-way: model -> local state)
watch( watch(
() => props.modelValue, () => props.modelValue,
(v) => { (v) => {
...@@ -333,20 +334,34 @@ watch( ...@@ -333,20 +334,34 @@ watch(
const c = allOf[ci] const c = allOf[ci]
if (c?.type === 'subscription') { if (c?.type === 'subscription') {
ensureSelectionPath(gi, ci) ensureSelectionPath(gi, ci)
subscriptionSelections[gi][ci] = (c.group_ids ?? []).slice() // Only update if different to avoid triggering unnecessary updates
const newIds = (c.group_ids ?? []).slice()
const currentIds = subscriptionSelections[gi]?.[ci] ?? []
if (JSON.stringify(newIds.sort()) !== JSON.stringify(currentIds.sort())) {
subscriptionSelections[gi][ci] = newIds
}
} }
} }
} }
}, },
{ immediate: true, deep: true } { immediate: true }
) )
// Sync from subscriptionSelections to modelValue (one-way: local state -> model)
// Use a debounced approach to avoid infinite loops
let syncTimeout: ReturnType<typeof setTimeout> | null = null
watch( watch(
() => subscriptionSelections, () => subscriptionSelections,
() => { () => {
// sync back to targeting // Debounce the sync to avoid rapid fire updates
updateTargeting((draft) => { if (syncTimeout) clearTimeout(syncTimeout)
const groups = draft.any_of ?? []
syncTimeout = setTimeout(() => {
// Build the new targeting state
const newTargeting: TargetingDraft = JSON.parse(JSON.stringify(props.modelValue ?? { any_of: [] }))
if (!newTargeting.any_of) newTargeting.any_of = []
const groups = newTargeting.any_of ?? []
for (let gi = 0; gi < groups.length; gi++) { for (let gi = 0; gi < groups.length; gi++) {
const allOf = groups[gi]?.all_of ?? [] const allOf = groups[gi]?.all_of ?? []
for (let ci = 0; ci < allOf.length; ci++) { for (let ci = 0; ci < allOf.length; ci++) {
...@@ -358,7 +373,12 @@ watch( ...@@ -358,7 +373,12 @@ watch(
} }
} }
} }
})
// Only emit if there's an actual change (deep comparison)
if (JSON.stringify(props.modelValue) !== JSON.stringify(newTargeting)) {
emit('update:modelValue', newTargeting)
}
}, 0)
}, },
{ deep: true } { deep: true }
) )
......
This diff is collapsed.
...@@ -107,6 +107,9 @@ const icons = { ...@@ -107,6 +107,9 @@ const icons = {
database: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125', database: 'M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125',
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4', cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
// Notification
bell: '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',
// Misc // Misc
bolt: 'M13 10V3L4 14h7v7l9-11h-7z', bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z', sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z',
......
...@@ -21,8 +21,11 @@ ...@@ -21,8 +21,11 @@
</div> </div>
</div> </div>
<!-- Right: Docs + Language + Subscriptions + Balance + User Dropdown --> <!-- Right: Announcements + Docs + Language + Subscriptions + Balance + User Dropdown -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Announcement Bell -->
<AnnouncementBell v-if="user" />
<!-- Docs Link --> <!-- Docs Link -->
<a <a
v-if="docUrl" v-if="docUrl"
...@@ -210,6 +213,7 @@ import { useI18n } from 'vue-i18n' ...@@ -210,6 +213,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue' import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
const router = useRouter() const router = useRouter()
......
...@@ -433,7 +433,6 @@ const ChevronDoubleRightIcon = { ...@@ -433,7 +433,6 @@ const ChevronDoubleRightIcon = {
const userNavItems = computed(() => { const userNavItems = computed(() => {
const items = [ const items = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
{ path: '/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...@@ -456,7 +455,6 @@ const userNavItems = computed(() => { ...@@ -456,7 +455,6 @@ const userNavItems = computed(() => {
// Personal navigation items (for admin's "My Account" section, without Dashboard) // Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed(() => { const personalNavItems = computed(() => {
const items = [ const items = [
{ path: '/announcements', label: t('nav.announcements'), icon: BellIcon },
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
......
...@@ -3139,13 +3139,22 @@ export default { ...@@ -3139,13 +3139,22 @@ export default {
description: 'View system announcements', description: 'View system announcements',
unreadOnly: 'Show unread only', unreadOnly: 'Show unread only',
markRead: 'Mark as read', markRead: 'Mark as read',
markAllRead: 'Mark all as read',
viewAll: 'View all announcements',
markedAsRead: 'Marked as read',
allMarkedAsRead: 'All announcements marked as read',
newCount: '{count} new announcement | {count} new announcements',
readAt: 'Read at', readAt: 'Read at',
read: 'Read', read: 'Read',
unread: 'Unread', unread: 'Unread',
startsAt: 'Starts at', startsAt: 'Starts at',
endsAt: 'Ends at', endsAt: 'Ends at',
empty: 'No announcements', empty: 'No announcements',
emptyUnread: 'No unread announcements' emptyUnread: 'No unread announcements',
total: 'announcements',
emptyDescription: 'There are no system announcements at this time',
readStatus: 'You have read this announcement',
markReadHint: 'Click "Mark as read" to mark this announcement'
}, },
// User Subscriptions Page // User Subscriptions Page
......
...@@ -3288,13 +3288,22 @@ export default { ...@@ -3288,13 +3288,22 @@ export default {
description: '查看系统公告', description: '查看系统公告',
unreadOnly: '仅显示未读', unreadOnly: '仅显示未读',
markRead: '标记已读', markRead: '标记已读',
markAllRead: '全部已读',
viewAll: '查看全部公告',
markedAsRead: '已标记为已读',
allMarkedAsRead: '所有公告已标记为已读',
newCount: '有 {count} 条新公告',
readAt: '已读时间', readAt: '已读时间',
read: '已读', read: '已读',
unread: '未读', unread: '未读',
startsAt: '开始时间', startsAt: '开始时间',
endsAt: '结束时间', endsAt: '结束时间',
empty: '暂无公告', empty: '暂无公告',
emptyUnread: '暂无未读公告' emptyUnread: '暂无未读公告',
total: '条公告',
emptyDescription: '暂时没有任何系统公告',
readStatus: '您已阅读此公告',
markReadHint: '点击"已读"标记此公告'
}, },
// User Subscriptions Page // User Subscriptions Page
......
...@@ -187,18 +187,6 @@ const routes: RouteRecordRaw[] = [ ...@@ -187,18 +187,6 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'purchase.description' descriptionKey: 'purchase.description'
} }
}, },
{
path: '/announcements',
name: 'Announcements',
component: () => import('@/views/user/AnnouncementsView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Announcements',
titleKey: 'announcements.title',
descriptionKey: 'announcements.description'
}
},
// ==================== Admin Routes ==================== // ==================== Admin Routes ====================
{ {
......
...@@ -261,3 +261,22 @@ export function formatCountdownWithSuffix(targetDate: string | Date | null | und ...@@ -261,3 +261,22 @@ export function formatCountdownWithSuffix(targetDate: string | Date | null | und
if (!countdown) return null if (!countdown) return null
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown }) return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
} }
/**
* 格式化为相对时间 + 具体时间组合
* @param date 日期字符串或 Date 对象
* @returns 组合时间字符串,如 "5 天前 · 2026-01-27 15:25"
*/
export function formatRelativeWithDateTime(date: string | Date | null | undefined): string {
if (!date) return ''
const relativeTime = formatRelativeTime(date)
const dateTime = formatDateTime(date)
// 如果是 "从未" 或空字符串,只返回相对时间
if (!dateTime || relativeTime === i18n.global.t('common.time.never')) {
return relativeTime
}
return `${relativeTime} · ${dateTime}`
}
<template>
<AppLayout>
<TablePageLayout>
<template #actions>
<div class="flex justify-end gap-3">
<button
@click="loadAnnouncements"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</template>
<template #filters>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="unreadOnly" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
<span>{{ t('announcements.unreadOnly') }}</span>
</label>
</div>
</template>
<template #table>
<div v-if="loading" class="flex items-center justify-center py-10">
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
</div>
<div v-else-if="announcements.length === 0" class="py-12 text-center text-gray-500 dark:text-gray-400">
{{ unreadOnly ? t('announcements.emptyUnread') : t('announcements.empty') }}
</div>
<div v-else class="space-y-4">
<div
v-for="item in announcements"
:key="item.id"
class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">
{{ item.title }}
</h3>
<span v-if="!item.read_at" class="badge badge-warning">
{{ t('announcements.unread') }}
</span>
<span v-else class="badge badge-success">
{{ t('announcements.read') }}
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-dark-400">
<span>{{ formatDateTime(item.created_at) }}</span>
<span v-if="item.starts_at">
{{ t('announcements.startsAt') }}: {{ formatDateTime(item.starts_at) }}
</span>
<span v-if="item.ends_at">
{{ t('announcements.endsAt') }}: {{ formatDateTime(item.ends_at) }}
</span>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-2">
<button
v-if="!item.read_at"
class="btn btn-secondary"
:disabled="markingReadId === item.id"
@click="markRead(item.id)"
>
{{ markingReadId === item.id ? t('common.processing') : t('announcements.markRead') }}
</button>
<span v-else class="text-xs text-gray-500 dark:text-dark-400">
{{ t('announcements.readAt') }}: {{ formatDateTime(item.read_at) }}
</span>
</div>
</div>
<div class="mt-4 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
{{ item.content }}
</div>
</div>
</div>
</template>
</TablePageLayout>
</AppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { announcementsAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import { formatDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const announcements = ref<UserAnnouncement[]>([])
const loading = ref(false)
const unreadOnly = ref(false)
const markingReadId = ref<number | null>(null)
async function loadAnnouncements() {
try {
loading.value = true
announcements.value = await announcementsAPI.list(unreadOnly.value)
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
loading.value = false
}
}
async function markRead(id: number) {
if (markingReadId.value) return
try {
markingReadId.value = id
await announcementsAPI.markRead(id)
await loadAnnouncements()
} catch (err: any) {
appStore.showError(err?.message || t('common.unknownError'))
} finally {
markingReadId.value = null
}
}
watch(unreadOnly, () => {
loadAnnouncements()
})
onMounted(() => {
loadAnnouncements()
})
</script>
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