video-shuoshan/web/src/pages/AdminAssetsPage.tsx
seaislee1209 0b770340c8
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m52s
fix: 修复资产页素材库引用不可查看 + 重新编辑素材泄漏
1. AdminAssetsPage/TeamAssetsPage: asset:// 协议 URL 改用 thumb_url 显示缩略图
2. generation.ts reEdit/regenerate: 过滤 isAssetRef,素材库引用不混入 references 数组
3. PromptInput extractText: 实时同步 assetMentions store,删除 @标签后不再残留旧数据

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:33:08 +08:00

264 lines
11 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 isAssetUrl(url: string): boolean {
return url.startsWith('asset://') || url.startsWith('Asset://');
}
function assetVideoToTask(v: AssetVideo): GenerationTask {
const references = (v.reference_urls || []).map((ref, i) => {
const url = ref.url || '';
const assetRef = isAssetUrl(url);
return {
id: `ref_${v.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
previewUrl: assetRef ? (ref.thumb_url || '') : url,
label: ref.label || `素材${i + 1}`,
role: ref.role,
isAssetRef: assetRef || undefined,
};
});
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,
assetMentions: [],
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}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: member.is_online ? '#00b894' : '#555', marginRight: 6,
verticalAlign: 'middle',
}} />
{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
{...(() => {
if (!detailTask || !expandedMember || !memberVideos[expandedMember]) return {};
const vids = memberVideos[expandedMember].videos;
const idx = vids.findIndex((v) => String(v.id) === detailTask.id);
if (idx < 0) return {};
return {
hasPrev: idx > 0,
hasNext: idx < vids.length - 1,
onPrev: () => idx > 0 && setDetailTask(assetVideoToTask(vids[idx - 1])),
onNext: () => idx < vids.length - 1 && setDetailTask(assetVideoToTask(vids[idx + 1])),
};
})()}
/>
</div>
);
}