feat: v0.12.6 公告弹窗 + HTML 编辑器
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m5s
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:
parent
a026c04310
commit
0ab5523ed1
@ -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='最后阅读公告时间'),
|
||||
),
|
||||
]
|
||||
@ -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='更新时间')
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ urlpatterns = [
|
||||
path('video/tasks/<uuid:task_id>/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'),
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
85
web/src/components/AnnouncementModal.tsx
Normal file
85
web/src/components/AnnouncementModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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<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 setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
|
||||
|
||||
@ -120,7 +123,27 @@ export function VideoGenerationPage() {
|
||||
<div className={styles.layout}>
|
||||
<Sidebar />
|
||||
<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}>
|
||||
{tasks.length === 0 ? (
|
||||
<div className={styles.emptyArea}>
|
||||
@ -157,6 +180,14 @@ 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)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<HTMLTextAreaElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@ -197,14 +199,85 @@ export function SettingsPage() {
|
||||
</label>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>公告内容</label>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={settings.announcement}
|
||||
onChange={(e) => setSettings({ ...settings, announcement: e.target.value })}
|
||||
placeholder="输入公告内容..."
|
||||
rows={4}
|
||||
/>
|
||||
<label>公告内容(支持 HTML 格式)</label>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'B', tag: 'b', title: '加粗' },
|
||||
{ label: '红字', wrap: ['<span style="color:#ff4d4f">', '</span>'], title: '红色文字' },
|
||||
{ label: '蓝字', wrap: ['<span style="color:#00b8e6">', '</span>'], title: '蓝色文字' },
|
||||
{ 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>
|
||||
<button className={styles.saveBtn} onClick={handleSaveAnnouncement} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存公告'}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user