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:
parent
c54fdda0e8
commit
7a503db814
@ -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'),
|
||||
|
||||
@ -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)} 个用户'})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@ -9,6 +9,7 @@ class Notification(models.Model):
|
||||
('anomaly_disabled_user', '账号因异常被自动封禁'),
|
||||
('anomaly_disabled_team', '团队因异常被自动封禁'),
|
||||
('quota_warning', '额度即将耗尽'),
|
||||
('announcement', '系统公告'),
|
||||
('system', '系统通知'),
|
||||
]
|
||||
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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}>
|
||||
|
||||
60
web/src/components/GlobalAnnouncementGate.tsx
Normal file
60
web/src/components/GlobalAnnouncementGate.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -476,6 +476,7 @@ export type NotificationType =
|
||||
| 'anomaly_disabled_user'
|
||||
| 'anomaly_disabled_team'
|
||||
| 'quota_warning'
|
||||
| 'announcement'
|
||||
| 'system';
|
||||
|
||||
// 用 AppNotification 命名,避免与浏览器内置的 Notification Web API 冲突
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user