- admin资产页视频详情隐藏「重新编辑」按钮(hideReEdit prop)
- 团管重新编辑跳转修正:navigate('/') → navigate('/app')
- _release_freeze 防止 frozen_amount 变负数
- 生成进度条用 sessionStorage 持久化,刷新页面后从之前位置继续
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
9.6 KiB
TypeScript
237 lines
9.6 KiB
TypeScript
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>
|
||
);
|
||
}
|