火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
`(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露
## 后端(7 处 + 1 次迁移)
- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
* RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
* get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
* estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
* VideoGenerateSerializer 加 resolution ChoiceField
* aspect_ratio 改 ChoiceField 显式拒绝 adaptive
* SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
* _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
* _sum_video_duration 累加视频参考时长
* video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
传给 get_resolution/estimate_tokens/_get_token_price/create_task/
GenerationRecord.resolution(移除 L450 硬编码 '720p')
* _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
* _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video
## 前端(10 处)
- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
* 加分辨率选择器 Dropdown(位置:比例和时长之间)
* modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
* estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
* estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
* tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填
## 测试(47 个用例)
### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
+ 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过
### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
(1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
Dropdown 双向置灰、tooltip 明示以火山为准
## 与官方文档对齐
- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
11 KiB
TypeScript
265 lines
11 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 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,
|
||
resolution: v.resolution,
|
||
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>
|
||
);
|
||
}
|