video-shuoshan/web/src/components/VideoGenerationPage.tsx
seaislee1209 85f76d8543 feat: v0.8.2~v0.8.4 — 管理后台 UI 修复 + 团队详情重构 + 审计日志系统
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>
2026-03-16 01:18:44 +08:00

160 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}