From 0ab5523ed1da7e27e703d8f705bdff970aac55b4 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 23 Mar 2026 20:57:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.12.6=20=E5=85=AC=E5=91=8A=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=20+=20HTML=20=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ①公告改为弹窗(用户未读自动弹出,已读不再弹) ②生成页右上角小铃铛按钮可重新查看公告 ③公告支持 HTML 渲染(加粗/红字/蓝字/标题/分割线/列表) ④超管公告编辑器加格式工具栏 + 预览按钮 ⑤去掉旧的公告横幅 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../0012_user_last_read_announcement.py | 18 ++++ backend/apps/accounts/models.py | 1 + backend/apps/generation/urls.py | 1 + backend/apps/generation/views.py | 23 ++++- web/src/components/AnnouncementModal.tsx | 85 +++++++++++++++++ web/src/components/VideoGenerationPage.tsx | 33 ++++++- web/src/lib/api.ts | 5 +- web/src/pages/SettingsPage.tsx | 91 +++++++++++++++++-- 8 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 backend/apps/accounts/migrations/0012_user_last_read_announcement.py create mode 100644 web/src/components/AnnouncementModal.tsx diff --git a/backend/apps/accounts/migrations/0012_user_last_read_announcement.py b/backend/apps/accounts/migrations/0012_user_last_read_announcement.py new file mode 100644 index 0000000..487a835 --- /dev/null +++ b/backend/apps/accounts/migrations/0012_user_last_read_announcement.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-23 12:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0011_team_max_concurrent_tasks_user_spending_limit'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_read_announcement', + field=models.DateTimeField(blank=True, null=True, verbose_name='最后阅读公告时间'), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 3380135..e9c2fa2 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -59,6 +59,7 @@ class User(AbstractUser): spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)') must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码') disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源') + last_read_announcement = models.DateTimeField(null=True, blank=True, verbose_name='最后阅读公告时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 43da766..21813e0 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('video/tasks//favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'), # Public announcement path('announcement', views.announcement_view, name='announcement'), + path('announcement/read', views.announcement_read_view, name='announcement_read'), # ── Super Admin: Dashboard ── path('admin/stats', views.admin_stats_view, name='admin_stats'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 8d22f2e..fae7829 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -1939,11 +1939,28 @@ def admin_audit_logs_view(request): @api_view(['GET']) @permission_classes([IsAuthenticated]) def announcement_view(request): - """GET /api/v1/announcement — return active announcement for logged-in users.""" + """GET /api/v1/announcement — return active announcement + read status.""" config, _ = QuotaConfig.objects.get_or_create(pk=1) if config.announcement_enabled and config.announcement: - return Response({'announcement': config.announcement, 'enabled': True}) - return Response({'announcement': '', 'enabled': False}) + is_read = False + if request.user.is_authenticated and request.user.last_read_announcement: + is_read = request.user.last_read_announcement >= config.updated_at + return Response({ + 'announcement': config.announcement, + 'enabled': True, + 'is_read': is_read, + 'updated_at': config.updated_at.isoformat(), + }) + return Response({'announcement': '', 'enabled': False, 'is_read': True}) + + +@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}) # ────────────────────────────────────────────── diff --git a/web/src/components/AnnouncementModal.tsx b/web/src/components/AnnouncementModal.tsx new file mode 100644 index 0000000..89ff859 --- /dev/null +++ b/web/src/components/AnnouncementModal.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState, useCallback } from 'react'; +import { videoApi } from '../lib/api'; + +interface Props { + /** If true, force show even if already read (for manual open) */ + forceOpen?: boolean; + 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]); + + const handleClose = useCallback(() => { + videoApi.readAnnouncement().catch(() => {}); + setVisible(false); + onClose?.(); + }, [onClose]); + + if (!visible || !content) return null; + + return ( +
{ if (e.target === e.currentTarget) handleClose(); }} + > +
+
+ + 📢 公告 + + +
+
li{margin-left:16px}${content}` }} + /> +
+ +
+
+
+ ); +} diff --git a/web/src/components/VideoGenerationPage.tsx b/web/src/components/VideoGenerationPage.tsx index 333e770..e7c00e0 100644 --- a/web/src/components/VideoGenerationPage.tsx +++ b/web/src/components/VideoGenerationPage.tsx @@ -4,6 +4,7 @@ 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'; @@ -24,6 +25,8 @@ export function VideoGenerationPage() { const savedScrollTop = useGenerationStore((s) => s.savedScrollTop); const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition); const [detailTaskId, setDetailTaskId] = useState(null); + const [showAnnouncement, setShowAnnouncement] = useState(false); + const [autoAnnouncementDone, setAutoAnnouncementDone] = 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), []); @@ -120,7 +123,27 @@ export function VideoGenerationPage() {
- + {/* 公告已改为弹窗,旧的横幅不再显示 */} + {/* 右上角公告小喇叭 */} +
{tasks.length === 0 ? (
@@ -157,6 +180,14 @@ export function VideoGenerationPage() { onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])} onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])} /> + {/* 自动弹窗(首次未读)*/} + {!autoAnnouncementDone && ( + setAutoAnnouncementDone(true)} /> + )} + {/* 手动弹窗(点小喇叭)*/} + {showAnnouncement && ( + setShowAnnouncement(false)} /> + )}
); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 014df4f..5487f4e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -160,7 +160,10 @@ export const videoApi = { api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`), getAnnouncement: () => - api.get<{ announcement: string; enabled: boolean }>('/announcement'), + api.get<{ announcement: string; enabled: boolean; is_read: boolean; updated_at?: string }>('/announcement'), + + readAnnouncement: () => + api.post('/announcement/read'), }; // Admin APIs (Super Admin) diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 2b797c1..7ddd594 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { adminApi } from '../lib/api'; import type { SystemSettings } from '../types'; import { showToast } from '../components/Toast'; @@ -34,6 +34,8 @@ export function SettingsPage() { }); const [testingFeishu, setTestingFeishu] = useState(false); const [testingSms, setTestingSms] = useState(false); + const [previewAnnouncement, setPreviewAnnouncement] = useState(false); + const announcementRef = useRef(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -197,14 +199,85 @@ export function SettingsPage() {
- -