From 49616128da03ff0350554b0fffc8e69071a62cb8 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 25 Mar 2026 01:56:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.13.2=20=E6=B6=88=E8=B4=B9=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=A2=9E=E5=8A=A0=E8=80=97=E6=97=B6=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GenerationRecord 新增 completed_at 字段,任务完成/失败时记录时间 - 超管/团管/个人消费记录 API 返回 completed_at - RecordsPage、TeamRecordsPage 表格新增"耗时"列 - CSV 导出包含耗时字段 - 历史记录 completed_at 为空显示"-" Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0011_add_completed_at.py | 18 +++++++++++++++ backend/apps/generation/models.py | 1 + backend/apps/generation/views.py | 13 ++++++++--- web/src/pages/RecordsPage.tsx | 22 +++++++++++++++---- web/src/pages/TeamRecordsPage.tsx | 22 +++++++++++++++---- web/src/types/index.ts | 1 + 6 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 backend/apps/generation/migrations/0011_add_completed_at.py diff --git a/backend/apps/generation/migrations/0011_add_completed_at.py b/backend/apps/generation/migrations/0011_add_completed_at.py new file mode 100644 index 0000000..1f4e0d7 --- /dev/null +++ b/backend/apps/generation/migrations/0011_add_completed_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-24 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0010_generationrecord_seed'), + ] + + operations = [ + migrations.AddField( + model_name='generationrecord', + name='completed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='完成时间'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index a4cf854..4b6a974 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -47,6 +47,7 @@ class GenerationRecord(models.Model): is_favorited = models.BooleanField(default=False, verbose_name='已收藏') seed = models.BigIntegerField(default=-1, verbose_name='种子值') created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') + completed_at = models.DateTimeField(null=True, blank=True, verbose_name='完成时间') class Meta: verbose_name = '生成记录' diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index a421a5f..f536f28 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -366,17 +366,19 @@ def video_generate_view(request): except Exception as e: logger.exception('AirDrama API create task failed') record.status = 'failed' + record.completed_at = timezone.now() from utils.airdrama_client import AirDramaAPIError if isinstance(e, AirDramaAPIError): record.error_message = e.user_message else: record.error_message = str(e) - record.save(update_fields=['status', 'error_message']) + record.save(update_fields=['status', 'completed_at', 'error_message']) # API 调用失败,释放冻结 _release_freeze(record) else: record.status = 'completed' - record.save(update_fields=['status']) + record.completed_at = timezone.now() + record.save(update_fields=['status', 'completed_at']) return Response({ 'task_id': str(record.task_id), @@ -530,7 +532,9 @@ def video_task_detail_view(request, task_id): # Seedance 未计费,释放冻结 _release_freeze(record) - record.save(update_fields=['status', 'result_url', 'error_message', 'seed']) + if new_status in ('completed', 'failed'): + record.completed_at = timezone.now() + record.save(update_fields=['status', 'result_url', 'error_message', 'seed', 'completed_at']) except Exception as e: logger.exception('AirDrama API query failed for %s', ark_task_id) @@ -1605,6 +1609,7 @@ def admin_records_view(request): results.append({ 'id': r.id, 'created_at': r.created_at.isoformat(), + 'completed_at': r.completed_at.isoformat() if r.completed_at else None, 'user_id': r.user_id, 'username': r.user.username, 'team_name': r.user.team.name if r.user.team else None, @@ -1663,6 +1668,7 @@ def team_records_view(request): results.append({ 'id': r.id, 'created_at': r.created_at.isoformat(), + 'completed_at': r.completed_at.isoformat() if r.completed_at else None, 'user_id': r.user_id, 'username': r.user.username, 'seconds_consumed': r.seconds_consumed, @@ -2544,6 +2550,7 @@ def profile_records_view(request): results.append({ 'id': r.id, 'created_at': r.created_at.isoformat(), + 'completed_at': r.completed_at.isoformat() if r.completed_at else None, 'seconds_consumed': r.seconds_consumed, 'tokens_consumed': r.tokens_consumed, 'cost_amount': float(r.cost_amount), diff --git a/web/src/pages/RecordsPage.tsx b/web/src/pages/RecordsPage.tsx index e263670..fee1197 100644 --- a/web/src/pages/RecordsPage.tsx +++ b/web/src/pages/RecordsPage.tsx @@ -58,7 +58,7 @@ export function RecordsPage() { team_id: teamFilter ? Number(teamFilter) : undefined, }); - const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n'; + const header = '时间,耗时,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n'; const rows = data.results.map((r) => { // Escape CSV fields to prevent injection const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); @@ -66,7 +66,8 @@ export function RecordsPage() { const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const profit = ((r.cost_amount || 0) - (r.base_cost_amount || 0)).toFixed(2); - return `${r.created_at},"${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; + const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; + return `${r.created_at},"${elapsed}","${r.team_name || '-'}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${(r.base_cost_amount || 0).toFixed(2)}","${profit}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -85,6 +86,17 @@ export function RecordsPage() { const totalPages = Math.ceil(total / pageSize); const statusMap: Record = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }; + const formatElapsed = (r: AdminRecord) => { + if (!r.completed_at) return '-'; + const ms = new Date(r.completed_at).getTime() - new Date(r.created_at).getTime(); + if (ms < 0) return '-'; + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}秒`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}分${sec > 0 ? sec + '秒' : ''}`; + }; + return (
@@ -118,6 +130,7 @@ export function RecordsPage() { 时间 + 耗时 团队 用户名 消费秒数 @@ -134,17 +147,18 @@ export function RecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 11 }).map((_, j) => ( + {Array.from({ length: 12 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( {new Date(r.created_at).toLocaleString('zh-CN')} + {formatElapsed(r)} {r.team_name || '-'} {r.username} {r.seconds_consumed.toLocaleString()}s diff --git a/web/src/pages/TeamRecordsPage.tsx b/web/src/pages/TeamRecordsPage.tsx index 52695d0..1848f9a 100644 --- a/web/src/pages/TeamRecordsPage.tsx +++ b/web/src/pages/TeamRecordsPage.tsx @@ -47,13 +47,14 @@ export function TeamRecordsPage() { end_date: endDate || undefined, }); - const header = '时间,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n'; + const header = '时间,耗时,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n'; const rows = data.results.map((r) => { const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); - return `${r.created_at},${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; + const elapsed = r.completed_at ? Math.round((new Date(r.completed_at).getTime() - new Date(r.created_at).getTime()) / 1000) + '秒' : ''; + return `${r.created_at},"${elapsed}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`; }).join('\n'); const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' }); @@ -72,6 +73,17 @@ export function TeamRecordsPage() { const totalPages = Math.ceil(total / pageSize); const statusMap: Record = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }; + const formatElapsed = (r: AdminRecord) => { + if (!r.completed_at) return '-'; + const ms = new Date(r.completed_at).getTime() - new Date(r.created_at).getTime(); + if (ms < 0) return '-'; + const totalSec = Math.round(ms / 1000); + if (totalSec < 60) return `${totalSec}秒`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}分${sec > 0 ? sec + '秒' : ''}`; + }; + return (
@@ -99,6 +111,7 @@ export function TeamRecordsPage() { 时间 + 耗时 用户名 消费秒数 Tokens @@ -112,17 +125,18 @@ export function TeamRecordsPage() { {loading ? ( Array.from({ length: 5 }).map((_, i) => ( - {Array.from({ length: 8 }).map((_, j) => ( + {Array.from({ length: 9 }).map((_, j) => (
))} )) ) : records.length === 0 ? ( - 暂无记录 + 暂无记录 ) : ( records.map((r) => ( {new Date(r.created_at).toLocaleString('zh-CN')} + {formatElapsed(r)} {r.username} {r.seconds_consumed.toLocaleString()}s {(r.tokens_consumed || 0).toLocaleString()} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 249de9d..9243409 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -182,6 +182,7 @@ export interface AdminUserDetail extends AdminUser { export interface AdminRecord { id: number; created_at: string; + completed_at?: string | null; user_id?: number; username?: string; team_name?: string;