feat: v0.13.2 消费记录增加耗时列
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m31s

- GenerationRecord 新增 completed_at 字段,任务完成/失败时记录时间
- 超管/团管/个人消费记录 API 返回 completed_at
- RecordsPage、TeamRecordsPage 表格新增"耗时"列
- CSV 导出包含耗时字段
- 历史记录 completed_at 为空显示"-"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-25 01:56:26 +08:00
parent 7a0be57227
commit 49616128da
6 changed files with 66 additions and 11 deletions

View File

@ -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='完成时间'),
),
]

View File

@ -47,6 +47,7 @@ class GenerationRecord(models.Model):
is_favorited = models.BooleanField(default=False, verbose_name='已收藏') is_favorited = models.BooleanField(default=False, verbose_name='已收藏')
seed = models.BigIntegerField(default=-1, verbose_name='种子值') seed = models.BigIntegerField(default=-1, verbose_name='种子值')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, 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: class Meta:
verbose_name = '生成记录' verbose_name = '生成记录'

View File

@ -366,17 +366,19 @@ def video_generate_view(request):
except Exception as e: except Exception as e:
logger.exception('AirDrama API create task failed') logger.exception('AirDrama API create task failed')
record.status = 'failed' record.status = 'failed'
record.completed_at = timezone.now()
from utils.airdrama_client import AirDramaAPIError from utils.airdrama_client import AirDramaAPIError
if isinstance(e, AirDramaAPIError): if isinstance(e, AirDramaAPIError):
record.error_message = e.user_message record.error_message = e.user_message
else: else:
record.error_message = str(e) record.error_message = str(e)
record.save(update_fields=['status', 'error_message']) record.save(update_fields=['status', 'completed_at', 'error_message'])
# API 调用失败,释放冻结 # API 调用失败,释放冻结
_release_freeze(record) _release_freeze(record)
else: else:
record.status = 'completed' record.status = 'completed'
record.save(update_fields=['status']) record.completed_at = timezone.now()
record.save(update_fields=['status', 'completed_at'])
return Response({ return Response({
'task_id': str(record.task_id), 'task_id': str(record.task_id),
@ -530,7 +532,9 @@ def video_task_detail_view(request, task_id):
# Seedance 未计费,释放冻结 # Seedance 未计费,释放冻结
_release_freeze(record) _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: except Exception as e:
logger.exception('AirDrama API query failed for %s', ark_task_id) logger.exception('AirDrama API query failed for %s', ark_task_id)
@ -1605,6 +1609,7 @@ def admin_records_view(request):
results.append({ results.append({
'id': r.id, 'id': r.id,
'created_at': r.created_at.isoformat(), 'created_at': r.created_at.isoformat(),
'completed_at': r.completed_at.isoformat() if r.completed_at else None,
'user_id': r.user_id, 'user_id': r.user_id,
'username': r.user.username, 'username': r.user.username,
'team_name': r.user.team.name if r.user.team else None, 'team_name': r.user.team.name if r.user.team else None,
@ -1663,6 +1668,7 @@ def team_records_view(request):
results.append({ results.append({
'id': r.id, 'id': r.id,
'created_at': r.created_at.isoformat(), 'created_at': r.created_at.isoformat(),
'completed_at': r.completed_at.isoformat() if r.completed_at else None,
'user_id': r.user_id, 'user_id': r.user_id,
'username': r.user.username, 'username': r.user.username,
'seconds_consumed': r.seconds_consumed, 'seconds_consumed': r.seconds_consumed,
@ -2544,6 +2550,7 @@ def profile_records_view(request):
results.append({ results.append({
'id': r.id, 'id': r.id,
'created_at': r.created_at.isoformat(), 'created_at': r.created_at.isoformat(),
'completed_at': r.completed_at.isoformat() if r.completed_at else None,
'seconds_consumed': r.seconds_consumed, 'seconds_consumed': r.seconds_consumed,
'tokens_consumed': r.tokens_consumed, 'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount), 'cost_amount': float(r.cost_amount),

View File

@ -58,7 +58,7 @@ export function RecordsPage() {
team_id: teamFilter ? Number(teamFilter) : undefined, team_id: teamFilter ? Number(teamFilter) : undefined,
}); });
const header = '时间,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n'; const header = '时间,耗时,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
// Escape CSV fields to prevent injection // Escape CSV fields to prevent injection
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
@ -66,7 +66,8 @@ export function RecordsPage() {
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
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);
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'); }).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;' });
@ -85,6 +86,17 @@ export function RecordsPage() {
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }; const statusMap: Record<string, string> = { 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 ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <div className={styles.header}>
@ -118,6 +130,7 @@ export function RecordsPage() {
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
@ -134,17 +147,18 @@ 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: 11 }).map((_, j) => ( {Array.from({ length: 12 }).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={11} className={styles.empty}></td></tr> <tr><td colSpan={12} className={styles.empty}></td></tr>
) : ( ) : (
records.map((r) => ( records.map((r) => (
<tr key={r.id}> <tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td> <td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{formatElapsed(r)}</td>
<td>{r.team_name || '-'}</td> <td>{r.team_name || '-'}</td>
<td>{r.username}</td> <td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td> <td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>

View File

@ -47,13 +47,14 @@ export function TeamRecordsPage() {
end_date: endDate || undefined, end_date: endDate || undefined,
}); });
const header = '时间,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n'; const header = '时间,耗时,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
const rows = data.results.map((r) => { const rows = data.results.map((r) => {
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧'; const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status]; const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&"); 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'); }).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;' });
@ -72,6 +73,17 @@ export function TeamRecordsPage() {
const totalPages = Math.ceil(total / pageSize); const totalPages = Math.ceil(total / pageSize);
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }; const statusMap: Record<string, string> = { 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 ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <div className={styles.header}>
@ -99,6 +111,7 @@ export function TeamRecordsPage() {
<thead> <thead>
<tr> <tr>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
<th>Tokens</th> <th>Tokens</th>
@ -112,17 +125,18 @@ 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: 8 }).map((_, j) => ( {Array.from({ length: 9 }).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={8} className={styles.empty}></td></tr> <tr><td colSpan={9} className={styles.empty}></td></tr>
) : ( ) : (
records.map((r) => ( records.map((r) => (
<tr key={r.id}> <tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td> <td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{formatElapsed(r)}</td>
<td>{r.username}</td> <td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td> <td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td>{(r.tokens_consumed || 0).toLocaleString()}</td> <td>{(r.tokens_consumed || 0).toLocaleString()}</td>

View File

@ -182,6 +182,7 @@ export interface AdminUserDetail extends AdminUser {
export interface AdminRecord { export interface AdminRecord {
id: number; id: number;
created_at: string; created_at: string;
completed_at?: string | null;
user_id?: number; user_id?: number;
username?: string; username?: string;
team_name?: string; team_name?: string;