video-shuoshan/web/src/pages/AssetsPage.tsx
zyc bc47bd09c4 feat: 前端预览资源切换到 CDN 域名 airflow-play.airlabs.art
新增 rewriteTosUrl 在渲染层把 airdrama-media.tos-cn-beijing.volces.com
替换成 airflow-play.airlabs.art,覆盖 <video>/<audio> src 及 tosThumb
图片缩略;下载仍走原 TOS 直连域名以避开 CDN CORS 配置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:07:32 +08:00

232 lines
7.7 KiB
TypeScript

import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation';
import { rewriteTosUrl } from '../lib/api';
import { ConfirmModal } from '../components/ConfirmModal';
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={rewriteTosUrl(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 loadMore = useGenerationStore((s) => s.loadMore);
const hasMore = useGenerationStore((s) => s.hasMore);
const isLoadingMore = useGenerationStore((s) => s.isLoadingMore);
const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
loadTasks();
}, [loadTasks]);
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
// Reverse: newest first for asset page (store keeps oldest-first for generation page)
const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed').slice().reverse(),
[tasks],
);
const displayTasks = useMemo(
() => subTab === 'favorites' ? completedTasks.filter((t) => t.isFavorited) : completedTasks,
[completedTasks, subTab],
);
const dateGroups = useMemo(() => groupByDate(displayTasks), [displayTasks]);
const handleReEdit = (id: string) => {
reEdit(id);
setDetailTask(null);
};
const handleRegenerate = (id: string) => {
regenerate(id);
setDetailTask(null);
};
const handleDelete = (id: string) => {
setConfirmDeleteId(id);
};
const doDelete = () => {
if (confirmDeleteId) {
removeTask(confirmDeleteId);
setDetailTask(null);
}
setConfirmDeleteId(null);
};
const detailIdx = detailTask ? displayTasks.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} ${subTab === 'all' ? styles.subTabActive : ''}`} onClick={() => setSubTab('all')} style={{ cursor: 'pointer' }}></span>
<span className={`${styles.subTab} ${subTab === 'favorites' ? styles.subTabActive : ''}`} onClick={() => setSubTab('favorites')} style={{ cursor: 'pointer' }}></span>
</div>
</div>
{/* Video grid by date */}
<div className={styles.content}>
{displayTasks.length === 0 ? (
<div className={styles.empty}>
<p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</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>
))}
{hasMore && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<button
onClick={loadMore}
disabled={isLoadingMore}
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
padding: '8px 32px',
color: 'var(--color-text-secondary)',
cursor: isLoadingMore ? 'not-allowed' : 'pointer',
fontSize: 13,
}}
>
{isLoadingMore ? '加载中...' : '加载更多'}
</button>
</div>
)}
</>
)}
</div>
</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 < displayTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(displayTasks[detailIdx - 1])}
onNext={() => detailIdx < displayTasks.length - 1 && setDetailTask(displayTasks[detailIdx + 1])}
/>
<ConfirmModal
open={!!confirmDeleteId}
title="删除视频"
message="确定要删除这条生成记录吗?此操作不可撤销。"
confirmText="删除"
danger
onConfirm={doDelete}
onCancel={() => setConfirmDeleteId(null)}
/>
</div>
);
}