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='已收藏')
|
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 = '生成记录'
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user