video-shuoshan/web/src/pages/NotificationsPage.tsx
seaislee1209 7a503db814 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>
2026-05-15 15:58:38 +08:00

380 lines
11 KiB
TypeScript

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, 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();
if (Number.isNaN(ts)) return '';
const ms = Date.now() - ts;
if (ms < 0) return '刚刚';
const sec = Math.floor(ms / 1000);
if (sec < 60) return '刚刚';
if (sec < 3600) return `${Math.floor(sec / 60)} 分钟前`;
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时前`;
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)} 天前`;
return new Date(iso).toLocaleDateString('zh-CN');
}
const styles: Record<string, CSSProperties> = {
layout: {
display: 'flex',
height: '100%',
position: 'relative',
zIndex: 2,
},
main: {
flex: 1,
overflowY: 'auto',
padding: '24px 32px 60px',
background: 'var(--color-bg-page)',
},
container: {
maxWidth: 800,
margin: '0 auto',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
},
title: {
fontSize: 20,
fontWeight: 600,
color: 'var(--color-text-primary)',
margin: 0,
},
markAllBtn: {
padding: '6px 14px',
background: 'transparent',
border: '1px solid var(--color-border-card)',
borderRadius: 'var(--radius-btn, 6px)',
color: 'var(--color-text-secondary)',
fontSize: 13,
cursor: 'pointer',
transition: 'background 0.15s, color 0.15s',
},
list: {
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border-modal-soft)',
borderRadius: 8,
overflow: 'hidden',
},
row: {
display: 'flex',
gap: 12,
padding: '14px 18px',
borderBottom: '1px solid var(--color-border-modal-soft)',
cursor: 'pointer',
transition: 'background 0.15s',
position: 'relative',
},
rowLast: {
borderBottom: 'none',
},
unreadDot: {
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--color-primary)',
flexShrink: 0,
marginTop: 7,
},
dotPlaceholder: {
width: 8,
height: 8,
flexShrink: 0,
},
rowContent: {
flex: 1,
minWidth: 0,
},
rowHead: {
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
marginBottom: 4,
},
rowTitle: {
fontSize: 14,
fontWeight: 600,
color: 'var(--color-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
rowTitleRead: {
fontWeight: 500,
color: 'var(--color-text-light)',
},
rowTime: {
fontSize: 12,
color: 'var(--color-text-tertiary)',
flexShrink: 0,
whiteSpace: 'nowrap',
},
rowBody: {
fontSize: 13,
color: 'var(--color-text-secondary)',
lineHeight: 1.5,
wordBreak: 'break-word',
},
empty: {
padding: '80px 0',
textAlign: 'center',
color: 'var(--color-text-tertiary)',
fontSize: 14,
},
loading: {
padding: '60px 0',
textAlign: 'center',
color: 'var(--color-text-secondary)',
fontSize: 14,
},
pagination: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
marginTop: 20,
color: 'var(--color-text-secondary)',
fontSize: 13,
},
pageBtn: {
width: 28,
height: 28,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--color-border-card)',
background: 'transparent',
borderRadius: 6,
cursor: 'pointer',
color: 'var(--color-text-secondary)',
padding: 0,
},
pageBtnDisabled: {
cursor: 'not-allowed',
opacity: 0.4,
},
backBtn: {
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
background: 'transparent',
border: '1px solid var(--color-border-card)',
borderRadius: 'var(--radius-btn, 6px)',
color: 'var(--color-text-secondary)',
fontSize: 13,
cursor: 'pointer',
marginRight: 12,
},
};
interface NotificationRowProps {
item: AppNotification;
isLast: boolean;
onClick: (item: AppNotification) => void;
}
function NotificationRow({ item, isLast, onClick }: NotificationRowProps) {
const rowStyle: CSSProperties = {
...styles.row,
...(isLast ? styles.rowLast : {}),
background: item.is_read ? 'transparent' : 'var(--color-primary-bg, transparent)',
};
const titleStyle: CSSProperties = {
...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}
onClick={() => onClick(item)}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = item.is_read
? 'transparent'
: 'var(--color-primary-bg, transparent)';
}}
>
{item.is_read ? (
<div style={styles.dotPlaceholder} />
) : (
<div style={styles.unreadDot} aria-label="未读" />
)}
<div style={styles.rowContent}>
<div style={styles.rowHead}>
<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>
{isAnnouncement ? (
<div
style={styles.rowBody}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.content) }}
/>
) : (
<div style={styles.rowBody}>{item.content}</div>
)}
</div>
</div>
);
}
export function NotificationsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const unreadOnly = searchParams.get('unread_only') === 'true';
const list = useNotificationStore((s) => s.list);
const total = useNotificationStore((s) => s.total);
const page = useNotificationStore((s) => s.page);
const pageSize = useNotificationStore((s) => s.pageSize);
const unreadCount = useNotificationStore((s) => s.unreadCount);
const loading = useNotificationStore((s) => s.loading);
const fetchList = useNotificationStore((s) => s.fetchList);
const markRead = useNotificationStore((s) => s.markRead);
const markAllRead = useNotificationStore((s) => s.markAllRead);
// 首次加载 + URL 切换时拉第一页
useEffect(() => {
fetchList({ page: 1, unread_only: unreadOnly });
}, [fetchList, unreadOnly]);
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const goPage = (p: number) => {
if (p < 1 || p > totalPages || p === page) return;
fetchList({ page: p, unread_only: unreadOnly });
};
const handleRowClick = async (item: AppNotification) => {
if (!item.is_read) {
markRead(item.id);
}
if (item.link_url) {
navigate(item.link_url);
}
};
return (
<div style={styles.layout}>
<Sidebar />
<main style={styles.main}>
<div style={styles.container}>
<div style={styles.header}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<button
style={styles.backBtn}
onClick={() => navigate(-1)}
title="返回上一页"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<h1 style={styles.title}></h1>
</div>
{unreadCount > 0 && (
<button
style={styles.markAllBtn}
onClick={() => markAllRead()}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-bg-hover)';
e.currentTarget.style.color = 'var(--color-text-primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
>
</button>
)}
</div>
{loading && list.length === 0 ? (
<div style={styles.loading}>...</div>
) : list.length === 0 ? (
<div style={styles.empty}></div>
) : (
<>
<div style={styles.list}>
{list.map((item, idx) => (
<NotificationRow
key={item.id}
item={item}
isLast={idx === list.length - 1}
onClick={handleRowClick}
/>
))}
</div>
{totalPages > 1 && (
<div style={styles.pagination}>
<button
style={{
...styles.pageBtn,
...(page <= 1 ? styles.pageBtnDisabled : {}),
}}
onClick={() => goPage(page - 1)}
disabled={page <= 1}
aria-label="上一页"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span> {page} / {totalPages} </span>
<button
style={{
...styles.pageBtn,
...(page >= totalPages ? styles.pageBtnDisabled : {}),
}}
onClick={() => goPage(page + 1)}
disabled={page >= totalPages}
aria-label="下一页"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
)}
</>
)}
</div>
</main>
</div>
);
}