feat: v0.15.0 Seedance 2.0 Fast 模型上线 + 四档计费
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s

- 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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-30 20:33:02 +08:00
parent 7a358ea9ef
commit b50ad147cd
14 changed files with 106 additions and 17 deletions

View File

@ -220,7 +220,8 @@ def me_view(request):
).aggregate(total=Sum('cost_amount'))['total'] or 0 ).aggregate(total=Sum('cost_amount'))['total'] or 0
config = QuotaConfig.objects.get_or_create(pk=1)[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'] = { data['team'] = {
'id': team.id, 'id': team.id,
@ -237,6 +238,9 @@ def me_view(request):
'monthly_spent': float(team_monthly_spent), 'monthly_spent': float(team_monthly_spent),
'frozen_amount': float(team.frozen_amount), 'frozen_amount': float(team.frozen_amount),
'token_price': token_price, '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, 'is_active': team.is_active,
} }
data['team_disabled'] = not team.is_active data['team_disabled'] = not team.is_active

View File

@ -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)'),
),
]

View File

@ -93,6 +93,8 @@ class QuotaConfig(models.Model):
default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数') 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 = 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_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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View File

@ -38,6 +38,8 @@ class SystemSettingsSerializer(serializers.Serializer):
default_monthly_generation_limit = serializers.IntegerField(min_value=0, required=False) 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 = 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_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 = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False) announcement_enabled = serializers.BooleanField(required=False, default=False)
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1) max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)

View File

@ -55,6 +55,13 @@ def _has_video_reference(references):
return any(ref.get('type') == 'video' for ref in 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. # Columns added in migration 0003; may not exist in production DB yet.
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
_m0003_ok = None # None = unknown, True = columns exist, False = missing _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) w, h = get_resolution(aspect_ratio)
estimated_tokens = estimate_tokens(w, h, duration) estimated_tokens = estimate_tokens(w, h, duration)
has_video_ref = _has_video_reference(request.data.get('references', [])) 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) estimated_cost = calculate_cost(estimated_tokens, token_price, team.markup_percentage)
# ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ── # ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ──
@ -487,7 +494,7 @@ def _settle_payment(record, total_tokens):
return return
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
has_video_ref = _has_video_reference(record.reference_urls) 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) actual_cost = calculate_cost(total_tokens, token_price, team.markup_percentage)
base_cost = calculate_base_cost(total_tokens, token_price) base_cost = calculate_base_cost(total_tokens, token_price)
frozen = record.frozen_amount frozen = record.frozen_amount
@ -1785,6 +1792,8 @@ def _settings_dict(config):
'default_monthly_generation_limit': config.default_monthly_generation_limit, 'default_monthly_generation_limit': config.default_monthly_generation_limit,
'base_token_price': float(config.base_token_price), 'base_token_price': float(config.base_token_price),
'base_token_price_video': float(config.base_token_price_video), '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': config.announcement,
'announcement_enabled': config.announcement_enabled, 'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions, 'max_desktop_sessions': config.max_desktop_sessions,

View File

@ -94,6 +94,8 @@ spec:
key: ARK_API_KEY key: ARK_API_KEY
- name: ARK_ENDPOINT_SEEDANCE - name: ARK_ENDPOINT_SEEDANCE
value: "ep-m-20260315211214-z9dp6" value: "ep-m-20260315211214-z9dp6"
- name: ARK_ENDPOINT_SEEDANCE_FAST
value: "ep-m-20260329211530-68999"
- name: SEEDANCE_ENABLED - name: SEEDANCE_ENABLED
value: "true" value: "true"
- name: ASSETS_API_ENABLED - name: ASSETS_API_ENABLED

View File

@ -83,6 +83,8 @@ spec:
key: ARK_API_KEY key: ARK_API_KEY
- name: ARK_ENDPOINT_SEEDANCE - name: ARK_ENDPOINT_SEEDANCE
value: "ep-m-20260315211214-z9dp6" value: "ep-m-20260315211214-z9dp6"
- name: ARK_ENDPOINT_SEEDANCE_FAST
value: "ep-m-20260329211530-68999"
- name: SEEDANCE_ENABLED - name: SEEDANCE_ENABLED
value: "true" value: "true"
resources: resources:

View File

@ -70,6 +70,7 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
{showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />} {showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />}
<InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} /> <InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} />
<InfoItem label="耗时" value={elapsed} /> <InfoItem label="耗时" value={elapsed} />
<InfoItem label="模型" value={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} />
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} /> <InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
<InfoItem label="比例" value={r.aspect_ratio || '-'} /> <InfoItem label="比例" value={r.aspect_ratio || '-'} />
<InfoItem label="时长" value={r.duration != null ? `${r.duration}` : '-'} /> <InfoItem label="时长" value={r.duration != null ? `${r.duration}` : '-'} />

View File

@ -72,8 +72,7 @@ const generationTypeItems = [
const modelItems = [ const modelItems = [
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> }, { label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
// Fast 暂未开通,隐藏选项 { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
// { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
]; ];
const modeItems = [ const modeItems = [
@ -124,7 +123,8 @@ export function Toolbar() {
const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt); const triggerInsertAt = useInputBarStore((s) => s.triggerInsertAt);
const isKeyframe = mode === 'keyframe'; 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); const addTask = useGenerationStore((s) => s.addTask);
@ -134,8 +134,15 @@ export function Toolbar() {
}, [aspectRatio, duration]); }, [aspectRatio, duration]);
const estimatedCost = useMemo(() => { const estimatedCost = useMemo(() => {
return (estimatedTokens * tokenPrice / 1000000).toFixed(2); const hasVideoRef = references.some((r) => r.type === 'video');
}, [estimatedTokens, tokenPrice]); 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(() => { const handleSend = useCallback(() => {
if (!isSubmittable) return; if (!isSubmittable) return;
@ -246,7 +253,7 @@ export function Toolbar() {
)} )}
{/* Estimated cost */} {/* Estimated cost */}
{isSubmittable && tokenPrice > 0 && ( {isSubmittable && (team?.token_price || 0) > 0 && (
<span <span
style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }} style={{ fontSize: 12, color: '#8b8ea8', whiteSpace: 'nowrap', userSelect: 'none', marginRight: 16, lineHeight: 1 }}
title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`} title={`预估公式: (宽 x 高 x 24fps x 时长) / 1024 = tokens, tokens x 单价 / 1000000 = 费用`}

View File

@ -30,6 +30,8 @@ const FIELD_LABELS: Record<string, string> = {
default_monthly_generation_limit: '每月生成次数', default_monthly_generation_limit: '每月生成次数',
base_token_price: '不含视频输入单价', base_token_price: '不含视频输入单价',
base_token_price_video: '含视频输入单价', base_token_price_video: '含视频输入单价',
base_token_price_fast: 'Fast不含视频输入单价',
base_token_price_fast_video: 'Fast含视频输入单价',
announcement: '公告内容', announcement: '公告内容',
announcement_enabled: '公告开关', announcement_enabled: '公告开关',
name: '名称', name: '名称',

View File

@ -60,16 +60,17 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined, team_id: teamFilter ? Number(teamFilter) : undefined,
}); });
const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; 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 statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2); 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 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 completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
const refCount = (r.reference_urls || []).length; 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'); }).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
@ -141,6 +142,7 @@ export function RecordsPage() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>
@ -149,13 +151,13 @@ export function RecordsPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{Array.from({ length: 12 }).map((_, j) => ( {Array.from({ length: 13 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td> <td key={j}><div className={styles.skeletonCell} /></td>
))} ))}
</tr> </tr>
)) ))
) : records.length === 0 ? ( ) : records.length === 0 ? (
<tr><td colSpan={12} className={styles.empty}></td></tr> <tr><td colSpan={13} className={styles.empty}></td></tr>
) : ( ) : (
records.map((r) => ( records.map((r) => (
<tr key={r.id} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}> <tr key={r.id} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}>
@ -169,6 +171,7 @@ export function RecordsPage() {
<td>¥{(r.base_cost_amount || 0).toFixed(2)}</td> <td>¥{(r.base_cost_amount || 0).toFixed(2)}</td>
<td>¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)}</td> <td>¥{((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2)}</td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td> <td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td> <td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}> <td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}> <span className={`${styles.statusBadge} ${styles[r.status]}`}>

View File

@ -12,6 +12,8 @@ export function SettingsPage() {
default_monthly_generation_limit: 500, default_monthly_generation_limit: 500,
base_token_price: 0, base_token_price: 0,
base_token_price_video: 0, base_token_price_video: 0,
base_token_price_fast: 0,
base_token_price_fast_video: 0,
announcement: '', announcement: '',
announcement_enabled: false, announcement_enabled: false,
max_desktop_sessions: 1, max_desktop_sessions: 1,
@ -141,6 +143,7 @@ export function SettingsPage() {
/> />
</div> </div>
</div> </div>
<p className={styles.cardDesc}>Seedance 2.0</p>
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label> (/tokens)</label> <label> (/tokens)</label>
@ -161,6 +164,27 @@ export function SettingsPage() {
/> />
</div> </div>
</div> </div>
<p className={styles.cardDesc}>Seedance 2.0 Fast</p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_fast}
onChange={(e) => setSettings({ ...settings, base_token_price_fast: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_fast_video}
onChange={(e) => setSettings({ ...settings, base_token_price_fast_video: Number(e.target.value) })}
/>
</div>
</div>
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}> <button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存配额设置'} {saving ? '保存中...' : '保存配额设置'}
</button> </button>

View File

@ -49,15 +49,16 @@ export function TeamRecordsPage() {
end_date: endDate || undefined, end_date: endDate || undefined,
}); });
const header = '任务ID,提交时间,完成时间,耗时,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n'; const header = '任务ID,提交时间,完成时间,耗时,用户名,模型,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const esc = (s: string) => s.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; 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 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 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 completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
const refCount = (r.reference_urls || []).length; 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'); }).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
@ -120,6 +121,7 @@ export function TeamRecordsPage() {
<th>Tokens</th> <th>Tokens</th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>
@ -128,13 +130,13 @@ export function TeamRecordsPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{Array.from({ length: 9 }).map((_, j) => ( {Array.from({ length: 10 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td> <td key={j}><div className={styles.skeletonCell} /></td>
))} ))}
</tr> </tr>
)) ))
) : records.length === 0 ? ( ) : records.length === 0 ? (
<tr><td colSpan={9} className={styles.empty}></td></tr> <tr><td colSpan={10} className={styles.empty}></td></tr>
) : ( ) : (
records.map((r) => ( records.map((r) => (
<tr key={r.id} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}> <tr key={r.id} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}>
@ -145,6 +147,7 @@ export function TeamRecordsPage() {
<td>{(r.tokens_consumed || 0).toLocaleString()}</td> <td>{(r.tokens_consumed || 0).toLocaleString()}</td>
<td>¥{(r.cost_amount || 0).toFixed(2)}</td> <td>¥{(r.cost_amount || 0).toFixed(2)}</td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td> <td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td> <td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}> <td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}> <span className={`${styles.statusBadge} ${styles[r.status]}`}>

View File

@ -103,6 +103,9 @@ export interface TeamInfo {
monthly_spent: number; monthly_spent: number;
frozen_amount: number; frozen_amount: number;
token_price: number; token_price: number;
token_price_video: number;
token_price_fast: number;
token_price_fast_video: number;
is_active: boolean; is_active: boolean;
} }
@ -210,6 +213,8 @@ export interface SystemSettings {
default_monthly_generation_limit: number; default_monthly_generation_limit: number;
base_token_price: number; base_token_price: number;
base_token_price_video: number; base_token_price_video: number;
base_token_price_fast: number;
base_token_price_fast_video: number;
announcement: string; announcement: string;
announcement_enabled: boolean; announcement_enabled: boolean;
max_desktop_sessions: number; max_desktop_sessions: number;