之前公告(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>
61 lines
2.2 KiB
TypeScript
61 lines
2.2 KiB
TypeScript
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} />;
|
|
}
|