video-shuoshan/web/src/pages/TeamAssetsPage.tsx
seaislee1209 ef2212e345 fix: v0.10.1 验收修复 — 重新编辑按钮/Decimal序列化/仪表盘布局/.env.local自动加载
- 视频详情弹窗「重新编辑」按钮改为所有视角可见(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>
2026-03-20 22:38:44 +08:00

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>
);
}