All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m52s
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>
211 lines
8.1 KiB
TypeScript
211 lines
8.1 KiB
TypeScript
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
import { teamApi } from '../lib/api';
|
|
import { VideoDetailModal } from '../components/VideoDetailModal';
|
|
import type { AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
|
import styles from './AdminAssetsPage.module.css';
|
|
|
|
function formatSeconds(s: number) {
|
|
return `${s.toLocaleString()}s`;
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
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 TeamAssetsPage() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [overview, setOverview] = useState<{
|
|
team_id: number; team_name: string;
|
|
total_videos: number; total_seconds: number;
|
|
member_count: number; members: AssetMemberSummary[];
|
|
} | null>(null);
|
|
|
|
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);
|
|
|
|
useEffect(() => {
|
|
teamApi.getAssetsOverview().then(({ data }) => {
|
|
setOverview(data);
|
|
setLoading(false);
|
|
}).catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
const toggleMember = useCallback(async (memberId: number) => {
|
|
if (expandedMember === memberId) {
|
|
setExpandedMember(null);
|
|
return;
|
|
}
|
|
setExpandedMember(memberId);
|
|
if (!memberVideos[memberId]) {
|
|
const { data } = await teamApi.getAssetsMemberVideos(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 teamApi.getAssetsMemberVideos(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}>加载失败</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.member_count}</div>
|
|
</div>
|
|
<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}>{formatSeconds(overview.total_seconds)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.accordion}>
|
|
{overview.members.map((member) => (
|
|
<div key={member.id} className={styles.accordionItem}>
|
|
<div className={styles.accordionHeader} onClick={() => toggleMember(member.id)}>
|
|
<Chevron open={expandedMember === member.id} />
|
|
<span className={styles.accordionName}>
|
|
<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}>{formatSeconds(member.seconds_consumed)}</span>
|
|
</div>
|
|
</div>
|
|
{expandedMember === member.id && memberVideos[member.id] && (
|
|
<div className={styles.accordionBody}>
|
|
<div className={styles.videoSection} style={{ paddingLeft: 20 }}>
|
|
{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>
|
|
|
|
<VideoDetailModal
|
|
task={detailTask}
|
|
onClose={() => setDetailTask(null)}
|
|
{...(() => {
|
|
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>
|
|
);
|
|
}
|