- 全局样式对标Air Spark设计系统(背景、glass card、配色、圆角) - 视频详情弹窗(VideoDetailModal)全屏预览+信息面板 - GenerationCard重构:fixed定位tooltip、9:16视频适配、blob下载 - 个人中心:总额度/今日/本月三卡片布局 - Dashboard图表配色统一为#6c63ff主色调 - Sidebar、InputBar、Toolbar等组件样式优化 - 新增AmbientBackground、AssetsPage组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
import { useEffect, useState, useRef, useMemo } from 'react';
|
|
import { Sidebar } from '../components/Sidebar';
|
|
import { VideoDetailModal } from '../components/VideoDetailModal';
|
|
import { useGenerationStore } from '../store/generation';
|
|
import type { GenerationTask } from '../types';
|
|
import styles from './AssetsPage.module.css';
|
|
|
|
function groupByDate(tasks: GenerationTask[]): { label: string; tasks: GenerationTask[] }[] {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const yesterday = today - 86400000;
|
|
|
|
const groups = new Map<string, GenerationTask[]>();
|
|
const order: string[] = [];
|
|
|
|
for (const task of tasks) {
|
|
const d = new Date(task.createdAt);
|
|
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
let label: string;
|
|
if (dayStart >= today) {
|
|
label = '今天';
|
|
} else if (dayStart >= yesterday) {
|
|
label = '昨天';
|
|
} else {
|
|
label = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
}
|
|
if (!groups.has(label)) {
|
|
groups.set(label, []);
|
|
order.push(label);
|
|
}
|
|
groups.get(label)!.push(task);
|
|
}
|
|
|
|
return order.map((label) => ({ label, tasks: groups.get(label)! }));
|
|
}
|
|
|
|
function VideoThumbnail({
|
|
task,
|
|
onClick,
|
|
}: {
|
|
task: GenerationTask;
|
|
onClick: () => void;
|
|
}) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [hover, setHover] = useState(false);
|
|
|
|
const handleEnter = () => {
|
|
setHover(true);
|
|
videoRef.current?.play().catch(() => {});
|
|
};
|
|
|
|
const handleLeave = () => {
|
|
setHover(false);
|
|
if (videoRef.current) {
|
|
videoRef.current.pause();
|
|
videoRef.current.currentTime = 0;
|
|
}
|
|
};
|
|
|
|
const durationLabel = `00:${String(task.duration).padStart(2, '0')}`;
|
|
|
|
return (
|
|
<div
|
|
className={styles.thumbnail}
|
|
onMouseEnter={handleEnter}
|
|
onMouseLeave={handleLeave}
|
|
onClick={onClick}
|
|
>
|
|
{task.resultUrl ? (
|
|
<video
|
|
ref={videoRef}
|
|
src={task.resultUrl}
|
|
className={styles.thumbVideo}
|
|
muted
|
|
loop
|
|
preload="metadata"
|
|
/>
|
|
) : (
|
|
<div className={styles.thumbPlaceholder} />
|
|
)}
|
|
<span className={styles.durationBadge}>{durationLabel}</span>
|
|
{hover && <div className={styles.thumbOverlay} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AssetsPage() {
|
|
const tasks = useGenerationStore((s) => s.tasks);
|
|
const loadTasks = useGenerationStore((s) => s.loadTasks);
|
|
const reEdit = useGenerationStore((s) => s.reEdit);
|
|
const regenerate = useGenerationStore((s) => s.regenerate);
|
|
const removeTask = useGenerationStore((s) => s.removeTask);
|
|
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadTasks();
|
|
}, [loadTasks]);
|
|
|
|
const completedTasks = useMemo(
|
|
() => tasks.filter((t) => t.status === 'completed'),
|
|
[tasks],
|
|
);
|
|
|
|
const dateGroups = useMemo(() => groupByDate(completedTasks), [completedTasks]);
|
|
|
|
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 detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1;
|
|
|
|
return (
|
|
<div className={styles.layout}>
|
|
<Sidebar />
|
|
<main className={styles.main}>
|
|
{/* Tab header */}
|
|
<div className={styles.tabHeader}>
|
|
<div className={styles.tabs}>
|
|
<span className={`${styles.tab} ${styles.tabActive}`}>视频</span>
|
|
</div>
|
|
<div className={styles.subTabs}>
|
|
<span className={`${styles.subTab} ${styles.subTabActive}`}>所有视频</span>
|
|
<span className={styles.subTab}>我的收藏</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Video grid by date */}
|
|
<div className={styles.content}>
|
|
{completedTasks.length === 0 ? (
|
|
<div className={styles.empty}>
|
|
<p>暂无已完成的视频</p>
|
|
</div>
|
|
) : (
|
|
dateGroups.map((group) => (
|
|
<section key={group.label} className={styles.dateGroup}>
|
|
<h3 className={styles.dateLabel}>{group.label}</h3>
|
|
<div className={styles.grid}>
|
|
{group.tasks.map((task) => (
|
|
<VideoThumbnail
|
|
key={task.id}
|
|
task={task}
|
|
onClick={() => setDetailTask(task)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))
|
|
)}
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|