video-shuoshan/web/src/components/VideoGenerationPage.tsx
seaislee1209 0ab5523ed1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m5s
feat: v0.12.6 公告弹窗 + HTML 编辑器
①公告改为弹窗(用户未读自动弹出,已读不再弹)
②生成页右上角小铃铛按钮可重新查看公告
③公告支持 HTML 渲染(加粗/红字/蓝字/标题/分割线/列表)
④超管公告编辑器加格式工具栏 + 预览按钮
⑤去掉旧的公告横幅

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:57:58 +08:00

194 lines
7.8 KiB
TypeScript

import { useRef, useEffect, useState, useMemo, useCallback } from 'react';
import { Sidebar } from './Sidebar';
import { InputBar } from './InputBar';
import { GenerationCard } from './GenerationCard';
import { VideoDetailModal } from './VideoDetailModal';
import { AnnouncementBanner } from './AnnouncementBanner';
import { AnnouncementModal } from './AnnouncementModal';
import { useGenerationStore } from '../store/generation';
import { useAuthStore } from '../store/auth';
import type { GenerationTask } from '../types';
import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks);
const loadTasks = useGenerationStore((s) => s.loadTasks);
const loadMore = useGenerationStore((s) => s.loadMore);
const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
const teamDisabled = useAuthStore((s) => s.teamDisabled);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(tasks.length);
const initialLoadRef = useRef(true);
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), []);
// Load tasks from backend on mount (persist across page refresh)
useEffect(() => {
loadTasks();
}, [loadTasks]);
// Restore scroll position after initial load, or scroll to bottom for new tasks
useEffect(() => {
if (tasks.length === 0) return;
if (initialLoadRef.current) {
initialLoadRef.current = false;
// Use requestAnimationFrame to ensure DOM has rendered
requestAnimationFrame(() => {
if (savedScrollTop !== null && scrollRef.current) {
scrollRef.current.scrollTop = savedScrollTop;
} else if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
prevCountRef.current = tasks.length;
return;
}
if (tasks.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}
prevCountRef.current = tasks.length;
}, [tasks.length, savedScrollTop]);
// Save scroll position + auto-load older tasks when scrolled near top
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
saveScrollPosition(scrollRef.current.scrollTop);
// Trigger loadMore when scrolled within 100px of the top
if (scrollRef.current.scrollTop < 100) {
const el = scrollRef.current;
const prevHeight = el.scrollHeight;
loadMore().then(() => {
// After older tasks are prepended, restore visual position so user doesn't jump
requestAnimationFrame(() => {
const diff = el.scrollHeight - prevHeight;
if (diff > 0) el.scrollTop += diff;
});
});
}
}, [saveScrollPosition, loadMore]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
removeTask(id);
setDetailTask(null);
};
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed' && t.resultUrl),
[tasks],
);
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
if (teamDisabled) {
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100%', flexDirection: 'column', gap: 16,
color: 'var(--color-text-secondary)',
}}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#8b8ea8" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M4.93 4.93l14.14 14.14" />
</svg>
<p style={{ fontSize: 18, color: 'var(--color-text-primary)' }}></p>
<p></p>
</div>
</main>
</div>
);
}
return (
<div className={styles.layout}>
<Sidebar />
<main className={styles.main}>
{/* 公告已改为弹窗,旧的横幅不再显示 */}
{/* 右上角公告小喇叭 */}
<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}>
<p className={styles.emptyHint}>Every frame was once just air.</p>
</div>
) : (
<div className={styles.taskList}>
{isLoadingMore && (
<div className={styles.loadMoreWrap}>
<span className={styles.loadMoreText}></span>
</div>
)}
{tasks.map((task) => (
<GenerationCard
key={task.id}
task={task}
onOpenDetail={setDetailTask}
/>
))}
</div>
)}
</div>
<InputBar />
</main>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onReEdit={handleReEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }}
hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
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>
);
}