feat: v0.13.3 消费记录详情弹窗 + 参考素材预览下载 + CSV 全量导出
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
- 消费记录点击行弹出任务详情弹窗(任务ID、状态、错误原因+原始错误、基本信息、完整提示词、参考素材) - ReferenceList 共用组件:图片点击大图、视频/音频点击播放、下载按钮 - VideoDetailModal 参考素材加播放和下载按钮 - 素材库引用图片修复:用 thumb_url 替代 asset:// 显示,轮询时也更新 references - raw_error 字段:存储火山原始错误信息,仅管理员弹窗可见 - CSV 导出扩充至 21 列(超管)/ 17 列(团管):新增任务ID、完成时间、视频时长、比例、种子值、原始错误、参考素材数 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49616128da
commit
911f3c158b
18
backend/apps/generation/migrations/0012_add_raw_error.py
Normal file
18
backend/apps/generation/migrations/0012_add_raw_error.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-03-25 02:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('generation', '0011_add_completed_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='generationrecord',
|
||||||
|
name='raw_error',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='原始错误信息'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -43,6 +43,7 @@ class GenerationRecord(models.Model):
|
|||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态')
|
||||||
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
|
||||||
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
|
||||||
|
raw_error = models.TextField(blank=True, default='', verbose_name='原始错误信息')
|
||||||
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
|
||||||
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='种子值')
|
||||||
|
|||||||
@ -370,9 +370,11 @@ def video_generate_view(request):
|
|||||||
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
|
||||||
|
record.raw_error = f'{e.code}: {e.api_message}' if hasattr(e, 'code') else str(e)
|
||||||
else:
|
else:
|
||||||
record.error_message = str(e)
|
record.error_message = '生成失败,请重试'
|
||||||
record.save(update_fields=['status', 'completed_at', 'error_message'])
|
record.raw_error = str(e)
|
||||||
|
record.save(update_fields=['status', 'completed_at', 'error_message', 'raw_error'])
|
||||||
# API 调用失败,释放冻结
|
# API 调用失败,释放冻结
|
||||||
_release_freeze(record)
|
_release_freeze(record)
|
||||||
else:
|
else:
|
||||||
@ -522,6 +524,7 @@ def video_task_detail_view(request, task_id):
|
|||||||
raw_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
raw_msg = error.get('message', '') if isinstance(error, dict) else str(error)
|
||||||
from utils.airdrama_client import ERROR_MESSAGES
|
from utils.airdrama_client import ERROR_MESSAGES
|
||||||
record.error_message = ERROR_MESSAGES.get(code, raw_msg)
|
record.error_message = ERROR_MESSAGES.get(code, raw_msg)
|
||||||
|
record.raw_error = f'{code}: {raw_msg}' if code else raw_msg
|
||||||
# 失败时检查是否产生了 token 消耗
|
# 失败时检查是否产生了 token 消耗
|
||||||
usage = ark_resp.get('usage', {})
|
usage = ark_resp.get('usage', {})
|
||||||
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
|
total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0
|
||||||
@ -534,7 +537,7 @@ def video_task_detail_view(request, task_id):
|
|||||||
|
|
||||||
if new_status in ('completed', 'failed'):
|
if new_status in ('completed', 'failed'):
|
||||||
record.completed_at = timezone.now()
|
record.completed_at = timezone.now()
|
||||||
record.save(update_fields=['status', 'result_url', 'error_message', 'seed', 'completed_at'])
|
record.save(update_fields=['status', 'result_url', 'error_message', 'raw_error', '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)
|
||||||
|
|
||||||
@ -1623,6 +1626,11 @@ def admin_records_view(request):
|
|||||||
'aspect_ratio': r.aspect_ratio,
|
'aspect_ratio': r.aspect_ratio,
|
||||||
'status': r.status,
|
'status': r.status,
|
||||||
'error_message': r.error_message or '',
|
'error_message': r.error_message or '',
|
||||||
|
'raw_error': r.raw_error or '',
|
||||||
|
'reference_urls': r.reference_urls or [],
|
||||||
|
'duration': r.duration,
|
||||||
|
'seed': r.seed,
|
||||||
|
'ark_task_id': r.ark_task_id or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1680,6 +1688,11 @@ def team_records_view(request):
|
|||||||
'aspect_ratio': r.aspect_ratio,
|
'aspect_ratio': r.aspect_ratio,
|
||||||
'status': r.status,
|
'status': r.status,
|
||||||
'error_message': r.error_message or '',
|
'error_message': r.error_message or '',
|
||||||
|
'raw_error': r.raw_error or '',
|
||||||
|
'reference_urls': r.reference_urls or [],
|
||||||
|
'duration': r.duration,
|
||||||
|
'seed': r.seed,
|
||||||
|
'ark_task_id': r.ark_task_id or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
146
web/src/components/RecordDetailModal.tsx
Normal file
146
web/src/components/RecordDetailModal.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import type { AdminRecord } from '../types';
|
||||||
|
import { ReferenceList } from './ReferenceList';
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
completed: { label: '已完成', color: '#00b894', bg: 'rgba(0,184,148,0.15)' },
|
||||||
|
failed: { label: '失败', color: '#e74c3c', bg: 'rgba(231,76,60,0.15)' },
|
||||||
|
processing: { label: '生成中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' },
|
||||||
|
queued: { label: '排队中', color: '#00b8e6', bg: 'rgba(0,184,230,0.15)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_MAP: Record<string, string> = { universal: '全能参考', keyframe: '首尾帧' };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: AdminRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
showTeam?: boolean;
|
||||||
|
showCost?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Props) {
|
||||||
|
const st = STATUS_MAP[r.status] || STATUS_MAP.processing;
|
||||||
|
|
||||||
|
const elapsed = (() => {
|
||||||
|
if (!r.completed_at) return '-';
|
||||||
|
const ms = new Date(r.completed_at).getTime() - new Date(r.created_at).getTime();
|
||||||
|
if (ms < 0) return '-';
|
||||||
|
const sec = Math.round(ms / 1000);
|
||||||
|
if (sec < 60) return `${sec}秒`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${min}分${s > 0 ? s + '秒' : ''}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const refs = r.reference_urls || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={overlay} onClick={onClose}>
|
||||||
|
<div style={modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={header}>
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600, color: '#e2e2ea' }}>任务详情</span>
|
||||||
|
<button style={closeBtn} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={body}>
|
||||||
|
{/* Status */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{r.status === 'failed' && r.error_message && (
|
||||||
|
<div style={errorBox}>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: 4 }}>失败原因</div>
|
||||||
|
<div>{r.error_message}</div>
|
||||||
|
{r.raw_error && r.raw_error !== r.error_message && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#888', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||||
|
原始错误:{r.raw_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info Grid */}
|
||||||
|
<div style={sectionTitle}>基本信息</div>
|
||||||
|
<div style={infoGrid}>
|
||||||
|
{r.ark_task_id && <InfoItem label="任务ID" value={r.ark_task_id} />}
|
||||||
|
{r.username && <InfoItem label="用户" value={r.username} />}
|
||||||
|
{showTeam && r.team_name && <InfoItem label="团队" value={r.team_name} />}
|
||||||
|
<InfoItem label="提交时间" value={new Date(r.created_at).toLocaleString('zh-CN')} />
|
||||||
|
<InfoItem label="耗时" value={elapsed} />
|
||||||
|
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
|
||||||
|
<InfoItem label="比例" value={r.aspect_ratio || '-'} />
|
||||||
|
<InfoItem label="时长" value={r.duration != null ? `${r.duration}秒` : '-'} />
|
||||||
|
<InfoItem label="Tokens" value={(r.tokens_consumed || 0).toLocaleString()} />
|
||||||
|
{showCost && <InfoItem label="费用" value={`¥${(r.cost_amount || 0).toFixed(2)}`} />}
|
||||||
|
{r.seed != null && r.seed !== -1 && <InfoItem label="种子值" value={String(r.seed)} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompt */}
|
||||||
|
<div style={sectionTitle}>提示词</div>
|
||||||
|
<div style={promptBox}>{r.prompt || '(无提示词)'}</div>
|
||||||
|
|
||||||
|
{/* References */}
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={sectionTitle}>参考素材({refs.length})</div>
|
||||||
|
<ReferenceList references={refs} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#888', marginBottom: 2 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#e2e2ea', wordBreak: 'break-all' }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const overlay: React.CSSProperties = {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', zIndex: 10000,
|
||||||
|
};
|
||||||
|
const modal: React.CSSProperties = {
|
||||||
|
background: '#111118', border: '1px solid #2a2a38', borderRadius: 12,
|
||||||
|
width: 560, maxHeight: '80vh', display: 'flex', flexDirection: 'column',
|
||||||
|
};
|
||||||
|
const header: React.CSSProperties = {
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
padding: '16px 20px', borderBottom: '1px solid #2a2a38',
|
||||||
|
};
|
||||||
|
const closeBtn: React.CSSProperties = {
|
||||||
|
background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer',
|
||||||
|
padding: '4px 8px', borderRadius: 4,
|
||||||
|
};
|
||||||
|
const body: React.CSSProperties = {
|
||||||
|
padding: 20, overflowY: 'auto', flex: 1,
|
||||||
|
};
|
||||||
|
const statusBadge: React.CSSProperties = {
|
||||||
|
padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500,
|
||||||
|
};
|
||||||
|
const errorBox: React.CSSProperties = {
|
||||||
|
background: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.2)',
|
||||||
|
borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: '#e74c3c',
|
||||||
|
};
|
||||||
|
const sectionTitle: React.CSSProperties = {
|
||||||
|
fontSize: 12, color: '#888', fontWeight: 500, marginBottom: 8, marginTop: 16,
|
||||||
|
textTransform: 'uppercase', letterSpacing: 1,
|
||||||
|
};
|
||||||
|
const infoGrid: React.CSSProperties = {
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px',
|
||||||
|
};
|
||||||
|
const promptBox: React.CSSProperties = {
|
||||||
|
background: '#0a0a0f', borderRadius: 8, padding: 12, fontSize: 13,
|
||||||
|
color: '#ccc', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||||
|
maxHeight: 150, overflowY: 'auto',
|
||||||
|
};
|
||||||
159
web/src/components/ReferenceList.tsx
Normal file
159
web/src/components/ReferenceList.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface RefItem {
|
||||||
|
type?: string;
|
||||||
|
url?: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
thumb_url?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
references: RefItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferenceList({ references }: Props) {
|
||||||
|
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
||||||
|
const [playingMedia, setPlayingMedia] = useState<{ url: string; type: 'video' | 'audio' } | null>(null);
|
||||||
|
|
||||||
|
if (references.length === 0) return null;
|
||||||
|
|
||||||
|
const handleDownload = (url: string, label: string) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = label;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={refsGrid}>
|
||||||
|
{references.map((ref, i) => {
|
||||||
|
const thumbUrl = ref.thumb_url || ref.url || '';
|
||||||
|
const fullUrl = ref.url || '';
|
||||||
|
const isAudio = ref.type === 'audio';
|
||||||
|
const isVideo = ref.type === 'video';
|
||||||
|
const label = ref.label || ref.name || ref.type || `素材${i + 1}`;
|
||||||
|
const hasUrl = fullUrl && !fullUrl.startsWith('asset://');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} style={refItem}>
|
||||||
|
{/* Thumbnail area */}
|
||||||
|
<div style={thumbWrap}>
|
||||||
|
{isAudio ? (
|
||||||
|
<div
|
||||||
|
style={{ ...placeholder, cursor: hasUrl ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => hasUrl && setPlayingMedia({ url: fullUrl, type: 'audio' })}
|
||||||
|
>♫</div>
|
||||||
|
) : isVideo ? (
|
||||||
|
<div
|
||||||
|
style={{ ...placeholder, cursor: hasUrl ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => hasUrl && setPlayingMedia({ url: fullUrl, type: 'video' })}
|
||||||
|
>▶</div>
|
||||||
|
) : thumbUrl && !thumbUrl.startsWith('asset://') ? (
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt=""
|
||||||
|
style={refImgStyle}
|
||||||
|
onClick={() => thumbUrl && !thumbUrl.startsWith('asset://') && setLightboxUrl(thumbUrl)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={placeholder}>?</div>
|
||||||
|
)}
|
||||||
|
{/* Download button */}
|
||||||
|
{hasUrl && (
|
||||||
|
<button
|
||||||
|
style={downloadBtn}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDownload(fullUrl, label); }}
|
||||||
|
title="下载"
|
||||||
|
>↓</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={refLabel}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image lightbox */}
|
||||||
|
{lightboxUrl && (
|
||||||
|
<div style={overlay} onClick={() => setLightboxUrl(null)}>
|
||||||
|
<img src={lightboxUrl} alt="" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video/Audio player modal */}
|
||||||
|
{playingMedia && (
|
||||||
|
<div style={overlay} onClick={() => setPlayingMedia(null)}>
|
||||||
|
<div style={playerWrap} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button style={playerClose} onClick={() => setPlayingMedia(null)}>✕</button>
|
||||||
|
{playingMedia.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={playingMedia.url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={audioWrap}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>♫</div>
|
||||||
|
<audio src={playingMedia.url} controls autoPlay style={{ width: 320 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const overlay: React.CSSProperties = {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', zIndex: 10002,
|
||||||
|
};
|
||||||
|
const refsGrid: React.CSSProperties = {
|
||||||
|
display: 'flex', gap: 8, flexWrap: 'wrap',
|
||||||
|
};
|
||||||
|
const refItem: React.CSSProperties = {
|
||||||
|
width: 80, textAlign: 'center',
|
||||||
|
};
|
||||||
|
const thumbWrap: React.CSSProperties = {
|
||||||
|
position: 'relative', width: 80, height: 80,
|
||||||
|
};
|
||||||
|
const refImgStyle: React.CSSProperties = {
|
||||||
|
width: 80, height: 80, objectFit: 'cover', borderRadius: 6, cursor: 'pointer',
|
||||||
|
border: '1px solid #2a2a38',
|
||||||
|
};
|
||||||
|
const placeholder: React.CSSProperties = {
|
||||||
|
width: 80, height: 80, borderRadius: 6, background: '#1a1a2e',
|
||||||
|
border: '1px solid #2a2a38', display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', fontSize: 24, color: '#888',
|
||||||
|
};
|
||||||
|
const downloadBtn: React.CSSProperties = {
|
||||||
|
position: 'absolute', bottom: 4, right: 4,
|
||||||
|
width: 22, height: 22, borderRadius: 4,
|
||||||
|
background: 'rgba(0,0,0,0.6)', border: 'none',
|
||||||
|
color: '#fff', fontSize: 12, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
};
|
||||||
|
const refLabel: React.CSSProperties = {
|
||||||
|
fontSize: 10, color: '#888', marginTop: 4, overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
};
|
||||||
|
const playerWrap: React.CSSProperties = {
|
||||||
|
position: 'relative', background: '#111118', borderRadius: 12,
|
||||||
|
padding: 24, border: '1px solid #2a2a38',
|
||||||
|
};
|
||||||
|
const playerClose: React.CSSProperties = {
|
||||||
|
position: 'absolute', top: 8, right: 12,
|
||||||
|
background: 'none', border: 'none', color: '#888',
|
||||||
|
fontSize: 16, cursor: 'pointer',
|
||||||
|
};
|
||||||
|
const audioWrap: React.CSSProperties = {
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
padding: '20px 40px', color: '#888',
|
||||||
|
};
|
||||||
@ -39,6 +39,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
|
const [refMediaPreview, setRefMediaPreview] = useState<{ url: string; type: 'video' | 'audio' } | null>(null);
|
||||||
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
|
const [fitSize, setFitSize] = useState<{ w: number; h: number } | null>(null);
|
||||||
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
|
const [intrinsicRatio, setIntrinsicRatio] = useState<number | null>(null);
|
||||||
const moreMenuRef = useRef<HTMLDivElement>(null);
|
const moreMenuRef = useRef<HTMLDivElement>(null);
|
||||||
@ -483,10 +484,11 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
<div className={styles.refGrid}>
|
<div className={styles.refGrid}>
|
||||||
{task.references.map((ref) => (
|
{task.references.map((ref) => (
|
||||||
<div key={ref.id} className={styles.refItem}>
|
<div key={ref.id} className={styles.refItem}>
|
||||||
|
<div style={{ position: 'relative', width: 56, height: 56 }}>
|
||||||
{ref.type === 'video' ? (
|
{ref.type === 'video' ? (
|
||||||
<video src={ref.previewUrl} className={styles.refImg} muted />
|
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
|
||||||
) : ref.type === 'audio' ? (
|
) : ref.type === 'audio' ? (
|
||||||
<div className={styles.refAudioPlaceholder}>
|
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||||
<path d="M9 18V5l12-2v13" />
|
<path d="M9 18V5l12-2v13" />
|
||||||
<circle cx="6" cy="18" r="3" />
|
<circle cx="6" cy="18" r="3" />
|
||||||
@ -498,6 +500,13 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
) : (
|
) : (
|
||||||
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}>无预览</div>
|
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}>无预览</div>
|
||||||
)}
|
)}
|
||||||
|
{ref.previewUrl && (
|
||||||
|
<a href={ref.previewUrl} download={ref.label} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ position: 'absolute', bottom: 2, right: 2, width: 18, height: 18, borderRadius: 3, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', textDecoration: 'none' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>↓</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className={styles.refLabel}>{ref.label}</span>
|
<span className={styles.refLabel}>{ref.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -555,6 +564,21 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
onCancel={() => setConfirmDelete(false)}
|
onCancel={() => setConfirmDelete(false)}
|
||||||
/>
|
/>
|
||||||
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
||||||
|
{refMediaPreview && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }} onClick={() => setRefMediaPreview(null)}>
|
||||||
|
<div style={{ position: 'relative', background: '#111118', borderRadius: 12, padding: 24, border: '1px solid #2a2a38' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button style={{ position: 'absolute', top: 8, right: 12, background: 'none', border: 'none', color: '#888', fontSize: 16, cursor: 'pointer' }} onClick={() => setRefMediaPreview(null)}>✕</button>
|
||||||
|
{refMediaPreview.type === 'video' ? (
|
||||||
|
<video src={refMediaPreview.url} controls autoPlay style={{ maxWidth: '80vw', maxHeight: '70vh', borderRadius: 8 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px 40px', color: '#888' }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 16 }}>♫</div>
|
||||||
|
<audio src={refMediaPreview.url} controls autoPlay style={{ width: 320 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import type { AdminRecord, Team } from '../types';
|
|||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
import { DatePicker } from '../components/DatePicker';
|
import { DatePicker } from '../components/DatePicker';
|
||||||
import { Select } from '../components/Select';
|
import { Select } from '../components/Select';
|
||||||
|
import { RecordDetailModal } from '../components/RecordDetailModal';
|
||||||
import styles from './RecordsPage.module.css';
|
import styles from './RecordsPage.module.css';
|
||||||
|
|
||||||
export function RecordsPage() {
|
export function RecordsPage() {
|
||||||
const [records, setRecords] = useState<AdminRecord[]>([]);
|
const [records, setRecords] = useState<AdminRecord[]>([]);
|
||||||
|
const [detailRecord, setDetailRecord] = useState<AdminRecord | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -58,16 +60,16 @@ export function RecordsPage() {
|
|||||||
team_id: teamFilter ? Number(teamFilter) : undefined,
|
team_id: teamFilter ? Number(teamFilter) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = '时间,耗时,团队,用户名,消费秒数,Tokens,费用(元),成本(元),利润(元),提示词,生成模式,状态,失败原因\n';
|
const header = '任务ID,提交时间,完成时间,耗时,团队,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),成本(元),利润(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
|
||||||
const rows = data.results.map((r) => {
|
const rows = data.results.map((r) => {
|
||||||
// Escape CSV fields to prevent injection
|
const esc = (s: string) => s.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 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) + '秒' : '';
|
||||||
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}"`;
|
const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
|
||||||
|
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}"`;
|
||||||
}).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;' });
|
||||||
@ -97,7 +99,7 @@ export function RecordsPage() {
|
|||||||
return `${min}分${sec > 0 ? sec + '秒' : ''}`;
|
return `${min}分${sec > 0 ? sec + '秒' : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (<>
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.title}>消费记录</h1>
|
<h1 className={styles.title}>消费记录</h1>
|
||||||
@ -156,7 +158,7 @@ export function RecordsPage() {
|
|||||||
<tr><td colSpan={12} 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} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}>
|
||||||
<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>{formatElapsed(r)}</td>
|
||||||
<td>{r.team_name || '-'}</td>
|
<td>{r.team_name || '-'}</td>
|
||||||
@ -205,5 +207,9 @@ export function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{detailRecord && (
|
||||||
|
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} showTeam showCost />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import { teamApi } from '../lib/api';
|
|||||||
import type { AdminRecord } from '../types';
|
import type { AdminRecord } from '../types';
|
||||||
import { showToast } from '../components/Toast';
|
import { showToast } from '../components/Toast';
|
||||||
import { DatePicker } from '../components/DatePicker';
|
import { DatePicker } from '../components/DatePicker';
|
||||||
|
import { RecordDetailModal } from '../components/RecordDetailModal';
|
||||||
import styles from './RecordsPage.module.css';
|
import styles from './RecordsPage.module.css';
|
||||||
|
|
||||||
export function TeamRecordsPage() {
|
export function TeamRecordsPage() {
|
||||||
const [records, setRecords] = useState<AdminRecord[]>([]);
|
const [records, setRecords] = useState<AdminRecord[]>([]);
|
||||||
|
const [detailRecord, setDetailRecord] = useState<AdminRecord | null>(null);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@ -47,14 +49,15 @@ export function TeamRecordsPage() {
|
|||||||
end_date: endDate || undefined,
|
end_date: endDate || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = '时间,耗时,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
|
const header = '任务ID,提交时间,完成时间,耗时,用户名,视频时长(秒),模式,比例,消费秒数,Tokens,费用(元),种子值,状态,提示词,失败原因,原始错误,参考素材数\n';
|
||||||
const rows = data.results.map((r) => {
|
const rows = data.results.map((r) => {
|
||||||
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
|
const esc = (s: string) => s.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 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) + '秒' : '';
|
||||||
return `${r.created_at},"${elapsed}",${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
|
const completedAt = r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '';
|
||||||
|
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}"`;
|
||||||
}).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;' });
|
||||||
@ -84,7 +87,7 @@ export function TeamRecordsPage() {
|
|||||||
return `${min}分${sec > 0 ? sec + '秒' : ''}`;
|
return `${min}分${sec > 0 ? sec + '秒' : ''}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (<>
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.title}>消费记录</h1>
|
<h1 className={styles.title}>消费记录</h1>
|
||||||
@ -134,7 +137,7 @@ export function TeamRecordsPage() {
|
|||||||
<tr><td colSpan={9} 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} onClick={() => setDetailRecord(r)} style={{ cursor: 'pointer' }}>
|
||||||
<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>{formatElapsed(r)}</td>
|
||||||
<td>{r.username}</td>
|
<td>{r.username}</td>
|
||||||
@ -180,5 +183,9 @@ export function TeamRecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{detailRecord && (
|
||||||
|
<RecordDetailModal record={detailRecord} onClose={() => setDetailRecord(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,17 +62,26 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
|
|||||||
const references: ReferenceSnapshot[] = allRefs
|
const references: ReferenceSnapshot[] = allRefs
|
||||||
.filter((ref) => {
|
.filter((ref) => {
|
||||||
const url = ref.url || '';
|
const url = ref.url || '';
|
||||||
|
const thumbUrl = (ref as Record<string, string>).thumb_url || '';
|
||||||
|
// 保留有真实 URL 或有缩略图的引用
|
||||||
|
if (thumbUrl) return true;
|
||||||
if (!url || url.trim() === '') return false;
|
if (!url || url.trim() === '') return false;
|
||||||
if (url.startsWith('asset://') || url.startsWith('Asset://')) return false;
|
if (url.startsWith('asset://') || url.startsWith('Asset://')) return false;
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((ref, i) => ({
|
.map((ref, i) => {
|
||||||
|
const url = ref.url || '';
|
||||||
|
const thumbUrl = (ref as Record<string, string>).thumb_url || '';
|
||||||
|
// 素材库引用用 thumb_url,普通上传用 url
|
||||||
|
const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url;
|
||||||
|
return {
|
||||||
id: `ref_${bt.task_id}_${i}`,
|
id: `ref_${bt.task_id}_${i}`,
|
||||||
type: (ref.type || 'image') as 'image' | 'video',
|
type: (ref.type || 'image') as 'image' | 'video',
|
||||||
previewUrl: ref.url,
|
previewUrl: displayUrl,
|
||||||
label: ref.label || `素材${i + 1}`,
|
label: ref.label || `素材${i + 1}`,
|
||||||
role: ref.role,
|
role: ref.role,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span
|
// Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span
|
||||||
const assetMentions = allRefs
|
const assetMentions = allRefs
|
||||||
@ -160,6 +169,29 @@ function startPolling(taskId: string, frontendId: string) {
|
|||||||
const { data } = await videoApi.getTaskStatus(taskId);
|
const { data } = await videoApi.getTaskStatus(taskId);
|
||||||
const newStatus = mapStatus(data.status);
|
const newStatus = mapStatus(data.status);
|
||||||
|
|
||||||
|
// Parse references from polling response (includes thumb_url for asset refs)
|
||||||
|
const pollRefs: ReferenceSnapshot[] = (data.reference_urls || [])
|
||||||
|
.filter((ref: Record<string, string>) => {
|
||||||
|
const url = ref.url || '';
|
||||||
|
const thumbUrl = ref.thumb_url || '';
|
||||||
|
if (thumbUrl) return true;
|
||||||
|
if (!url || url.trim() === '') return false;
|
||||||
|
if (url.startsWith('asset://') || url.startsWith('Asset://')) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((ref: Record<string, string>, i: number) => {
|
||||||
|
const url = ref.url || '';
|
||||||
|
const thumbUrl = ref.thumb_url || '';
|
||||||
|
const displayUrl = (url.startsWith('asset://') || url.startsWith('Asset://')) ? thumbUrl : url;
|
||||||
|
return {
|
||||||
|
id: `ref_${taskId}_${i}`,
|
||||||
|
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
||||||
|
previewUrl: displayUrl,
|
||||||
|
label: ref.label || `素材${i + 1}`,
|
||||||
|
role: ref.role,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
useGenerationStore.setState((s) => ({
|
useGenerationStore.setState((s) => ({
|
||||||
tasks: s.tasks.map((t) =>
|
tasks: s.tasks.map((t) =>
|
||||||
t.id === frontendId
|
t.id === frontendId
|
||||||
@ -172,6 +204,7 @@ function startPolling(taskId: string, frontendId: string) {
|
|||||||
tokensConsumed: data.tokens_consumed ?? t.tokensConsumed,
|
tokensConsumed: data.tokens_consumed ?? t.tokensConsumed,
|
||||||
costAmount: data.cost_amount ?? t.costAmount,
|
costAmount: data.cost_amount ?? t.costAmount,
|
||||||
seed: data.seed ?? t.seed,
|
seed: data.seed ?? t.seed,
|
||||||
|
references: pollRefs.length > 0 ? pollRefs : t.references,
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
|
|||||||
@ -196,6 +196,11 @@ export interface AdminRecord {
|
|||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
status: 'queued' | 'processing' | 'completed' | 'failed';
|
status: 'queued' | 'processing' | 'completed' | 'failed';
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
|
raw_error?: string;
|
||||||
|
reference_urls?: Array<{ type?: string; url?: string; name?: string; label?: string; thumb_url?: string; role?: string }>;
|
||||||
|
duration?: number;
|
||||||
|
seed?: number;
|
||||||
|
ark_task_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemSettings {
|
export interface SystemSettings {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user