feat(notification): 公告整合进 Notification — fan-out + 强弹 Modal + chip + 删 announcement_enabled 概念

之前公告(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 顶层挂 <GlobalAnnouncementGate /> — 任意路由有未读公告强弹 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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-15 15:57:48 +08:00
parent c54fdda0e8
commit 7a503db814
11 changed files with 215 additions and 95 deletions

View File

@ -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'),

View File

@ -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": "<html>...</html>" }
所有用户(含封禁,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)} 个用户'})
# ──────────────────────────────────────────────

View File

@ -9,6 +9,7 @@ class Notification(models.Model):
('anomaly_disabled_user', '账号因异常被自动封禁'),
('anomaly_disabled_team', '团队因异常被自动封禁'),
('quota_warning', '额度即将耗尽'),
('announcement', '系统公告'),
('system', '系统通知'),
]

View File

@ -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() {
<BrowserRouter>
<AmbientBackground />
<Toast />
{/* 全局公告 — 任意路由有未读公告就强弹 modal,必须看 */}
<GlobalAnnouncementGate />
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LandingPage autoLogin />} />

View File

@ -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
*
* + + <GlobalAnnouncementGate /> ,
* 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 (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
<div
className={styles.overlay}
onMouseDown={(e) => { if (e.target === e.currentTarget) handleClose(); }}
>
<div className={styles.modal}>
<div className={styles.header}>
<span className={styles.title}></span>
<button className={styles.closeBtn} onClick={handleClose}>
<button className={styles.closeBtn} onClick={handleClose} aria-label="关闭公告">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
@ -45,7 +42,7 @@ export function AnnouncementModal({ forceOpen, onClose }: Props) {
</div>
<div
className={styles.content}
dangerouslySetInnerHTML={{ __html: `<style>li{margin-left:16px}</style>${content}` }}
dangerouslySetInnerHTML={{ __html: `<style>li{margin-left:16px}</style>${safeHtml}` }}
/>
<div className={styles.footer}>
<button className={styles.confirmBtn} onClick={handleClose}>

View File

@ -0,0 +1,60 @@
import { useEffect, useState, useCallback } from 'react';
import { videoApi } from '../lib/api';
import { useAuthStore } from '../store/auth';
import { useNotificationStore } from '../store/notification';
import { AnnouncementModal } from './AnnouncementModal';
/**
* App.tsx ,
*
* :
* - / tab / GET /announcement
* - modal,
* - POST /announcement/read sidebar
*
* sidebar ( flow)
*/
export function GlobalAnnouncementGate() {
const user = useAuthStore((s) => s.user);
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
const [unread, setUnread] = useState<{ content: string; notification_id: number } | null>(null);
const check = useCallback(async () => {
try {
const { data } = await videoApi.getAnnouncement();
if (data.enabled && data.announcement && !data.is_read) {
setUnread({
content: data.announcement,
// 兼容老前端响应,notification_id 可能不存在(后端已加)
notification_id: (data as { notification_id?: number }).notification_id ?? 0,
});
}
} catch {
// 静默失败 — 网络抖动不炸 UI
}
}, []);
useEffect(() => {
if (!user) {
setUnread(null);
return;
}
check();
const onVis = () => { if (!document.hidden) check(); };
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [user, check]);
const handleClose = useCallback(async () => {
try {
await videoApi.readAnnouncement();
} catch {
// 即便后端标失败,前端也得让用户能关 — 否则页面卡死
}
setUnread(null);
fetchUnreadCount(); // 同步 sidebar 铃铛红点 -1
}, [fetchUnreadCount]);
if (!unread) return null;
return <AnnouncementModal content={unread.content} onClose={handleClose} />;
}

View File

@ -3,8 +3,6 @@ import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard';
import { VideoDetailModal } from './VideoDetailModal';
import { AnnouncementBanner } from './AnnouncementBanner';
import { AnnouncementModal } from './AnnouncementModal';
import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types';
@ -28,8 +26,6 @@ export function VideoGenerationPage() {
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [autoAnnouncementDone, setAutoAnnouncementDone] = useState(false);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
@ -145,27 +141,7 @@ export function VideoGenerationPage() {
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
{/* 公告已改为弹窗,旧的横幅不再显示 */}
{/* 右上角公告小喇叭 */}
<button
onClick={() => setShowAnnouncement(true)}
style={{
position: 'absolute', top: 12, right: 16, zIndex: 20,
background: 'var(--color-bg-card)', border: '1px solid var(--color-border-card)',
borderRadius: '50%', width: 32, height: 32,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: 'var(--color-text-secondary)',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; }}
title="查看公告"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</button>
{/* 旧的右上角公告小喇叭已删 — 公告统一走 sidebar 大铃铛 → 消息中心 */}
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
{tasks.length === 0 ? (
<div className={styles.emptyArea}>
@ -221,14 +197,8 @@ export function VideoGenerationPage() {
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/>
{/* 自动弹窗(首次未读)*/}
{!autoAnnouncementDone && (
<AnnouncementModal onClose={() => setAutoAnnouncementDone(true)} />
)}
{/* 手动弹窗(点小喇叭)*/}
{showAnnouncement && (
<AnnouncementModal forceOpen onClose={() => setShowAnnouncement(false)} />
)}
{/* 公告弹窗已搬到 App.tsx 顶层 GlobalAnnouncementGate(任意路由有未读公告自动强弹) */}
{/* 重看历史公告 → 走 sidebar 大铃铛 → 消息中心页 */}
</div>
);
}

View File

@ -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<AdminStats>('/admin/stats'),

View File

@ -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<NotificationType, { text: string; color: string; bg: string }> = {
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 (
<div
style={rowStyle}
@ -211,12 +223,27 @@ function NotificationRow({ item, isLast, onClick }: NotificationRowProps) {
)}
<div style={styles.rowContent}>
<div style={styles.rowHead}>
<span style={titleStyle}>{item.title}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<span style={{
padding: '1px 7px', borderRadius: 4, fontSize: 11, fontWeight: 500,
color: chip.color, background: chip.bg, flexShrink: 0,
}}>
{chip.text}
</span>
<span style={titleStyle}>{item.title}</span>
</div>
<span style={styles.rowTime} title={new Date(item.created_at).toLocaleString('zh-CN')}>
{formatRelative(item.created_at)}
</span>
</div>
<div style={styles.rowBody}>{item.content}</div>
{isAnnouncement ? (
<div
style={styles.rowBody}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.content) }}
/>
) : (
<div style={styles.rowBody}>{item.content}</div>
)}
</div>
</div>
);

View File

@ -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() {
<div className={styles.cardHeader}>
<div>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}></p>
<p className={styles.cardDesc}> (,);</p>
</div>
<label className={styles.switch}>
<input
type="checkbox"
checked={settings.announcement_enabled}
onChange={(e) => setSettings({ ...settings, announcement_enabled: e.target.checked })}
/>
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.formGroup}>
<label> HTML </label>
@ -338,8 +338,8 @@ export function SettingsPage() {
/>
)}
</div>
<button className={styles.saveBtn} onClick={handleSaveAnnouncement} disabled={saving}>
{saving ? '保存中...' : '保存公告'}
<button className={styles.saveBtn} onClick={handlePublishAnnouncement} disabled={saving}>
{saving ? '发送中...' : '发送公告'}
</button>
</div>

View File

@ -476,6 +476,7 @@ export type NotificationType =
| 'anomaly_disabled_user'
| 'anomaly_disabled_team'
| 'quota_warning'
| 'announcement'
| 'system';
// 用 AppNotification 命名,避免与浏览器内置的 Notification Web API 冲突