From e86e3d45b1b5ed6f60f1f99fbd66da7cc1866579 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 12 May 2026 18:14:38 +0800 Subject: [PATCH 01/26] =?UTF-8?q?fix(admin):=20=E4=B8=BB=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E6=92=A4=E9=94=80=20bug=20=E2=80=94=20TeamsPage=20?= =?UTF-8?q?=E4=B8=BB=E7=AE=A1=20badge=20=E5=8A=A0=20onClick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 L825 主管理员 badge 无 onClick,管理员之前把某成员设为主管后撤不掉,只能后台改 DB。 后端 admin_team_member_role_view 收到 is_team_admin=false 已支持同时清 is_team_owner。 前端补 onClick + confirm + 调 setMemberRole(false) 即可,后端不动。 Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/pages/TeamsPage.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/pages/TeamsPage.tsx b/web/src/pages/TeamsPage.tsx index 1745ed9..b139564 100644 --- a/web/src/pages/TeamsPage.tsx +++ b/web/src/pages/TeamsPage.tsx @@ -823,7 +823,15 @@ export function TeamsPage() { {m.email} {m.is_team_owner ? ( - 主管理员 + { + if (!window.confirm(`撤销 ${m.username} 的主管理员身份?\n确认后将变回普通成员。`)) return; + try { + // 后端 admin_team_member_role_view 收到 is_team_admin=false 会同时清 is_team_owner + await adminApi.setMemberRole(detailTeam!.id, m.id, false); + showToast('已撤销主管理员'); + const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); + } catch { showToast('操作失败'); } + }}>主管理员 ) : m.is_team_admin ? ( { try { From 72f351d54f95c2a633ef24c83919ded3071b7ca1 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 12 May 2026 18:17:16 +0800 Subject: [PATCH 02/26] =?UTF-8?q?feat(records):=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E5=8D=A1=E7=89=87/=E8=AF=A6=E6=83=85=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E7=94=A8=20thumbnail=5Furl=20=E6=98=BE=E7=A4=BA=E9=A6=96?= =?UTF-8?q?=E5=B8=A7=20poster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 GenerationRecord.thumbnail_url 字段早就被 tasks.py:_handle_completed (L109-111) 通过 ffmpeg 提取首帧 + 上传 TOS 填充,但只在 _serialize_task (生成页) 返回。 admin_records / team_records / profile_records 三个 view 都没回传,前端无从用。 后端:三个 records view 各加一行 'thumbnail_url': r.thumbnail_url or '' 前端: - types/index.ts AdminRecord 加 thumbnail_url?: string - 三处 ) : m.is_team_admin ? ( - { - try { - await adminApi.setMemberRole(detailTeam!.id, m.id, false); - showToast('已取消副管理员'); - const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); - } catch { showToast('操作失败'); } - }}>副管理员 + <> + { + try { + await adminApi.setMemberRole(detailTeam!.id, m.id, false); + showToast('已取消副管理员'); + const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); + } catch { showToast('操作失败'); } + }}>副管理员 + + ) : ( - { - try { - await adminApi.setMemberRole(detailTeam!.id, m.id, true); - showToast('已设为副管理员'); - const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); - } catch { showToast('操作失败'); } - }}>成员 + <> + { + try { + await adminApi.setMemberRole(detailTeam!.id, m.id, true); + showToast('已设为副管理员'); + const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); + } catch { showToast('操作失败'); } + }}>成员 + + )} From f77d30a4e67734ee5bf2bb00dcbe7e4ca0c3f1f8 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 12 May 2026 21:08:30 +0800 Subject: [PATCH 10/26] =?UTF-8?q?fix(admin):=20=E7=BF=BB=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9=20sticky=20=E8=B4=B4=E5=BA=95=20=E2=80=94=20?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E5=B0=8F=E5=B1=8F=E4=B8=8D=E7=94=A8?= =?UTF-8?q?=E6=BB=9A=E5=88=B0=E5=BA=95=E5=B0=B1=E8=83=BD=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.20.1 §I 只修了"能滚到能点到",用户期望是"始终可见不用滚"。 4 个 admin 管理页 .pagination 改 position: sticky + bottom: 0 + 背景遮挡, 翻页按钮固定在 .content 滚动容器视口底部,内容长度无关。 z-index: 10 确保 sticky 时压在表格行之上;background var(--color-bg-page) 覆盖透视下方内容(浅/深主题各自适应)。 涉及 4 个 page CSS:RecordsPage / UsersPage / LoginRecordsPage / AuditLogsPage Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/pages/AuditLogsPage.module.css | 10 +++++++++- web/src/pages/LoginRecordsPage.module.css | 10 +++++++++- web/src/pages/RecordsPage.module.css | 10 +++++++++- web/src/pages/UsersPage.module.css | 10 +++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/web/src/pages/AuditLogsPage.module.css b/web/src/pages/AuditLogsPage.module.css index aa517fc..1ffd56f 100644 --- a/web/src/pages/AuditLogsPage.module.css +++ b/web/src/pages/AuditLogsPage.module.css @@ -43,7 +43,15 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } +.pagination { + display: flex; justify-content: space-between; align-items: center; + /* sticky 贴底:翻页按钮始终可见 */ + position: sticky; bottom: 0; + margin-top: 16px; + padding: 8px 0 8px; + background: var(--color-bg-page); + z-index: 10; +} .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/LoginRecordsPage.module.css b/web/src/pages/LoginRecordsPage.module.css index 2c8b6f3..36eec2f 100644 --- a/web/src/pages/LoginRecordsPage.module.css +++ b/web/src/pages/LoginRecordsPage.module.css @@ -35,7 +35,15 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } +.pagination { + display: flex; justify-content: space-between; align-items: center; + /* sticky 贴底:翻页按钮始终可见 */ + position: sticky; bottom: 0; + margin-top: 16px; + padding: 8px 0 8px; + background: var(--color-bg-page); + z-index: 10; +} .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/RecordsPage.module.css b/web/src/pages/RecordsPage.module.css index d9670dc..3ac3d80 100644 --- a/web/src/pages/RecordsPage.module.css +++ b/web/src/pages/RecordsPage.module.css @@ -57,7 +57,15 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } +.pagination { + display: flex; justify-content: space-between; align-items: center; + /* sticky 贴底:翻页按钮始终可见,不用滚到最底才能点 */ + position: sticky; bottom: 0; + margin-top: 16px; + padding: 8px 0 8px; + background: var(--color-bg-page); + z-index: 10; +} .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/UsersPage.module.css b/web/src/pages/UsersPage.module.css index 31817b8..7249d9f 100644 --- a/web/src/pages/UsersPage.module.css +++ b/web/src/pages/UsersPage.module.css @@ -51,7 +51,15 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } +.pagination { + display: flex; justify-content: space-between; align-items: center; + /* sticky 贴底:翻页按钮始终可见 */ + position: sticky; bottom: 0; + margin-top: 16px; + padding: 8px 0 8px; + background: var(--color-bg-page); + z-index: 10; +} .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { From c54fdda0e83b9e92848ab32988e812031954ff5a Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 12 May 2026 21:42:36 +0800 Subject: [PATCH 11/26] =?UTF-8?q?revert(admin):=20=E6=92=A4=E6=8E=89=20sti?= =?UTF-8?q?cky=20=E7=BF=BB=E9=A1=B5=20=E2=80=94=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=20sticky=20=E8=AE=A9=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=9C=A8=E6=8C=89=E9=92=AE=E5=90=8E=E9=80=8F=E8=A7=86,?= =?UTF-8?q?=E4=B8=8D=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sticky bottom: 0 的语义是"natural 位置在视口底之下时贴底,否则正常坐落"。 当 .content 滚动容器下方还有内容时,翻页按钮浮在视口底部, 表格行从它身后滚过 — 视觉上"翻页按钮下面又有表格内容"非常违和。 恢复成普通 .pagination + padding-bottom 兜底, 依赖批次 I 的根因三件套(100dvh + min-height: 0)解决 Safari 翻页被工具栏遮挡: - 100dvh 保证 .layout 高度 = 用户实际可见区(不被 Safari 工具栏吃掉) - min-height: 0 保证 .content 内部能正常 overflow-y: auto 滚动 - padding-bottom: 8px 给按钮一点缓冲 用户期望就是"滚动条能滚到最底,翻页按钮可见可点",而不是"翻页按钮固定不动"。 Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/pages/AuditLogsPage.module.css | 10 +--------- web/src/pages/LoginRecordsPage.module.css | 10 +--------- web/src/pages/RecordsPage.module.css | 10 +--------- web/src/pages/UsersPage.module.css | 10 +--------- 4 files changed, 4 insertions(+), 36 deletions(-) diff --git a/web/src/pages/AuditLogsPage.module.css b/web/src/pages/AuditLogsPage.module.css index 1ffd56f..aa517fc 100644 --- a/web/src/pages/AuditLogsPage.module.css +++ b/web/src/pages/AuditLogsPage.module.css @@ -43,15 +43,7 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { - display: flex; justify-content: space-between; align-items: center; - /* sticky 贴底:翻页按钮始终可见 */ - position: sticky; bottom: 0; - margin-top: 16px; - padding: 8px 0 8px; - background: var(--color-bg-page); - z-index: 10; -} +.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/LoginRecordsPage.module.css b/web/src/pages/LoginRecordsPage.module.css index 36eec2f..2c8b6f3 100644 --- a/web/src/pages/LoginRecordsPage.module.css +++ b/web/src/pages/LoginRecordsPage.module.css @@ -35,15 +35,7 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { - display: flex; justify-content: space-between; align-items: center; - /* sticky 贴底:翻页按钮始终可见 */ - position: sticky; bottom: 0; - margin-top: 16px; - padding: 8px 0 8px; - background: var(--color-bg-page); - z-index: 10; -} +.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/RecordsPage.module.css b/web/src/pages/RecordsPage.module.css index 3ac3d80..d9670dc 100644 --- a/web/src/pages/RecordsPage.module.css +++ b/web/src/pages/RecordsPage.module.css @@ -57,15 +57,7 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { - display: flex; justify-content: space-between; align-items: center; - /* sticky 贴底:翻页按钮始终可见,不用滚到最底才能点 */ - position: sticky; bottom: 0; - margin-top: 16px; - padding: 8px 0 8px; - background: var(--color-bg-page); - z-index: 10; -} +.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { diff --git a/web/src/pages/UsersPage.module.css b/web/src/pages/UsersPage.module.css index 7249d9f..31817b8 100644 --- a/web/src/pages/UsersPage.module.css +++ b/web/src/pages/UsersPage.module.css @@ -51,15 +51,7 @@ .skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } -.pagination { - display: flex; justify-content: space-between; align-items: center; - /* sticky 贴底:翻页按钮始终可见 */ - position: sticky; bottom: 0; - margin-top: 16px; - padding: 8px 0 8px; - background: var(--color-bg-page); - z-index: 10; -} +.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; padding-bottom: 8px; } .pageInfo { color: var(--color-text-secondary); font-size: 13px; } .pageButtons { display: flex; gap: 4px; } .pageButtons button { From 7a503db814d27f2cf570647c029890a1f13109f3 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 15:57:48 +0800 Subject: [PATCH 12/26] =?UTF-8?q?feat(notification):=20=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E6=95=B4=E5=90=88=E8=BF=9B=20Notification=20=E2=80=94=20fan-ou?= =?UTF-8?q?t=20+=20=E5=BC=BA=E5=BC=B9=20Modal=20+=20chip=20+=20=E5=88=A0?= =?UTF-8?q?=20announcement=5Fenabled=20=E6=A6=82=E5=BF=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前公告(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 顶层挂 — 任意路由有未读公告强弹 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) --- backend/apps/generation/urls.py | 2 + backend/apps/generation/views.py | 83 +++++++++++++++---- backend/apps/notifications/models.py | 1 + web/src/App.tsx | 3 + web/src/components/AnnouncementModal.tsx | 51 ++++++------ web/src/components/GlobalAnnouncementGate.tsx | 60 ++++++++++++++ web/src/components/VideoGenerationPage.tsx | 36 +------- web/src/lib/api.ts | 6 ++ web/src/pages/NotificationsPage.tsx | 33 +++++++- web/src/pages/SettingsPage.tsx | 34 ++++---- web/src/types/index.ts | 1 + 11 files changed, 215 insertions(+), 95 deletions(-) create mode 100644 web/src/components/GlobalAnnouncementGate.tsx diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index e4a3c65..8a5ca45 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -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'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index b4c857b..39f578b 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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": "..." } + + 所有用户(含封禁,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)} 个用户'}) # ────────────────────────────────────────────── diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py index 7bcf4e3..329d67e 100644 --- a/backend/apps/notifications/models.py +++ b/backend/apps/notifications/models.py @@ -9,6 +9,7 @@ class Notification(models.Model): ('anomaly_disabled_user', '账号因异常被自动封禁'), ('anomaly_disabled_team', '团队因异常被自动封禁'), ('quota_warning', '额度即将耗尽'), + ('announcement', '系统公告'), ('system', '系统通知'), ] diff --git a/web/src/App.tsx b/web/src/App.tsx index 135a577..333a889 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { + {/* 全局公告 — 任意路由有未读公告就强弹 modal,必须看 */} + } /> } /> diff --git a/web/src/components/AnnouncementModal.tsx b/web/src/components/AnnouncementModal.tsx index c1d8a63..e37234e 100644 --- a/web/src/components/AnnouncementModal.tsx +++ b/web/src/components/AnnouncementModal.tsx @@ -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 — 纯展示组件。 + * + * 数据来源 + 自动弹时机 + 标记已读 全部由 统一处理, + * 本组件只负责渲染。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 ( -
{ if (e.target === e.currentTarget) handleClose(); }}> +
{ if (e.target === e.currentTarget) handleClose(); }} + >
公告 -
li{margin-left:16px}${content}` }} + dangerouslySetInnerHTML={{ __html: `${safeHtml}` }} />
+ {/* 旧的右上角公告小喇叭已删 — 公告统一走 sidebar 大铃铛 → 消息中心 */}
{tasks.length === 0 ? (
@@ -221,14 +197,8 @@ 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)} /> - )} + {/* 公告弹窗已搬到 App.tsx 顶层 GlobalAnnouncementGate(任意路由有未读公告自动强弹) */} + {/* 重看历史公告 → 走 sidebar 大铃铛 → 消息中心页 */}
); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index af576a9..5cb0500 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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('/admin/stats'), diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index 0232349..fbde682 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -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 = { + 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 (
- {item.title} +
+ + {chip.text} + + {item.title} +
{formatRelative(item.created_at)}
-
{item.content}
+ {isAnnouncement ? ( +
+ ) : ( +
{item.content}
+ )}
); diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1d4b80b..61cf0d3 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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() {

系统公告

-

启用后公告将展示在用户端页面顶部

+

编辑内容后点【发送公告】→ 一次性发给所有用户(含被封禁,解封后能看到);所有人打开页面会强制弹出未读公告必须看完

-
@@ -338,8 +338,8 @@ export function SettingsPage() { /> )}
-
diff --git a/web/src/types/index.ts b/web/src/types/index.ts index b9afe70..9c4541e 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -476,6 +476,7 @@ export type NotificationType = | 'anomaly_disabled_user' | 'anomaly_disabled_team' | 'quota_warning' + | 'announcement' | 'system'; // 用 AppNotification 命名,避免与浏览器内置的 Notification Web API 冲突 From 850acf646ea982d83d237b76c8ea2fad97789863 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 15:59:38 +0800 Subject: [PATCH 13/26] =?UTF-8?q?refactor(notification):=20=E5=88=A0=20Ann?= =?UTF-8?q?ouncementBanner=20=E5=BA=9F=E5=BC=83=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnnouncementBanner 在 v0.12.6 公告改 modal 之后就已经废弃了, VideoGenerationPage L148 注释明确写"公告已改为弹窗,旧的横幅不再显示", 现在公告整合到 Notification 表后,无任何 import 引用,清理。 同步删 AnnouncementBanner.module.css 配套样式。 typecheck 0 error,smoke 全过。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/AnnouncementBanner.module.css | 81 ------------------- web/src/components/AnnouncementBanner.tsx | 34 -------- 2 files changed, 115 deletions(-) delete mode 100644 web/src/components/AnnouncementBanner.module.css delete mode 100644 web/src/components/AnnouncementBanner.tsx diff --git a/web/src/components/AnnouncementBanner.module.css b/web/src/components/AnnouncementBanner.module.css deleted file mode 100644 index 36c1f59..0000000 --- a/web/src/components/AnnouncementBanner.module.css +++ /dev/null @@ -1,81 +0,0 @@ -.banner { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - /* 深色 - 紫青渐变玻璃 */ - background: linear-gradient(90deg, rgba(108, 99, 255, 0.10), rgba(0, 184, 230, 0.08)); - border-left: 3px solid var(--color-primary); - border-bottom: 1px solid var(--color-border-soft); - backdrop-filter: var(--bf-glass-md); - -webkit-backdrop-filter: var(--bf-glass-md); - box-shadow: inset 0 1px 0 var(--color-inset-highlight); - font-size: 13px; - color: var(--color-text-primary); - line-height: 1.5; - flex-shrink: 0; -} - -[data-theme="light"] .banner { - /* 浅色 - 暖米色 chip */ - background: var(--color-chip-warm-bg); - border-left-color: var(--color-chip-warm-border); - border-bottom-color: var(--color-chip-warm-border); - color: var(--color-chip-warm-text); -} - -[data-theme="light"] .icon { - color: var(--color-chip-warm-badge-text); -} - -.icon { - flex-shrink: 0; - color: var(--color-primary); -} - -.marqueeWrapper { - flex: 1; - overflow: hidden; - position: relative; - mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent); - -webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent); -} - -.marqueeText { - display: inline-block; - white-space: nowrap; - animation: marquee 20s linear infinite; - padding-left: 100%; -} - -.marqueeWrapper:hover .marqueeText { - animation-play-state: paused; -} - -@keyframes marquee { - 0% { - transform: translateX(0); - } - 100% { - transform: translateX(-100%); - } -} - -.closeBtn { - flex-shrink: 0; - background: none; - border: none; - color: var(--color-text-secondary); - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: all 0.15s; -} - -.closeBtn:hover { - color: var(--color-text-primary); - background: var(--color-bg-hover); -} diff --git a/web/src/components/AnnouncementBanner.tsx b/web/src/components/AnnouncementBanner.tsx deleted file mode 100644 index b56735b..0000000 --- a/web/src/components/AnnouncementBanner.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect, useState } from 'react'; -import { videoApi } from '../lib/api'; -import styles from './AnnouncementBanner.module.css'; - -export function AnnouncementBanner() { - const [text, setText] = useState(''); - const [dismissed, setDismissed] = useState(false); - - useEffect(() => { - videoApi.getAnnouncement().then(({ data }) => { - if (data.enabled && data.announcement) { - setText(data.announcement); - } - }).catch(() => {}); - }, []); - - if (!text || dismissed) return null; - - return ( -
- - - -
- {text} -
- -
- ); -} From e55a6665f25efe05abe33ac05997196dd9a246fe Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 16:04:43 +0800 Subject: [PATCH 14/26] =?UTF-8?q?test+docs(notification):=20announcement-i?= =?UTF-8?q?ntegration-smoke.mjs=2010/10=20+=20plan=20=E5=BD=92=E6=A1=A3=20?= =?UTF-8?q?+=20=E5=AE=8C=E6=88=90=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 web/test/announcement-integration-smoke.mjs 10 项 E2E: - 空内容 400 / HTML 发送 200 / fan-out 数 = User 总数 - tudou 拿到未读 / 浏览器自动强弹 modal / 关闭标已读 / 再开不弹 - 消息中心 [公告] chip + HTML 渲染 / 无 console.error 跑通基线对比: - vitest 71 fail / 162 pass (0 新增回归) - v2-smoke 25/25 + modal-interaction 8/8 + v0.20.1-smoke 11/11 - 新 announcement-integration-smoke 10/10 归档 plan 文件 docs/todo/通知公告整合.md(v2,本次实施的源 plan)。 写完成报告 docs/todo/通知公告整合-完成报告.md(改动文件清单 + 3 commit hash + 测试结果 + 关键设计决策 + 边缘 case 处理)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/todo/通知公告整合-完成报告.md | 183 +++++++++++++ docs/todo/通知公告整合.md | 281 ++++++++++++++++++++ web/test/announcement-integration-smoke.mjs | 205 ++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 docs/todo/通知公告整合-完成报告.md create mode 100644 docs/todo/通知公告整合.md create mode 100644 web/test/announcement-integration-smoke.mjs diff --git a/docs/todo/通知公告整合-完成报告.md b/docs/todo/通知公告整合-完成报告.md new file mode 100644 index 0000000..707c4ed --- /dev/null +++ b/docs/todo/通知公告整合-完成报告.md @@ -0,0 +1,183 @@ +# 通知 / 公告整合 — 完成报告 + +**完成日期**: 2026-05-15 +**分支**: dev(3 个 commit + plan/报告 docs) +**push 状态**: 本地完成,等用户授权 push + +--- + +## 一、改动总览 + +| # | Commit | 简述 | +|---|--------|------| +| 1 | `7a503db` | feat(notification): 公告整合进 Notification — fan-out + 强弹 Modal + chip + 删 announcement_enabled 概念 | +| 2 | `850acf6` | refactor(notification): 删 AnnouncementBanner 废弃文件 | +| 3 | (即将) | test+docs: announcement-integration-smoke.mjs + plan 归档 + 完成报告 | + +--- + +## 二、最终行为(对齐用户) + +1. 超管在系统设置编辑公告内容(rich text + 6 个工具按钮 + 预览) +2. 编辑完点【**发送公告**】独立按钮 → 二次 confirm "确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回" +3. 后端 fan-out 给**所有用户**(含 `is_active=False` 封禁用户,解封后能看到累积历史) +4. 任意用户登录 / 打开任意路由 → `` 顶层组件 fetch 未读公告 → **强制弹 modal,必须看** +5. 关闭 modal(点【我知道了】/ ✕ / 遮罩点击)= 标已读 → 不再弹 +6. 想重看 → sidebar 大铃铛 → 消息中心 → 找到那条公告点开(HTML 渲染) +7. 消息中心列表每条带 type chip: [公告] / [安全] / [额度] / [系统] 4 色 +8. 删除右上角小喇叭、删除 `announcement_enabled` 开关(发了就强弹,无静默模式) + +--- + +## 三、改动文件 + +### 后端 (3 modified) +- `backend/apps/notifications/models.py` — TYPE_CHOICES 加 `'announcement'` +- `backend/apps/generation/views.py` — 新增 `admin_publish_announcement_view` + 重写 `announcement_view` / `announcement_read_view`(内部全部走 Notification 表) +- `backend/apps/generation/urls.py` — 新路由 `POST /api/v1/admin/announcement/publish` + +### 前端 (7 modified + 1 new) +- `web/src/App.tsx` — 顶层挂 `` +- `web/src/components/GlobalAnnouncementGate.tsx` — **新建**,负责"任意路由检测 + 强弹 + 标已读 + 同步铃铛" +- `web/src/components/AnnouncementModal.tsx` — 改纯展示组件(props 接 content + onClose,不自己 fetch),HTML 用 DOMPurify +- `web/src/components/VideoGenerationPage.tsx` — 删右上角小喇叭 + 旧 AnnouncementModal 自动弹 + 旧 AnnouncementBanner import +- `web/src/lib/api.ts` — 加 `announcementApi.publish(content)` +- `web/src/pages/SettingsPage.tsx` — 删 announcement_enabled checkbox + 加【发送公告】按钮 + 二次 confirm +- `web/src/pages/NotificationsPage.tsx` — 每条加 type chip + announcement HTML 渲染(DOMPurify) +- `web/src/types/index.ts` — `NotificationType` 加 `'announcement'` + +### 删除 (2 files) +- `web/src/components/AnnouncementBanner.tsx`(废弃,无引用) +- `web/src/components/AnnouncementBanner.module.css` + +### 测试 + 文档 (3 new) +- `web/test/announcement-integration-smoke.mjs` — 10 项 E2E +- `docs/todo/通知公告整合.md` — 整合 plan v2 +- `docs/todo/通知公告整合-完成报告.md` — 本报告 + +--- + +## 四、关键设计决策 + +### 1. 强弹位置:全局顶层 vs 各路由各自挂 + +**选**:全局 `` 在 `App.tsx` `` 之外。 +- 所有路由统一行为 +- 不需要每个 page 都加 `` +- 跨路由切换时如果未读还在,继续弹(行为一致) + +### 2. fan-out 范围:含封禁用户 + +**选**:`User.objects.all()` 而非 `filter(is_active=True)`。 +- 封禁用户解封后能看到累积的历史公告 +- bulk_create + 索引 `(recipient, is_read, -created_at)` 性能足够(150 用户 / 1 年 50 公告 = 7500 行可忽略) +- 用户不会因为"封禁期间错过通知"而抱怨 + +### 3. 发布触发:独立按钮 vs 整体保存 + +**选**:独立【发送公告】按钮 + 二次 confirm,不和"保存设置"按钮共用。 +- 防误操作 — 超管改配额时不会顺带触发公告 fan-out +- 用户明确表达"我点发就直接发了"的明确动作语义 + +### 4. announcement_enabled 字段保留 vs 删除 + +**选**:字段保留(QuotaConfig 不动 schema)但前端不再读。 +- 避免 migration +- 后端发送时仍写 `announcement_enabled=True`,作为"最近一次发送过"的标记 +- 真实判断 fan-out 走的是 `Notification.is_read`,不再依赖这个 flag + +### 5. HTML 安全:DOMPurify 而非禁 HTML + +**选**:Modal 和 NotificationsPage 两处都用 `DOMPurify.sanitize(content)`。 +- 保留 HTML 富文本能力(超管可加粗/红字/链接) +- 用 DOMPurify 防 XSS + +### 6. 老 endpoint 兼容 + +**选**:`/announcement` + `/announcement/read` 保留 endpoint,内部改走 Notification 表,响应结构不变。 +- 老前端 PWA 缓存平滑过渡 +- 后续可以无痛删 endpoint(本次不删) + +--- + +## 五、测试结果 + +### 后端 curl 验证(5 项全过) + +| Case | 期望 | 结果 | +|------|------|------| +| 空内容发送 | 400 "公告内容不能为空" | ✓ | +| HTML 发送 | 200 sent_to=N | ✓ (sent_to=21) | +| tudou GET /announcement | 拿到那条 HTML + is_read=false + notification_id | ✓ | +| tudou POST /announcement/read | updated≥1 | ✓ (updated=1) | +| tudou 再 GET | enabled=false + is_read=true | ✓ | + +### 前端 smoke 全过 + +| 测试 | 基线 | 整合后 | 状态 | +|------|------|--------|------| +| `npx tsc -b` | 0 error | 0 error | ✓ | +| `npx vitest run` | 71 fail / 162 pass | 71 fail / 162 pass | ✓ 0 新增回归 | +| `web/test/v2-smoke.mjs` | 25/25 | 25/25 | ✓ | +| `web/test/modal-interaction.mjs` | 8/8 | 8/8 | ✓ | +| `web/test/v0.20.1-smoke.mjs` | 11/11 | 11/11 | ✓ | +| `web/test/announcement-integration-smoke.mjs` | 新增 | **10/10** | ✓ | + +### 新 smoke 覆盖 10 项 + +1. 空内容发送返回 400 +2. HTML 发送成功 sent_to=N +3. fan-out 数 = User 总数(21=21) +4. tudou GET /announcement 拿到未读公告 +5. tudou 浏览器进 /app **自动强弹公告 modal** +6. 关闭 modal 后 GET /announcement 返回已读 +6.1 再开页面不再弹 modal +7. 消息中心显示 [公告] chip +7.1 公告内容 HTML 渲染正常 +8. 全程无 console.error + +--- + +## 六、边缘 case 处理 + +| Case | 处理 | +|------|------| +| 超管反复编辑公告但不点【发送】 | textarea 内容只保存在本地 state,刷新页面会重置到上次发布的内容(QuotaConfig.announcement)— 接受 | +| 超管点【发送】两次 | fan-out 两次,所有用户消息中心多两条记录 — UI 二次 confirm 防误点 | +| 公告内容为空点【发送】 | 前端先校验 + 后端 400 兜底,双重保护 | +| 用户在路由 A 弹 modal 没关 → 切到 B | modal 跟随全局,在 B 继续显示。关闭前任何路由都被遮住 | +| 同时多条未读公告 | 显示最新一条;关闭后下次 visibilitychange 再 check 又拿到下一条;依次到都读完 | +| 封禁用户公告 | 发出去时也 bulk_create 给他;封禁状态登不上来看不到,解封后能看到累积 | +| 老前端 PWA 缓存调 `/announcement` | endpoint 响应结构兼容,平滑 | + +--- + +## 七、本地状态 + +- **分支**: dev +- **commit 数**: 2 个功能 commit + 1 个 docs 收尾(下一步) +- **未 push**: 等用户授权 +- **DB migration**: 无(TYPE_CHOICES 是 choices 软约束,DB schema 不变) + +--- + +## 八、待用户做 + +1. **本地手测**(可选,smoke 已覆盖关键链路)— 浏览器跑一遍: + - admin 系统设置写公告 → 点【发送公告】→ 二次确认 → 提示 "已发送给 N 个用户" + - 退出 admin → 用 tudou 登录 → 任何页面应该被 modal 挡住 + - 点【我知道了】→ modal 关闭 + sidebar 铃铛红点同步 + - 进消息中心 → 看到刚发的公告 + [公告] chip + HTML 渲染 +2. **授权 push** — 等"可以 push"指令,跑 `ALLOW_PUSH=1 git push origin dev`(memory `feedback_must_confirm_push`) +3. **测试服跑通后** — 合 master 部署生产(单独授权) + +--- + +## 九、风险与已知限制 + +| 风险 | 缓解 | +|------|------| +| Notification 表 announcement 类条目随时间膨胀 | 现有索引 `(recipient, is_read, -created_at)` 覆盖查询;未来加 90 天软清理 | +| 超管打开页面也会被自己刚发的公告挡 | 接受 — 超管也是用户,看一次就行;关掉就不再弹 | +| 新注册用户看不到历史公告 | 接受 — 公告时效性强,新人不补发 | +| HTML 内容超长 modal 滚动 | AnnouncementModal CSS 已有 max-height + overflow-y(继承自原实现)| diff --git a/docs/todo/通知公告整合.md b/docs/todo/通知公告整合.md new file mode 100644 index 0000000..59c5b66 --- /dev/null +++ b/docs/todo/通知公告整合.md @@ -0,0 +1,281 @@ +# 通知 / 公告整合 plan(v2) + +**起因**:v0.20.1 消息中心(Notification)上线后跟公告(QuotaConfig.announcement)UX 重叠 — 两个铃铛、两套未读、两套入口。 + +**目标**:统一到消息中心,公告作为 `type='announcement'` 的 Notification。 + +**预估**:约 1.5 小时 + +--- + +## 一、最终行为(对齐用户) + +1. 超管在系统设置编辑公告内容(rich text) +2. 编辑完点【**发送公告**】独立按钮 → fan-out 给**所有用户**(含封禁用户) +3. 任意用户登录 / 打开任意页面 → 有未读 announcement 就**强制弹 modal,必须看**(关闭才能用页面) +4. 关闭 = 标已读,不再弹 +5. 想重看 → sidebar 大铃铛 → 消息中心 → 找到那条公告 +6. 消息中心列表每条带 type chip([公告] / [安全] / [额度] / [系统]) +7. 删除右上角小喇叭、删除 `announcement_enabled` 开关概念(发了就强弹,无静默模式) + +--- + +## 二、后端改动 + +### 1. `apps/notifications/models.py` TYPE_CHOICES 加项 + +```python +TYPE_CHOICES = [ + ('anomaly_disabled_user', '账号因异常被自动封禁'), + ('anomaly_disabled_team', '团队因异常被自动封禁'), + ('quota_warning', '额度即将耗尽'), + ('announcement', '系统公告'), # 新增 + ('system', '系统通知'), +] +``` + +无 migration(CharField choices 软约束)。 + +### 2. `apps/generation/views.py` 新 endpoint + +```python +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_publish_announcement_view(request): + """POST /api/v1/admin/announcement/publish — 发送公告给所有用户。 + + Body: { "content": "..." } + """ + from apps.notifications.models import Notification + from apps.accounts.models import User as UserModel + + content = (request.data.get('content') or '').strip() + if not content: + return Response({'error': '公告内容不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 把最新公告也写回 QuotaConfig.announcement 当存档(超管下次编辑时能看到上次发的) + config = QuotaConfig.objects.get(pk=1) + config.announcement = content + config.announcement_enabled = True # 字段保留兼容,但前端不再读 + config.save(update_fields=['announcement', 'announcement_enabled']) + + # fan-out — 所有用户(含封禁,封禁用户解封后能看到历史) + user_ids = list(UserModel.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, 'announcement_publish', 'system', + after={'recipients': len(notifs), 'content_preview': content[:80]}) + return Response({'sent_to': len(notifs)}) +``` + +URL 加路由:`path('admin/announcement/publish', views.admin_publish_announcement_view, ...)`。 + +### 3. `apps/generation/views.py` `admin_settings_view` 改 + +整体 `PUT /admin/settings` **保留** announcement / announcement_enabled 字段读写(让超管编辑器有数据来源),但**不再 fan-out**。fan-out 唯一入口是 `/admin/announcement/publish`。 + +### 4. 老 endpoint 处理 + +- `GET /api/v1/announcement` — 重写,返回当前用户**最新未读公告**(从 Notification 表),给老 AnnouncementModal 用 +- `POST /api/v1/announcement/read` — 重写,标记当前用户所有 type=announcement 未读为已读 +- 都保留 endpoint 兼容,内部走 Notification + +```python +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def announcement_view(request): + 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': '', 'enabled': False, 'is_read': True, 'notification_id': None}) + return Response({ + 'announcement': latest.content, + 'enabled': True, + 'is_read': False, + 'notification_id': latest.id, + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def announcement_read_view(request): + from apps.notifications.models import Notification + Notification.objects.filter( + recipient=request.user, type='announcement', is_read=False + ).update(is_read=True) + return Response({'success': True}) +``` + +--- + +## 三、前端改动 + +### 1. `SettingsPage.tsx` 公告编辑区改 + +现状:公告内容编辑器 + announcement_enabled checkbox + 跟其他设置一起"保存"。 + +改成: +- 删 announcement_enabled checkbox(不再有静默概念) +- 公告编辑器下面加一个独立的【**发送公告**】按钮 +- 按钮点击 → confirm "确认发送给所有用户?发送后所有人打开页面会强制看到" → 确认 → `POST /admin/announcement/publish { content }` → 提示"已发送给 N 个用户" +- 整体"保存"按钮**不再**改 announcement(避免误发)— 或者保留但只存草稿不触发 fan-out + +UX 说明:超管自己心里要清楚 — 编辑公告内容 = 写草稿,点【发送公告】= 真正发出。 + +### 2. **全局** AnnouncementModal(强弹核心) + +现状:`AnnouncementModal` 只在 VideoGenerationPage L226-230 渲染,只在生成页弹。 + +改成:**移到全局**(App.tsx ProtectedRoute 内部 或 顶层 layout),所有登录用户在任意页面都会触发。 + +实现: +- 在 App.tsx 顶层挂 `` 组件 +- 组件内 `useEffect`:登录后 / 路由变化时 / focus 回来时 → 调 `GET /announcement` → 有未读 → setShow(true) +- 弹出 modal,用户必须点关闭 → `POST /announcement/read` → setShow(false) +- 关闭后调 `useNotificationStore.getState().fetchUnreadCount()` 同步铃铛红点 + +代码骨架: +```tsx +function GlobalAnnouncementGate() { + const user = useAuthStore((s) => s.user); + const [unread, setUnread] = useState<{ content: string; notification_id: number } | null>(null); + useEffect(() => { + if (!user) return; + const check = async () => { + try { + const { data } = await api.get('/announcement'); + if (data.enabled && data.announcement && !data.is_read) { + setUnread({ content: data.announcement, notification_id: data.notification_id }); + } + } catch {} + }; + check(); + const onVis = () => { if (!document.hidden) check(); }; + document.addEventListener('visibilitychange', onVis); + return () => document.removeEventListener('visibilitychange', onVis); + }, [user]); + if (!unread) return null; + return ( + { + await api.post('/announcement/read'); + setUnread(null); + useNotificationStore.getState().fetchUnreadCount(); + }} + /> + ); +} +``` + +`AnnouncementModal.tsx` 改成纯展示组件,数据从 prop 传入(`content`),不再自己 fetch。 + +### 3. 删除 `VideoGenerationPage.tsx` 右上角小喇叭 + +L150-165 整段 button 删掉,连带 `showAnnouncement` state 和那个 `` 重看入口删掉(重看走消息中心)。 + +### 4. `NotificationsPage.tsx` 每条加 type chip + +```tsx +const TYPE_LABEL: Record = { + announcement: { text: '公告', color: 'var(--color-info)' }, + anomaly_disabled_user: { text: '安全', color: 'var(--color-danger)' }, + anomaly_disabled_team: { text: '安全', color: 'var(--color-danger)' }, + quota_warning: { text: '额度', color: 'var(--color-warning)' }, + system: { text: '系统', color: 'var(--color-text-tertiary)' }, +}; +// 在 title 旁边渲染 [{TYPE_LABEL[n.type].text}] +``` + +announcement 类型的 content 用 DOMPurify + `dangerouslySetInnerHTML` 渲染(其他 type 用纯文本)。 + +### 5. 删除废弃文件 + +- `web/src/components/AnnouncementBanner.tsx`(已废弃,grep 确认无 import 后 rm) + +--- + +## 四、边缘 case + +| case | 处理 | +|------|------| +| 公告发出后封禁某用户 | 该用户解封后能看到历史公告(因为 fan-out 时也发给了他) ✓ | +| 新用户注册 | 看不到老公告(只 fan-out 给发布时存在的 user)— 可接受,公告时效性强 | +| 超管点【发送公告】两次 | fan-out 两次,所有用户消息中心多两条记录 — 超管自己负责,UI 加二次 confirm 防误点 | +| 公告内容为空点【发送】 | 400 报错"公告内容不能为空" | +| 用户在路由 A 弹 modal 没关 → 切到 B | modal 跟随全局,在 B 继续显示。关闭前任何路由都被遮住 | +| 同时多条未读公告 | 显示最新一条;关闭后下次 check 再显示下一条;一直到都读完 | +| 老前端 PWA 缓存调 `/announcement` | endpoint 兼容,返回结构不变 | + +--- + +## 五、测试 + +新建 `web/test/announcement-integration-smoke.mjs`(~7 项): +1. admin 登录 → 系统设置 → 写公告"测试 v1" → 点【发送公告】→ 二次 confirm → 提示"已发送给 N 个用户" +2. 后端 Notification 表 fan-out ≥150 条 type=announcement +3. tudou 登录 → 任意路由(/app)→ **自动弹 modal** 显示公告 HTML 内容 +4. 点关闭 → 标已读 → 再刷新 /app **不再弹** +5. sidebar 铃铛红点同步 -1 +6. 进消息中心 → 看到这条公告 + 蓝色 [公告] chip + HTML 渲染 +7. admin 重新点【发送公告】(改了内容)→ tudou 再开页面又弹新公告 + +回归: +- tsc / vitest 71 162 / v0.20.1-smoke 11 / v2-smoke 25 / modal-interaction 8 都过 + +--- + +## 六、Commit 策略 + +3 个 commit: +1. `feat(notification): 公告整合进 Notification — fan-out + 强弹 Modal + chip + 删 announcement_enabled 概念` + - 后端:新 `/admin/announcement/publish` endpoint + TYPE_CHOICES + 重写 announcement_view/read_view + - 前端:GlobalAnnouncementGate 全局挂载 + AnnouncementModal 改纯展示 + SettingsPage 公告区加【发送】按钮删 checkbox + NotificationsPage chip + DOMPurify +2. `refactor(notification): 删 VideoGenerationPage 公告小喇叭 + AnnouncementBanner 废弃文件` +3. `test+docs: announcement-integration-smoke.mjs + 整合 plan 归档` + +--- + +## 七、Critical Files + +修改: +- `backend/apps/notifications/models.py` — TYPE_CHOICES 加 announcement +- `backend/apps/generation/views.py` — 新增 `admin_publish_announcement_view` + 重写 `announcement_view` + `announcement_read_view` +- `backend/apps/generation/urls.py` — 加路由 +- `web/src/App.tsx` — 挂 `` +- `web/src/components/AnnouncementModal.tsx` — 改纯展示组件(props 接 content,不自己 fetch) +- `web/src/components/VideoGenerationPage.tsx` — 删小喇叭 button + 删原 AnnouncementModal 调用 +- `web/src/pages/SettingsPage.tsx` — 删 announcement_enabled checkbox + 加【发送公告】按钮 + 二次 confirm +- `web/src/pages/NotificationsPage.tsx` — 每条加 type chip + announcement HTML 渲染 + +新建: +- `web/src/components/GlobalAnnouncementGate.tsx`(或直接挂在 App.tsx 内部 function) +- `web/test/announcement-integration-smoke.mjs` + +删除: +- `web/src/components/AnnouncementBanner.tsx` + +不动: +- `QuotaConfig.announcement` / `announcement_enabled` 字段(保留作为编辑数据源) +- `User.last_read_announcement` 字段(免 migration,后续可清理) +- `apps/notifications/` 主结构(已就绪) +- Sidebar 大铃铛(已就绪,自然包含公告未读) + +--- + +## 八、用户拍板顺序 + +- 本 plan ✓(就是这版) +- 写代码 + 测试 + 本地验证 ✅ 我自主做 +- 推 dev → **要授权** +- 测试服跑通后合 master → **要授权** diff --git a/web/test/announcement-integration-smoke.mjs b/web/test/announcement-integration-smoke.mjs new file mode 100644 index 0000000..abb9e20 --- /dev/null +++ b/web/test/announcement-integration-smoke.mjs @@ -0,0 +1,205 @@ +/** + * 通知 / 公告整合 smoke test — + * + * 覆盖: + * 1. admin POST /admin/announcement/publish (空内容 → 400) + * 2. admin POST /admin/announcement/publish (HTML 内容 → 200 sent_to=N) + * 3. 后端 DB:fan-out 数等于 User 总数(active+inactive 都收到) + * 4. tudou GET /announcement → 拿到那条 HTML + * 5. tudou 浏览器进 /app 应自动强弹 modal(GlobalAnnouncementGate) + * 6. 关闭 modal → POST /announcement/read → 再开页面不弹 + * 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染) + * + * 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/tudoupass123 可登录。 + * 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。 + */ +import { chromium } from '@playwright/test'; + +const BASE = 'http://localhost:5173'; +const API = 'http://localhost:8000'; + +const results = []; +function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); } +function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); } + +async function login(page, username, password) { + const res = await page.request.post(`${API}/api/v1/auth/login`, { + data: { username, password }, + }); + if (!res.ok()) throw new Error(`登录失败 ${username}: ${res.status()}`); + const body = await res.json(); + return { token: body?.tokens?.access, user: body?.user }; +} + +async function setStorage(page, { token, refresh, user }) { + await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(({ access, r, u }) => { + localStorage.setItem('access_token', access); + if (r) localStorage.setItem('refresh_token', r); + if (u) localStorage.setItem('user', JSON.stringify(u)); + }, { access: token, r: refresh, u: user }); +} + +async function clearAllUnreadAnnouncements(adminToken) { + // 没有专用清理 endpoint,用 admin 自己的 read-all 跑一遍(只清自己的); + // 其他用户的未读靠 tudou 的 read-all 单独清。这里只清 admin 自己。 + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminToken}` }, + }); +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + const consoleErrors = []; + page.on('console', (m) => { + if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) { + consoleErrors.push(m.text()); + } + }); + // 接受 confirm 弹窗(发送公告二次确认) + page.on('dialog', (d) => d.accept()); + + console.log('\n════ 公告整合 smoke ════'); + + // 前置:登录拿 token + let adminTok, tudouTok; + try { + const a = await login(page, 'admin', 'admin123'); + const t = await login(page, 'tudou', 'tudoupass123'); + adminTok = a.token; tudouTok = t.token; + if (!adminTok || !tudouTok) throw new Error('token 空'); + } catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; } + + // 先清掉 tudou 可能的旧未读公告 + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + + // ── 1. 空内容 400 ── + try { + const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ content: '' }), + }); + if (r.status === 400) pass('1. 空内容发送返回 400'); + else fail('1. 空内容', new Error(`期望 400 实际 ${r.status}`)); + } catch (e) { fail('1. 空内容', e); } + + // ── 2. HTML 发送返回 200 + sent_to ── + const testContent = `

smoke 测试公告 ${Date.now()} - 请忽略

`; + let sentTo = 0; + try { + const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ content: testContent }), + }); + const data = await r.json(); + sentTo = data.sent_to; + if (r.status === 200 && sentTo > 0) pass(`2. HTML 发送成功 sent_to=${sentTo}`); + else fail('2. HTML 发送', new Error(`期望 200+sent_to>0 实际 ${r.status} ${JSON.stringify(data)}`)); + } catch (e) { fail('2. HTML 发送', e); } + + // ── 3. fan-out 数 = User 总数(用 admin 计:GET /admin/users total 应 ≈ sent_to) ── + try { + const r = await fetch(`${API}/api/v1/admin/users?page_size=1`, { + headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + const data = await r.json(); + if (data.total === sentTo) pass(`3. fan-out 数 (${sentTo}) = User 总数 (${data.total})`); + else fail('3. fan-out 数', new Error(`sent_to=${sentTo} vs admin/users.total=${data.total}`)); + } catch (e) { fail('3. fan-out 数', e); } + + // ── 4. tudou GET /announcement 拿到那条 ── + try { + const r = await fetch(`${API}/api/v1/announcement`, { + headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + const data = await r.json(); + if (data.enabled && data.announcement.includes('smoke 测试公告')) { + pass('4. tudou 拿到未读公告'); + } else { + fail('4. tudou 未读公告', new Error(`enabled=${data.enabled} content=${data.announcement.slice(0, 40)}`)); + } + } catch (e) { fail('4. tudou GET /announcement', e); } + + // ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ── + const tudouLogin = await login(page, 'tudou', 'tudoupass123'); + await setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user }); + await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染 + try { + const modalVisible = await page.locator('text=我知道了').isVisible({ timeout: 3000 }); + if (modalVisible) pass('5. tudou 进 /app 自动强弹公告 modal'); + else fail('5. 自动强弹', new Error('未找到"我知道了"按钮 — modal 没弹')); + } catch (e) { fail('5. 自动强弹', e); } + + // ── 6. 关闭 modal → 标已读 → 再开页面不弹 ── + try { + await page.locator('button:has-text("我知道了")').click({ timeout: 3000 }); + await page.waitForTimeout(1500); // 等 POST /announcement/read 完成 + // 验证后端已标读 + const r = await fetch(`${API}/api/v1/announcement`, { + headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + const data = await r.json(); + if (!data.enabled) pass('6. 关闭 modal 后 GET /announcement 返回 enabled=false (已读)'); + else fail('6. 关闭后未读取', new Error(`期望 enabled=false 实际 ${data.enabled}`)); + + // 再开 /app 不该弹 + await page.goto(`${BASE}/admin/dashboard`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + const stillVisible = await page.locator('button:has-text("我知道了")').isVisible({ timeout: 1500 }).catch(() => false); + if (!stillVisible) pass('6.1 再开页面不再弹 modal'); + else fail('6.1 再次弹出', new Error('已读状态下还在弹')); + } catch (e) { fail('6. 关闭流程', e); } + + // ── 7. 消息中心:看到公告条目 + chip + HTML 渲染 ── + try { + await page.goto(`${BASE}/notifications`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + // 找带 [公告] chip 的行 + const chip = await page.locator('text=/^公告$/').first().isVisible({ timeout: 3000 }); + if (chip) pass('7. 消息中心显示 [公告] chip'); + else fail('7. chip 缺失', new Error('未找到公告 chip')); + // HTML 渲染:smoke 测试公告 文字应可见 + const contentVisible = await page.locator('text=smoke 测试公告').first().isVisible({ timeout: 2000 }); + if (contentVisible) pass('7.1 公告内容 HTML 渲染正常'); + else fail('7.1 HTML 渲染', new Error('看不到公告文字')); + } catch (e) { fail('7. 消息中心', e); } + + // 清场:把测试造的公告标已读,避免污染下一次 smoke + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, + }); + await fetch(`${API}/api/v1/announcement/read`, { + method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, + }); + + if (consoleErrors.length > 0) { + fail('console errors', new Error(consoleErrors.slice(0, 3).join(' | '))); + } else { + pass('全程无 console.error'); + } + + console.log('\n────────────── 汇总 ──────────────'); + const passed = results.filter(r => r.ok).length; + const failed = results.filter(r => !r.ok); + console.log(`通过: ${passed} / ${results.length}`); + if (failed.length > 0) { + console.log(`失败 ${failed.length} 项:`); + failed.forEach(r => console.log(` - ${r.name}: ${r.err}`)); + } + + await browser.close(); + process.exit(failed.length === 0 ? 0 : 1); +} + +main().catch((e) => { console.error(e); process.exit(1); }); From bd3e80fd58acd986cd5eea2060cc14f759b7617d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 16:24:49 +0800 Subject: [PATCH 15/26] =?UTF-8?q?feat(notification):=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E6=94=B9=20accordion=20=E6=A8=A1=E5=BC=8F=20?= =?UTF-8?q?+=20=E8=B7=B3=E8=BD=AC=E6=8C=89=E9=92=AE=20+=20=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E9=A2=9C=E8=89=B2=20CSS=20var=20=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈三点: 1. 公告里"红字"/"蓝字"工具按钮硬编码颜色 #ff4d4f / #00b8e6 在浅色下糊 2. 消息中心列表里公告直接渲染完整 HTML,行被撑得很大 3. 点行就自动 navigate(link_url),用户希望"只看不跳",看完主动决定要不要跳转 改动: a) SettingsPage 公告编辑器颜色按钮(2 个): - 红字: → var(--color-danger) - 蓝字: → var(--color-primary) - 分割线 border-top 颜色 #333 → var(--color-border-card) - 三个都改成 CSS var,自动适配浅/深主题 - title 文案加"(自适应主题)"提示超管 b) NotificationsPage accordion 模式: - 加 expandedId: number | null state,始终最多 1 条展开 - 折叠态:chip + title + 时间 + 一行剥 HTML 后的纯文本预览(stripAndTruncate, 60 字 '…') - 展开态:头部 + 下方完整内容(announcement 用 DOMPurify+HTML / 其他 plain) + link_url 非空时显示【前往查看】按钮(蓝底白字,带箭头 icon) - chevron icon 旋转 0deg/180deg 视觉指示折叠/展开 - 同 id 再点 → 收起;不同 id → 切换(前一个自动收起) - 切页时自动重置 expandedId 为 null c) 点击行为: - handleRowClick(自动跳) → handleToggle(只 markRead + 切 expandedId) - 新加 handleJump(url):用户主动点【前往查看】才触发 navigate / window.open(http url) - 展开区域 onClick 加 stopPropagation 防误触收起 d) smoke test 更新: - 测试公告内容做长用 EXPANDED-ONLY-MARKER 末尾标记,preview 截断后看不到 - 7.2.0 折叠态 preview 截断验证(marker 不可见) - 7.2 展开后 marker 可见 - 7.3 再点收起 marker 不再可见 - 用 chip [公告] 文字作为稳定点击锚点(只在头部出现不在展开内容里) 验证: - typecheck 0 error - announcement-integration-smoke 13/13(从 10 项扩到 13,加 accordion 路径) - v0.20.1-smoke 11/11 + v2-smoke 25/25 + modal-interaction 8/8 全过 - vitest 71 fail / 162 pass 与基线一致 GlobalAnnouncementGate 强弹 modal 行为不变(plan §一 7 — 公告强制阅读语义保留)。 重看路径走 sidebar 大铃铛 → 消息中心 → accordion 展开看全文 → 可点【前往查看】跳。 Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/pages/NotificationsPage.tsx | 184 +++++++++++++++----- web/src/pages/SettingsPage.tsx | 7 +- web/test/announcement-integration-smoke.mjs | 39 ++++- 3 files changed, 179 insertions(+), 51 deletions(-) diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index fbde682..6cf90ff 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -1,10 +1,19 @@ -import { useEffect, type CSSProperties } from 'react'; +import { useEffect, useState, 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'; +// 剥 HTML 取纯文本前 N 字,用于列表行缩略预览 +function stripAndTruncate(html: string, maxChars = 60): string { + // 用 DOMParser 而非正则,防 `