新增 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>
232 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|