v0.8.2: DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip v0.8.3: 团队详情抽屉→弹窗重构 + 修改秒数池功能 + member_count 修复 v0.8.4: AdminAuditLog 模型 + 12 处管理操作埋点 + 日志查询页面(/admin/logs) 审计日志覆盖所有管理员 mutation 操作(充值、修改额度、创建/禁用用户等), 记录操作人、变更前后值、IP 地址,支持按操作类型/操作人/日期筛选。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
160 lines
5.8 KiB
TypeScript
160 lines
5.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 { 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 [detailTask, setDetailTask] = useState<GenerationTask | null>(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}>
|
||
<AnnouncementBanner />
|
||
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
|
||
{tasks.length === 0 ? (
|
||
<div className={styles.emptyArea}>
|
||
<p className={styles.emptyHint}>在下方输入提示词,开始创作 AI 视频</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}
|
||
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])}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|