From 7a503db814d27f2cf570647c029890a1f13109f3 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 15:57:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E6=95=B4=E5=90=88=E8=BF=9B=20Notification=20=E2=80=94=20fan-ou?= =?UTF-8?q?t=20+=20=E5=BC=BA=E5=BC=B9=20Modal=20+=20chip=20+=20=E5=88=A0?= =?UTF-8?q?=20announcement=5Fenabled=20=E6=A6=82=E5=BF=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前公告(QuotaConfig.announcement,v0.12.6)与 v0.20.1 消息中心 UX 重叠 — 两个铃铛 + 两套未读 + 两套入口让用户分不清。整合到统一 Notification 表: 后端: - apps.notifications Notification.TYPE_CHOICES 加 'announcement' - 新 endpoint POST /api/v1/admin/announcement/publish (IsSuperAdmin) - body { content: HTML 字符串 } - 空内容 400 "公告内容不能为空" - User.objects.all() (含 is_active=False 封禁用户,解封后能看到历史) - bulk_create(batch_size=500) 防大团队 OOM - 同步把 content 存档到 QuotaConfig.announcement 作为下次编辑器初始值 - audit log: settings_update, target=announcement - 重写 GET /announcement 内部查 Notification 表最新未读 - 重写 POST /announcement/read 标记所有未读公告已读 - endpoint 签名不变保持老前端兼容(返回结构相同) 前端: - App.tsx 顶层挂 — 任意路由有未读公告强弹 modal 必须看(关闭遮罩点击也算关闭→标已读),关闭后 60s 内不再弹 - AnnouncementModal 改成纯展示组件: props { content, onClose },不自己 fetch HTML 内容用 DOMPurify.sanitize 防 XSS - 删 VideoGenerationPage 右上角小喇叭 + 旧 AnnouncementModal 自动弹 + 重看路径 (用户重看走 sidebar 大铃铛 → 消息中心) - SettingsPage 公告区: - 删 announcement_enabled checkbox(不再有"启用/停用"概念,发了就强弹) - "保存公告"按钮 → 改 "发送公告" 按钮 - 二次 confirm "确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回" - 调 announcementApi.publish (POST /admin/announcement/publish) - NotificationsPage 每条加 type chip([公告]/[安全]/[额度]/[系统]) 4 色 announcement type 用 DOMPurify + dangerouslySetInnerHTML 渲染(其他 type 纯文本) - types/index.ts NotificationType 加 'announcement' 验证: - 后端 curl 全过:发空 400 / 发 HTML 200 sent_to=21 / tudou GET 拿到未读 / read 后 GET 拿空 - typecheck 0 error - v0.20.1-smoke 11/11 + modal-interaction 8/8 + v2-smoke 25/25 - vitest 71 fail / 162 pass 与基线 0 新增回归 Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/apps/generation/urls.py | 2 + backend/apps/generation/views.py | 83 +++++++++++++++---- backend/apps/notifications/models.py | 1 + web/src/App.tsx | 3 + web/src/components/AnnouncementModal.tsx | 51 ++++++------ web/src/components/GlobalAnnouncementGate.tsx | 60 ++++++++++++++ web/src/components/VideoGenerationPage.tsx | 36 +------- web/src/lib/api.ts | 6 ++ web/src/pages/NotificationsPage.tsx | 33 +++++++- web/src/pages/SettingsPage.tsx | 34 ++++---- web/src/types/index.ts | 1 + 11 files changed, 215 insertions(+), 95 deletions(-) create mode 100644 web/src/components/GlobalAnnouncementGate.tsx diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index e4a3c65..8a5ca45 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -12,6 +12,8 @@ urlpatterns = [ # Public announcement path('announcement', views.announcement_view, name='announcement'), path('announcement/read', views.announcement_read_view, name='announcement_read'), + # Admin publish announcement (fan-out to all users) + path('admin/announcement/publish', views.admin_publish_announcement_view, name='admin_announcement_publish'), # ── Super Admin: Dashboard ── path('admin/stats', views.admin_stats_view, name='admin_stats'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index b4c857b..39f578b 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -2188,28 +2188,81 @@ def admin_audit_logs_view(request): @api_view(['GET']) @permission_classes([IsAuthenticated]) def announcement_view(request): - """GET /api/v1/announcement — return active announcement + read status.""" - config, _ = QuotaConfig.objects.get_or_create(pk=1) - if config.announcement_enabled and config.announcement: - is_read = False - if request.user.is_authenticated and request.user.last_read_announcement: - is_read = request.user.last_read_announcement >= config.updated_at + """GET /api/v1/announcement — 返回当前用户最新未读公告(从 Notification 表)。 + + 兼容老前端的响应结构(announcement/enabled/is_read)。 + """ + from apps.notifications.models import Notification + latest = Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).order_by('-created_at').first() + if not latest: return Response({ - 'announcement': config.announcement, - 'enabled': True, - 'is_read': is_read, - 'updated_at': config.updated_at.isoformat(), + 'announcement': '', + 'enabled': False, + 'is_read': True, + 'notification_id': None, }) - return Response({'announcement': '', 'enabled': False, 'is_read': True}) + return Response({ + 'announcement': latest.content, + 'enabled': True, + 'is_read': False, + 'notification_id': latest.id, + 'updated_at': latest.created_at.isoformat(), + }) @api_view(['POST']) @permission_classes([IsAuthenticated]) def announcement_read_view(request): - """POST /api/v1/announcement/read — mark announcement as read.""" - request.user.last_read_announcement = timezone.now() - request.user.save(update_fields=['last_read_announcement']) - return Response({'ok': True}) + """POST /api/v1/announcement/read — 标记当前用户所有未读公告已读。""" + from apps.notifications.models import Notification + updated = Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).update(is_read=True) + return Response({'ok': True, 'updated': updated}) + + +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_publish_announcement_view(request): + """POST /api/v1/admin/announcement/publish — 超管点【发送公告】fan-out 给所有用户。 + + Body: { "content": "..." } + + 所有用户(含封禁,is_active=False 的用户解封后能看到累积的历史公告)。 + 用 bulk_create(batch_size=500) 防大团队 OOM。 + 同步把 content 写回 QuotaConfig.announcement 作为"当前最新公告"草稿存档, + 超管下次进设置页能看到上次发的内容(便于改动后再发)。 + """ + from apps.notifications.models import Notification + User = get_user_model() + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'error': '公告内容不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 存档到 QuotaConfig(作为编辑器数据源,不再控制 fan-out) + config, _ = QuotaConfig.objects.get_or_create(pk=1) + config.announcement = content + config.announcement_enabled = True # 字段保留兼容,但前端不再读取 + config.save(update_fields=['announcement', 'announcement_enabled']) + + # fan-out 给所有用户(含封禁) + user_ids = list(User.objects.all().values_list('id', flat=True)) + notifs = [Notification( + recipient_id=uid, + type='announcement', + title='系统公告', + content=content, + link_url='', + is_read=False, + ) for uid in user_ids] + Notification.objects.bulk_create(notifs, batch_size=500) + + log_admin_action(request, 'settings_update', 'system', target_id=0, target_name='announcement', + after={'recipients': len(notifs), 'content_preview': content[:80]}) + return Response({'sent_to': len(notifs), 'message': f'已发送给 {len(notifs)} 个用户'}) # ────────────────────────────────────────────── diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py index 7bcf4e3..329d67e 100644 --- a/backend/apps/notifications/models.py +++ b/backend/apps/notifications/models.py @@ -9,6 +9,7 @@ class Notification(models.Model): ('anomaly_disabled_user', '账号因异常被自动封禁'), ('anomaly_disabled_team', '团队因异常被自动封禁'), ('quota_warning', '额度即将耗尽'), + ('announcement', '系统公告'), ('system', '系统通知'), ] diff --git a/web/src/App.tsx b/web/src/App.tsx index 135a577..333a889 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,7 @@ import { AmbientBackground } from './components/AmbientBackground'; import { Toast } from './components/Toast'; import { VideoGenerationPage } from './components/VideoGenerationPage'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { GlobalAnnouncementGate } from './components/GlobalAnnouncementGate'; import { LandingPage } from './pages/LandingPage'; import { AdminLayout } from './pages/AdminLayout'; @@ -39,6 +40,8 @@ export default function App() { + {/* 全局公告 — 任意路由有未读公告就强弹 modal,必须看 */} + } /> } /> diff --git a/web/src/components/AnnouncementModal.tsx b/web/src/components/AnnouncementModal.tsx index c1d8a63..e37234e 100644 --- a/web/src/components/AnnouncementModal.tsx +++ b/web/src/components/AnnouncementModal.tsx @@ -1,42 +1,39 @@ -import { useEffect, useState, useCallback } from 'react'; -import { videoApi } from '../lib/api'; +import { useCallback } from 'react'; +import DOMPurify from 'dompurify'; import styles from './AnnouncementModal.module.css'; interface Props { - /** If true, force show even if already read (for manual open) */ - forceOpen?: boolean; - onClose?: () => void; + /** 公告 HTML 内容(由父组件传入,本组件不自己 fetch)。 */ + content: string; + /** 用户点关闭/我知道了时回调,父组件负责标记已读 + 关闭。 */ + onClose: () => void; } -export function AnnouncementModal({ forceOpen, onClose }: Props) { - const [content, setContent] = useState(''); - const [visible, setVisible] = useState(false); - - useEffect(() => { - videoApi.getAnnouncement().then(({ data }) => { - if (data.enabled && data.announcement) { - setContent(data.announcement); - if (forceOpen || !data.is_read) { - setVisible(true); - } - } - }).catch(() => {}); - }, [forceOpen]); - +/** + * 公告 modal — 纯展示组件。 + * + * 数据来源 + 自动弹时机 + 标记已读 全部由 统一处理, + * 本组件只负责渲染。HTML 内容用 DOMPurify 防 XSS。 + * + * 强弹场景:用户必须点【关闭 ✕】或【我知道了】才能关闭, + * 点遮罩外区域同样关闭(关闭后会标已读,不再弹)。 + */ +export function AnnouncementModal({ content, onClose }: Props) { const handleClose = useCallback(() => { - videoApi.readAnnouncement().catch(() => {}); - setVisible(false); - onClose?.(); + onClose(); }, [onClose]); - if (!visible || !content) return null; + const safeHtml = DOMPurify.sanitize(content); return ( -
{ if (e.target === e.currentTarget) handleClose(); }}> +
{ if (e.target === e.currentTarget) handleClose(); }} + >
公告 -
li{margin-left:16px}${content}` }} + dangerouslySetInnerHTML={{ __html: `${safeHtml}` }} />
+ {/* 旧的右上角公告小喇叭已删 — 公告统一走 sidebar 大铃铛 → 消息中心 */}
{tasks.length === 0 ? (
@@ -221,14 +197,8 @@ export function VideoGenerationPage() { onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])} onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])} /> - {/* 自动弹窗(首次未读)*/} - {!autoAnnouncementDone && ( - setAutoAnnouncementDone(true)} /> - )} - {/* 手动弹窗(点小喇叭)*/} - {showAnnouncement && ( - setShowAnnouncement(false)} /> - )} + {/* 公告弹窗已搬到 App.tsx 顶层 GlobalAnnouncementGate(任意路由有未读公告自动强弹) */} + {/* 重看历史公告 → 走 sidebar 大铃铛 → 消息中心页 */}
); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index af576a9..5cb0500 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -181,6 +181,12 @@ export const videoApi = { }; // Admin APIs (Super Admin) +// 公告发送(超管 fan-out 给所有用户) +export const announcementApi = { + publish: (content: string) => + api.post<{ sent_to: number; message: string }>('/admin/announcement/publish', { content }), +}; + export const adminApi = { getStats: () => api.get('/admin/stats'), diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index 0232349..fbde682 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -1,8 +1,18 @@ import { useEffect, type CSSProperties } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import DOMPurify from 'dompurify'; import { Sidebar } from '../components/Sidebar'; import { useNotificationStore } from '../store/notification'; -import type { AppNotification } from '../types'; +import type { AppNotification, NotificationType } from '../types'; + +// 每条通知顶部标签 — 4 色一目了然 +const TYPE_CHIP: Record = { + announcement: { text: '公告', color: 'var(--color-primary)', bg: 'var(--color-primary-bg, rgba(0,184,230,0.12))' }, + anomaly_disabled_user: { text: '安全', color: 'var(--color-danger)', bg: 'rgba(231,76,60,0.12)' }, + anomaly_disabled_team: { text: '安全', color: 'var(--color-danger)', bg: 'rgba(231,76,60,0.12)' }, + quota_warning: { text: '额度', color: '#faad14', bg: 'rgba(250,173,20,0.12)' }, + system: { text: '系统', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-elevated)' }, +}; function formatRelative(iso: string): string { const ts = new Date(iso).getTime(); @@ -191,6 +201,8 @@ function NotificationRow({ item, isLast, onClick }: NotificationRowProps) { ...styles.rowTitle, ...(item.is_read ? styles.rowTitleRead : {}), }; + const chip = TYPE_CHIP[item.type] || TYPE_CHIP.system; + const isAnnouncement = item.type === 'announcement'; return (
- {item.title} +
+ + {chip.text} + + {item.title} +
{formatRelative(item.created_at)}
-
{item.content}
+ {isAnnouncement ? ( +
+ ) : ( +
{item.content}
+ )}
); diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1d4b80b..61cf0d3 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { adminApi } from '../lib/api'; +import { adminApi, announcementApi } from '../lib/api'; import type { SystemSettings } from '../types'; import { showToast } from '../components/Toast'; import styles from './SettingsPage.module.css'; @@ -69,13 +69,21 @@ export function SettingsPage() { } }; - const handleSaveAnnouncement = async () => { + const handlePublishAnnouncement = async () => { + const content = (settings.announcement || '').trim(); + if (!content) { + showToast('公告内容不能为空'); + return; + } + if (!window.confirm('确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回。')) { + return; + } setSaving(true); try { - await adminApi.updateSettings(settings); - showToast('公告已保存'); - } catch { - showToast('保存失败'); + const { data } = await announcementApi.publish(content); + showToast(data.message || `已发送给 ${data.sent_to} 个用户`); + } catch (e: any) { + showToast(e?.response?.data?.error || '发送失败'); } finally { setSaving(false); } @@ -246,16 +254,8 @@ export function SettingsPage() {

系统公告

-

启用后公告将展示在用户端页面顶部

+

编辑内容后点【发送公告】→ 一次性发给所有用户(含被封禁,解封后能看到);所有人打开页面会强制弹出未读公告必须看完

-
@@ -338,8 +338,8 @@ export function SettingsPage() { /> )}
-
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index b9afe70..9c4541e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -476,6 +476,7 @@ export type NotificationType = | 'anomaly_disabled_user' | 'anomaly_disabled_team' | 'quota_warning' + | 'announcement' | 'system'; // 用 AppNotification 命名,避免与浏览器内置的 Notification Web API 冲突