video-shuoshan/web/src/pages/AdminAssetsPage.tsx
seaislee1209 699a390f45 fix: v0.10.2 — admin重新编辑隐藏/frozen防负数/进度条刷新保持/navigate修正
- admin资产页视频详情隐藏「重新编辑」按钮(hideReEdit prop)
- 团管重新编辑跳转修正:navigate('/') → navigate('/app')
- _release_freeze 防止 frozen_amount 变负数
- 生成进度条用 sessionStorage 持久化,刷新页面后从之前位置继续

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:24:14 +08:00

237 lines
9.6 KiB
TypeScript
Raw 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 { useEffect, useState, useRef, useCallback } from 'react';
import { adminApi } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
function formatCost(val: number) {
return `¥${(val || 0).toFixed(2)}`;
}
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hover, setHover] = useState(false);
const durationLabel = `00:${String(video.duration).padStart(2, '0')}`;
return (
<div
className={styles.thumbnail}
onMouseEnter={() => { setHover(true); videoRef.current?.play().catch(() => {}); }}
onMouseLeave={() => { setHover(false); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } }}
onClick={onClick}
>
{video.result_url ? (
<video ref={videoRef} src={video.result_url} className={styles.thumbVideo} muted loop preload="metadata" />
) : (
<div className={styles.thumbPlaceholder} />
)}
<span className={styles.durationBadge}>{durationLabel}</span>
{hover && <div className={styles.thumbOverlay} />}
</div>
);
}
function assetVideoToTask(v: AssetVideo): GenerationTask {
const references = (v.reference_urls || []).map((ref, i) => ({
id: `ref_${v.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video',
previewUrl: ref.url,
label: ref.label || `素材${i + 1}`,
role: ref.role,
}));
return {
id: String(v.id),
taskId: v.task_id,
prompt: v.prompt,
editorHtml: '',
mode: 'universal',
model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any,
references,
status: 'completed',
progress: 100,
resultUrl: v.result_url,
createdAt: new Date(v.created_at).getTime(),
};
}
// Chevron icon
function Chevron({ open }: { open: boolean }) {
return (
<svg className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 6 15 12 9 18" />
</svg>
);
}
export function AdminAssetsPage() {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<{
total_videos: number; total_seconds: number; total_teams: number;
teams: AssetTeamSummary[];
no_team: { video_count: number; seconds_consumed: number };
} | null>(null);
// Expanded states
const [expandedTeam, setExpandedTeam] = useState<number | null>(null);
const [teamMembers, setTeamMembers] = useState<Record<number, AssetMemberSummary[]>>({});
const [expandedMember, setExpandedMember] = useState<number | null>(null);
const [memberVideos, setMemberVideos] = useState<Record<number, { videos: AssetVideo[]; total: number; page: number }>>({});
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
adminApi.getAssetsOverview().then(({ data }) => {
setOverview(data);
setLoading(false);
}).catch((err) => {
const msg = err?.response?.data?.detail || err?.response?.data?.error || err?.message || '未知错误';
const status = err?.response?.status || '';
setError(`${status ? `[${status}] ` : ''}${msg}`);
setLoading(false);
});
}, []);
const toggleTeam = useCallback(async (teamId: number) => {
if (expandedTeam === teamId) {
setExpandedTeam(null);
setExpandedMember(null);
return;
}
setExpandedTeam(teamId);
setExpandedMember(null);
if (!teamMembers[teamId]) {
const { data } = await adminApi.getAssetsTeamMembers(teamId);
setTeamMembers((prev) => ({ ...prev, [teamId]: data.members }));
}
}, [expandedTeam, teamMembers]);
const toggleMember = useCallback(async (memberId: number) => {
if (expandedMember === memberId) {
setExpandedMember(null);
return;
}
setExpandedMember(memberId);
if (!memberVideos[memberId]) {
const { data } = await adminApi.getAssetsUserVideos(memberId, 1);
setMemberVideos((prev) => ({ ...prev, [memberId]: { videos: data.results, total: data.total, page: 1 } }));
}
}, [expandedMember, memberVideos]);
const loadMoreVideos = useCallback(async (memberId: number) => {
const current = memberVideos[memberId];
if (!current) return;
const nextPage = current.page + 1;
const { data } = await adminApi.getAssetsUserVideos(memberId, nextPage);
setMemberVideos((prev) => ({
...prev,
[memberId]: { videos: [...current.videos, ...data.results], total: data.total, page: nextPage },
}));
}, [memberVideos]);
if (loading) return <div className={styles.loading}>...</div>;
if (!overview) return <div className={styles.empty}>{error ? `${error}` : ''}</div>;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.statsBar}>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_videos}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_teams}</div>
</div>
</div>
<div className={styles.accordion}>
{overview.teams.map((team) => (
<div key={team.id} className={styles.accordionItem}>
<div className={styles.accordionHeader} onClick={() => toggleTeam(team.id)}>
<Chevron open={expandedTeam === team.id} />
<span className={styles.accordionName}>{team.name}</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{team.video_count} </span>
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
</div>
</div>
{expandedTeam === team.id && (
<div className={styles.accordionBody}>
<div className={styles.memberList}>
{(teamMembers[team.id] || []).map((member) => (
<div key={member.id}>
<div className={styles.memberItem} onClick={() => toggleMember(member.id)}>
<Chevron open={expandedMember === member.id} />
<span className={styles.memberName}>
{member.username}
{member.is_team_admin && <span className={styles.adminBadge}></span>}
</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{member.video_count} </span>
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
<div className={styles.videoSection}>
{memberVideos[member.id].videos.length === 0 ? (
<div className={styles.empty}></div>
) : (
<>
<div className={styles.videoGrid}>
{memberVideos[member.id].videos.map((video) => (
<VideoThumbnail
key={video.id}
video={video}
onClick={() => setDetailTask(assetVideoToTask(video))}
/>
))}
</div>
{memberVideos[member.id].videos.length < memberVideos[member.id].total && (
<div className={styles.loadMore}>
<button className={styles.loadMoreBtn} onClick={() => loadMoreVideos(member.id)}>
</button>
</div>
)}
</>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
{overview.no_team.video_count > 0 && (
<div className={styles.accordionItem}>
<div className={styles.accordionHeader}>
<span className={styles.accordionName}></span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{overview.no_team.video_count} </span>
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
</div>
</div>
</div>
)}
</div>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
hideReEdit
/>
</div>
);
}