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>
194 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|