feat: v0.12.6 公告弹窗 + HTML 编辑器
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m5s

①公告改为弹窗(用户未读自动弹出,已读不再弹)
②生成页右上角小铃铛按钮可重新查看公告
③公告支持 HTML 渲染(加粗/红字/蓝字/标题/分割线/列表)
④超管公告编辑器加格式工具栏 + 预览按钮
⑤去掉旧的公告横幅

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-23 20:57:58 +08:00
parent a026c04310
commit 0ab5523ed1
8 changed files with 243 additions and 14 deletions

View File

@ -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='最后阅读公告时间'),
),
]

View File

@ -59,6 +59,7 @@ class User(AbstractUser):
spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)') spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码') must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
disabled_by = models.CharField(max_length=10, blank=True, default='', 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='创建时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')

View File

@ -11,6 +11,7 @@ urlpatterns = [
path('video/tasks/<uuid:task_id>/favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'), path('video/tasks/<uuid:task_id>/favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'),
# Public announcement # Public announcement
path('announcement', views.announcement_view, name='announcement'), path('announcement', views.announcement_view, name='announcement'),
path('announcement/read', views.announcement_read_view, name='announcement_read'),
# ── Super Admin: Dashboard ── # ── Super Admin: Dashboard ──
path('admin/stats', views.admin_stats_view, name='admin_stats'), path('admin/stats', views.admin_stats_view, name='admin_stats'),

View File

@ -1939,11 +1939,28 @@ def admin_audit_logs_view(request):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def announcement_view(request): 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) config, _ = QuotaConfig.objects.get_or_create(pk=1)
if config.announcement_enabled and config.announcement: if config.announcement_enabled and config.announcement:
return Response({'announcement': config.announcement, 'enabled': True}) is_read = False
return Response({'announcement': '', 'enabled': 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})
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@ -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 (
<div
style={{
position: 'fixed', inset: 0, zIndex: 400,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
onMouseDown={(e) => { if (e.target === e.currentTarget) handleClose(); }}
>
<div style={{
background: '#16161e', border: '1px solid var(--color-border-card)',
borderRadius: 12, padding: '28px 32px', maxWidth: 520, width: '90vw',
maxHeight: '70vh', overflowY: 'auto',
scrollbarWidth: 'none',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 16, fontWeight: 600, color: 'var(--color-text-primary)' }}>
📢
</span>
<button
onClick={handleClose}
style={{
background: 'none', border: 'none', color: 'var(--color-text-secondary)',
cursor: 'pointer', fontSize: 18, padding: 4,
}}
></button>
</div>
<div
style={{
fontSize: 14, lineHeight: 1.8, color: 'var(--color-text-primary)',
wordBreak: 'break-word', padding: '0 8px',
}}
dangerouslySetInnerHTML={{ __html: `<style>li{margin-left:16px}</style>${content}` }}
/>
<div style={{ textAlign: 'center', marginTop: 20 }}>
<button
onClick={handleClose}
style={{
padding: '8px 32px', background: 'var(--color-primary)',
border: 'none', borderRadius: 8, color: '#fff', fontSize: 14,
cursor: 'pointer',
}}
>
</button>
</div>
</div>
</div>
);
}

View File

@ -4,6 +4,7 @@ import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard'; import { GenerationCard } from './GenerationCard';
import { VideoDetailModal } from './VideoDetailModal'; import { VideoDetailModal } from './VideoDetailModal';
import { AnnouncementBanner } from './AnnouncementBanner'; import { AnnouncementBanner } from './AnnouncementBanner';
import { AnnouncementModal } from './AnnouncementModal';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types'; import type { GenerationTask } from '../types';
@ -24,6 +25,8 @@ export function VideoGenerationPage() {
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop); const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition); const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null); const [detailTaskId, setDetailTaskId] = useState<string | null>(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 detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []); const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
@ -120,7 +123,27 @@ export function VideoGenerationPage() {
<div className={styles.layout}> <div className={styles.layout}>
<Sidebar /> <Sidebar />
<main className={styles.main}> <main className={styles.main}>
<AnnouncementBanner /> {/* 公告已改为弹窗,旧的横幅不再显示 */}
{/* 右上角公告小喇叭 */}
<button
onClick={() => setShowAnnouncement(true)}
style={{
position: 'absolute', top: 12, right: 16, zIndex: 20,
background: 'rgba(255,255,255,0.06)', 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>
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}> <div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className={styles.emptyArea}> <div className={styles.emptyArea}>
@ -157,6 +180,14 @@ export function VideoGenerationPage() {
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])} onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])} onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])}
/> />
{/* 自动弹窗(首次未读)*/}
{!autoAnnouncementDone && (
<AnnouncementModal onClose={() => setAutoAnnouncementDone(true)} />
)}
{/* 手动弹窗(点小喇叭)*/}
{showAnnouncement && (
<AnnouncementModal forceOpen onClose={() => setShowAnnouncement(false)} />
)}
</div> </div>
); );
} }

View File

@ -160,7 +160,10 @@ export const videoApi = {
api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`), api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`),
getAnnouncement: () => 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) // Admin APIs (Super Admin)

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { adminApi } from '../lib/api'; import { adminApi } from '../lib/api';
import type { SystemSettings } from '../types'; import type { SystemSettings } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
@ -34,6 +34,8 @@ export function SettingsPage() {
}); });
const [testingFeishu, setTestingFeishu] = useState(false); const [testingFeishu, setTestingFeishu] = useState(false);
const [testingSms, setTestingSms] = useState(false); const [testingSms, setTestingSms] = useState(false);
const [previewAnnouncement, setPreviewAnnouncement] = useState(false);
const announcementRef = useRef<HTMLTextAreaElement>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -197,14 +199,85 @@ export function SettingsPage() {
</label> </label>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label></label> <label> HTML </label>
<textarea <div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
className={styles.textarea} {[
value={settings.announcement} { label: 'B', tag: 'b', title: '加粗' },
onChange={(e) => setSettings({ ...settings, announcement: e.target.value })} { label: '红字', wrap: ['<span style="color:#ff4d4f">', '</span>'], title: '红色文字' },
placeholder="输入公告内容..." { label: '蓝字', wrap: ['<span style="color:#00b8e6">', '</span>'], title: '蓝色文字' },
rows={4} { label: 'H3', wrap: ['<h3 style="margin:8px 0 4px">', '</h3>'], title: '标题' },
/> { label: '分割线', insert: '<hr style="border:none;border-top:1px solid #333;margin:12px 0">', title: '分割线' },
{ label: '列表项', insert: '<li>', title: '列表项' },
].map((btn) => (
<button
key={btn.label}
type="button"
title={btn.title}
onClick={() => {
const ta = announcementRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = settings.announcement;
const selected = text.substring(start, end);
let newText: string;
let cursorPos: number;
if ('insert' in btn && btn.insert) {
newText = text.substring(0, start) + btn.insert + text.substring(end);
cursorPos = start + btn.insert.length;
} else if ('wrap' in btn && btn.wrap) {
newText = text.substring(0, start) + btn.wrap[0] + selected + btn.wrap[1] + text.substring(end);
cursorPos = start + btn.wrap[0].length + selected.length + btn.wrap[1].length;
} else {
const open = `<${btn.tag}>`;
const close = `</${btn.tag}>`;
newText = text.substring(0, start) + open + selected + close + text.substring(end);
cursorPos = start + open.length + selected.length + close.length;
}
setSettings({ ...settings, announcement: newText });
setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos); }, 0);
}}
style={{
padding: '2px 8px', fontSize: 12, background: 'rgba(255,255,255,0.06)',
border: '1px solid var(--color-border-card)', borderRadius: 4,
color: 'var(--color-text-secondary)', cursor: 'pointer',
}}
>
{btn.label}
</button>
))}
<button
type="button"
onClick={() => setPreviewAnnouncement(!previewAnnouncement)}
style={{
padding: '2px 8px', fontSize: 12,
background: previewAnnouncement ? 'rgba(108,99,255,0.12)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${previewAnnouncement ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 4,
color: previewAnnouncement ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
}}
>
{previewAnnouncement ? '编辑' : '预览'}
</button>
</div>
{previewAnnouncement ? (
<div style={{
padding: '12px 16px', background: 'var(--color-bg-page)',
border: '1px solid var(--color-border-card)', borderRadius: 8,
fontSize: 14, lineHeight: 1.8, color: 'var(--color-text-primary)',
minHeight: 100, wordBreak: 'break-word',
}} dangerouslySetInnerHTML={{ __html: settings.announcement }} />
) : (
<textarea
ref={announcementRef}
className={styles.textarea}
value={settings.announcement}
onChange={(e) => setSettings({ ...settings, announcement: e.target.value })}
placeholder="输入公告内容,支持 HTML 格式..."
rows={6}
/>
)}
</div> </div>
<button className={styles.saveBtn} onClick={handleSaveAnnouncement} disabled={saving}> <button className={styles.saveBtn} onClick={handleSaveAnnouncement} disabled={saving}>
{saving ? '保存中...' : '保存公告'} {saving ? '保存中...' : '保存公告'}