From b50ad147cd1b7f63697205ef2e16b57af77c6180 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 30 Mar 2026 20:33:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.15.0=20Seedance=202.0=20Fast=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8A=E7=BA=BF=20+=20=E5=9B=9B=E6=A1=A3?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fast 模型:取消隐藏 Toolbar 选项,用户可选 AirDrama / AirDrama Fast - 四档计费:按模型+有无视频参考选单价(2.0: 46/28, Fast: 37/22 元/百万tokens) - QuotaConfig 新增 base_token_price_fast / base_token_price_fast_video 字段 - 系统设置页 4 个价格输入框(Seedance 2.0 + Fast 各两个) - 前端预估动态选价:根据当前选的模型和有无视频参考实时计算 - 推理接入点:Fast EP ep-m-20260329211530-68999 - 消费记录表格+CSV+详情弹窗加"模型"列 - 轮询间隔改为全程固定 5 秒 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/apps/accounts/views.py | 6 ++++- .../migrations/0015_add_fast_token_price.py | 23 ++++++++++++++++++ backend/apps/generation/models.py | 2 ++ backend/apps/generation/serializers.py | 2 ++ backend/apps/generation/views.py | 13 ++++++++-- k8s/backend-deployment.yaml | 2 ++ k8s/celery-deployment.yaml | 2 ++ web/src/components/RecordDetailModal.tsx | 1 + web/src/components/Toolbar.tsx | 19 ++++++++++----- web/src/pages/AuditLogsPage.tsx | 2 ++ web/src/pages/RecordsPage.tsx | 11 +++++---- web/src/pages/SettingsPage.tsx | 24 +++++++++++++++++++ web/src/pages/TeamRecordsPage.tsx | 11 +++++---- web/src/types/index.ts | 5 ++++ 14 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 backend/apps/generation/migrations/0015_add_fast_token_price.py diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index ddeb232..db8cecd 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -220,7 +220,8 @@ def me_view(request): ).aggregate(total=Sum('cost_amount'))['total'] or 0 config = QuotaConfig.objects.get_or_create(pk=1)[0] - token_price = float(config.base_token_price) * (1 + float(team.markup_percentage) / 100) + markup_mult = 1 + float(team.markup_percentage) / 100 + token_price = float(config.base_token_price) * markup_mult data['team'] = { 'id': team.id, @@ -237,6 +238,9 @@ def me_view(request): 'monthly_spent': float(team_monthly_spent), 'frozen_amount': float(team.frozen_amount), 'token_price': token_price, + 'token_price_video': float(config.base_token_price_video) * markup_mult, + 'token_price_fast': float(config.base_token_price_fast) * markup_mult, + 'token_price_fast_video': float(config.base_token_price_fast_video) * markup_mult, 'is_active': team.is_active, } data['team_disabled'] = not team.is_active diff --git a/backend/apps/generation/migrations/0015_add_fast_token_price.py b/backend/apps/generation/migrations/0015_add_fast_token_price.py new file mode 100644 index 0000000..5764c26 --- /dev/null +++ b/backend/apps/generation/migrations/0015_add_fast_token_price.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-03-29 13:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0014_add_updated_at_to_record'), + ] + + operations = [ + migrations.AddField( + model_name='quotaconfig', + name='base_token_price_fast', + field=models.DecimalField(decimal_places=2, default=37, max_digits=10, verbose_name='Fast单价-不含视频(元/百万tokens)'), + ), + migrations.AddField( + model_name='quotaconfig', + name='base_token_price_fast_video', + field=models.DecimalField(decimal_places=2, default=22, max_digits=10, verbose_name='Fast单价-含视频(元/百万tokens)'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 32a9f98..4d92155 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -93,6 +93,8 @@ class QuotaConfig(models.Model): default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数') base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价-不含视频(元/百万tokens)') base_token_price_video = models.DecimalField(max_digits=10, decimal_places=2, default=28, verbose_name='基础token单价-含视频(元/百万tokens)') + base_token_price_fast = models.DecimalField(max_digits=10, decimal_places=2, default=37, verbose_name='Fast单价-不含视频(元/百万tokens)') + base_token_price_fast_video = models.DecimalField(max_digits=10, decimal_places=2, default=22, verbose_name='Fast单价-含视频(元/百万tokens)') updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 502f584..0b75884 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -38,6 +38,8 @@ class SystemSettingsSerializer(serializers.Serializer): default_monthly_generation_limit = serializers.IntegerField(min_value=0, required=False) base_token_price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) base_token_price_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) + base_token_price_fast = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) + base_token_price_fast_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) announcement = serializers.CharField(required=False, allow_blank=True, default='') announcement_enabled = serializers.BooleanField(required=False, default=False) max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 18b9734..e98e40a 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -55,6 +55,13 @@ def _has_video_reference(references): return any(ref.get('type') == 'video' for ref in references) +def _get_token_price(config, model, has_video_ref): + """根据模型和是否有视频参考选择单价。""" + if model == 'seedance_2.0_fast': + return config.base_token_price_fast_video if has_video_ref else config.base_token_price_fast + return config.base_token_price_video if has_video_ref else config.base_token_price + + # Columns added in migration 0003; may not exist in production DB yet. _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _m0003_ok = None # None = unknown, True = columns exist, False = missing @@ -176,7 +183,7 @@ def video_generate_view(request): w, h = get_resolution(aspect_ratio) estimated_tokens = estimate_tokens(w, h, duration) has_video_ref = _has_video_reference(request.data.get('references', [])) - token_price = config.base_token_price_video if has_video_ref else config.base_token_price + token_price = _get_token_price(config, model, has_video_ref) estimated_cost = calculate_cost(estimated_tokens, token_price, team.markup_percentage) # ── 所有额度检查在 transaction 内完成,select_for_update 串行化同团队请求 ── @@ -487,7 +494,7 @@ def _settle_payment(record, total_tokens): return config = QuotaConfig.objects.get_or_create(pk=1)[0] has_video_ref = _has_video_reference(record.reference_urls) - token_price = config.base_token_price_video if has_video_ref else config.base_token_price + token_price = _get_token_price(config, record.model, has_video_ref) actual_cost = calculate_cost(total_tokens, token_price, team.markup_percentage) base_cost = calculate_base_cost(total_tokens, token_price) frozen = record.frozen_amount @@ -1785,6 +1792,8 @@ def _settings_dict(config): 'default_monthly_generation_limit': config.default_monthly_generation_limit, 'base_token_price': float(config.base_token_price), 'base_token_price_video': float(config.base_token_price_video), + 'base_token_price_fast': float(config.base_token_price_fast), + 'base_token_price_fast_video': float(config.base_token_price_fast_video), 'announcement': config.announcement, 'announcement_enabled': config.announcement_enabled, 'max_desktop_sessions': config.max_desktop_sessions, diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml index 010a72c..f457441 100644 --- a/k8s/backend-deployment.yaml +++ b/k8s/backend-deployment.yaml @@ -94,6 +94,8 @@ spec: key: ARK_API_KEY - name: ARK_ENDPOINT_SEEDANCE value: "ep-m-20260315211214-z9dp6" + - name: ARK_ENDPOINT_SEEDANCE_FAST + value: "ep-m-20260329211530-68999" - name: SEEDANCE_ENABLED value: "true" - name: ASSETS_API_ENABLED diff --git a/k8s/celery-deployment.yaml b/k8s/celery-deployment.yaml index 71bdd7b..d7f99f4 100644 --- a/k8s/celery-deployment.yaml +++ b/k8s/celery-deployment.yaml @@ -83,6 +83,8 @@ spec: key: ARK_API_KEY - name: ARK_ENDPOINT_SEEDANCE value: "ep-m-20260315211214-z9dp6" + - name: ARK_ENDPOINT_SEEDANCE_FAST + value: "ep-m-20260329211530-68999" - name: SEEDANCE_ENABLED value: "true" resources: diff --git a/web/src/components/RecordDetailModal.tsx b/web/src/components/RecordDetailModal.tsx index c6784d2..6efa5a1 100644 --- a/web/src/components/RecordDetailModal.tsx +++ b/web/src/components/RecordDetailModal.tsx @@ -70,6 +70,7 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr {showTeam && r.team_name && } + diff --git a/web/src/components/Toolbar.tsx b/web/src/components/Toolbar.tsx index e29fcb1..5bda6e9 100644 --- a/web/src/components/Toolbar.tsx +++ b/web/src/components/Toolbar.tsx @@ -72,8 +72,7 @@ const generationTypeItems = [ const modelItems = [ { label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: }, - // Fast 暂未开通,隐藏选项 - // { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: }, + { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: }, ]; const modeItems = [ @@ -124,7 +123,8 @@ export function Toolbar() { const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt); const isKeyframe = mode === 'keyframe'; - const tokenPrice = useAuthStore((s) => s.team?.token_price) || 0; + const references = useInputBarStore((s) => s.references); + const team = useAuthStore((s) => s.team); const addTask = useGenerationStore((s) => s.addTask); @@ -134,8 +134,15 @@ export function Toolbar() { }, [aspectRatio, duration]); const estimatedCost = useMemo(() => { - return (estimatedTokens * tokenPrice / 1000000).toFixed(2); - }, [estimatedTokens, tokenPrice]); + const hasVideoRef = references.some((r) => r.type === 'video'); + let price = team?.token_price || 0; + if (model === 'seedance_2.0_fast') { + price = hasVideoRef ? (team?.token_price_fast_video || 0) : (team?.token_price_fast || 0); + } else { + price = hasVideoRef ? (team?.token_price_video || 0) : (team?.token_price || 0); + } + return (estimatedTokens * price / 1000000).toFixed(2); + }, [estimatedTokens, model, references, team]); const handleSend = useCallback(() => { if (!isSubmittable) return; @@ -246,7 +253,7 @@ export function Toolbar() { )} {/* Estimated cost */} - {isSubmittable && tokenPrice > 0 && ( + {isSubmittable && (team?.token_price || 0) > 0 && ( = { default_monthly_generation_limit: '每月生成次数', base_token_price: '不含视频输入单价', base_token_price_video: '含视频输入单价', + base_token_price_fast: 'Fast不含视频输入单价', + base_token_price_fast_video: 'Fast含视频输入单价', announcement: '公告内容', announcement_enabled: '公告开关', name: '名称', diff --git a/web/src/pages/RecordsPage.tsx b/web/src/pages/RecordsPage.tsx index 2d187b9..526826e 100644 --- a/web/src/pages/RecordsPage.tsx +++ b/web/src/pages/RecordsPage.tsx @@ -60,16 +60,17 @@ export function RecordsPage() { team_id: teamFilter ? Number(teamFilter) : undefined, }); - const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; + const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const rows = data.results.map((r) => { const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; + const modelLabel = r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2); const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : ''; const refCount = (r.reference_urls || []).length; - return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; + return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.team_name || '-'}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -141,6 +142,7 @@ export function RecordsPage() { 成本 利润 视频描述 + 模型 模式 状态 @@ -149,13 +151,13 @@ export function RecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 12 }).map((_, j) => ( + {Array.from({ length: 13 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( setDetailRecord(r)} style={{ cursor: 'pointer' }}> @@ -169,6 +171,7 @@ export function RecordsPage() { ¥{(r.base_cost_amount || 0).toFixed(2)} ¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)} {r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'} + {r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} {r.mode === 'universal' ? '全能参考' : '首尾帧'} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 91d1515..a3c13da 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -12,6 +12,8 @@ export function SettingsPage() { default_monthly_generation_limit: 500, base_token_price: 0, base_token_price_video: 0, + base_token_price_fast: 0, + base_token_price_fast_video: 0, announcement: '', announcement_enabled: false, max_desktop_sessions: 1, @@ -141,6 +143,7 @@ export function SettingsPage() { />
+

Seedance 2.0

@@ -161,6 +164,27 @@ export function SettingsPage() { />
+

Seedance 2.0 Fast

+
+
+ + setSettings({ ...settings, base_token_price_fast: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, base_token_price_fast_video: Number(e.target.value) })} + /> +
+
diff --git a/web/src/pages/TeamRecordsPage.tsx b/web/src/pages/TeamRecordsPage.tsx index 9430077..9b72daf 100644 --- a/web/src/pages/TeamRecordsPage.tsx +++ b/web/src/pages/TeamRecordsPage.tsx @@ -49,15 +49,16 @@ export function TeamRecordsPage() { end_date: endDate || undefined, }); - const header = '任务ID,提交时间,完成时间,耗时,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; + const header = '任务ID,提交时间,完成时间,耗时,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const rows = data.results.map((r) => { const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; + const modelLabel = r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : ''; const refCount = (r.reference_urls || []).length; - return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.username}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; + return `"${r.ark_task_id || ''}","${new Date(r.created_at).toLocaleString('zh-CN')}","${completedAt}","${elapsed}","${r.username}","${modelLabel}","${r.duration ?? ''}","${modeLabel}","${r.aspect_ratio || ''}","${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${r.seed != null && r.seed !== -1 ? r.seed : ''}","${statusLabel}","${esc(r.prompt || '')}","${esc(r.error_message || '')}","${esc(r.raw_error || '')}","${refCount}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -120,6 +121,7 @@ export function TeamRecordsPage() { Tokens 费用 视频描述 + 模型 模式 状态 @@ -128,13 +130,13 @@ export function TeamRecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 9 }).map((_, j) => ( + {Array.from({ length: 10 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( setDetailRecord(r)} style={{ cursor: 'pointer' }}> @@ -145,6 +147,7 @@ export function TeamRecordsPage() { {(r.tokens_consumed || 0).toLocaleString()} ¥{(r.cost_amount || 0).toFixed(2)} {r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'} + {r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} {r.mode === 'universal' ? '全能参考' : '首尾帧'} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index f00f75f..55b8156 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -103,6 +103,9 @@ export interface TeamInfo { monthly_spent: number; frozen_amount: number; token_price: number; + token_price_video: number; + token_price_fast: number; + token_price_fast_video: number; is_active: boolean; } @@ -210,6 +213,8 @@ export interface SystemSettings { default_monthly_generation_limit: number; base_token_price: number; base_token_price_video: number; + base_token_price_fast: number; + base_token_price_fast_video: number; announcement: string; announcement_enabled: boolean; max_desktop_sessions: number;