- 视频详情弹窗「重新编辑」按钮改为所有视角可见(admin/团管点击跳转生成页回填数据) - 团队详情月消费限额/加价率支持内联编辑,保存后列表同步刷新 - 修复 Decimal not JSON serializable(审计日志 before/after 字段) - 允许月消费限额输入 -1(不限制),fmtMoney 显示「不限」 - 仪表盘利润卡片移至第二行,团队/用户排行显示有秒数消耗的历史数据 - 资产页视频详情显示参考图片缩略图(reference_urls→references映射) - Toolbar 预估消耗仅在有内容时显示,全部清空与预估文字对齐 - settings.py 自动加载 backend/.env.local(本地开发免手动source) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.9 KiB
TypeScript
184 lines
6.9 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 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(),
|
|
};
|
|
}
|
|
|
|
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}>
|
|
{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)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|