feat: v0.13.2 消费记录增加耗时列
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m31s
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:
parent
7a0be57227
commit
49616128da
18
backend/apps/generation/migrations/0011_add_completed_at.py
Normal file
18
backend/apps/generation/migrations/0011_add_completed_at.py
Normal 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='完成时间'),
|
||||
),
|
||||
]
|
||||
@ -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 = '生成记录'
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
@ -118,6 +130,7 @@ export function RecordsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>耗时</th>
|
||||
<th>团队</th>
|
||||
<th>用户名</th>
|
||||
<th>消费秒数</th>
|
||||
@ -134,17 +147,18 @@ export function RecordsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 11 }).map((_, j) => (
|
||||
{Array.from({ length: 12 }).map((_, j) => (
|
||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={11} className={styles.empty}>暂无记录</td></tr>
|
||||
<tr><td colSpan={12} className={styles.empty}>暂无记录</td></tr>
|
||||
) : (
|
||||
records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>{formatElapsed(r)}</td>
|
||||
<td>{r.team_name || '-'}</td>
|
||||
<td>{r.username}</td>
|
||||
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
||||
|
||||
@ -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<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 (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
@ -99,6 +111,7 @@ export function TeamRecordsPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>耗时</th>
|
||||
<th>用户名</th>
|
||||
<th>消费秒数</th>
|
||||
<th>Tokens</th>
|
||||
@ -112,17 +125,18 @@ export function TeamRecordsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 8 }).map((_, j) => (
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
<td key={j}><div className={styles.skeletonCell} /></td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={8} className={styles.empty}>暂无记录</td></tr>
|
||||
<tr><td colSpan={9} className={styles.empty}>暂无记录</td></tr>
|
||||
) : (
|
||||
records.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>{formatElapsed(r)}</td>
|
||||
<td>{r.username}</td>
|
||||
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
|
||||
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user