之前公告(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>
380 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|