后端:
- GenerationRecord 加 api_prompt TextField(blank, default='')
- 0021_add_api_prompt migration
- video_generate_view 计算完 _format_prompt_for_ark 后立即 save api_prompt
(即使 create_task 抛错也保留,方便事后查实际传了什么)
- admin_records / team_records view 各回传 api_prompt 字段
前端:
- AdminRecord 类型加 api_prompt?: string
- RecordDetailModal 详情弹窗右侧底部加"调试信息(开发/客服参考)"折叠区
- 默认收起,小灰字 ▸/▾ toggle
- 仅当 api_prompt && api_prompt !== prompt 才显示"实际发给火山"等宽字 box
(历史记录 api_prompt 为空则不显示这栏)
- 火山 Task ID + 复制按钮(showToast 反馈)
- 失败任务才显示原始错误(raw_error)
- 平时用户察觉不到,客服/财务复盘时点开就能看完整调试信息
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
460 lines
17 KiB
TypeScript
460 lines
17 KiB
TypeScript
import { useState } from 'react';
|
||
import type { AdminRecord } from '../types';
|
||
import { ReferenceList } from './ReferenceList';
|
||
import { rewriteTosUrl } from '../lib/api';
|
||
import { showToast } from './Toast';
|
||
|
||
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
|
||
completed: { label: '已完成', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
|
||
failed: { label: '失败', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
|
||
processing: { label: '生成中', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
||
queued: { label: '排队中', color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
||
};
|
||
|
||
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 [debugOpen, setDebugOpen] = useState(false);
|
||
// 仅当转换后的 prompt 与原文不同(即 prompt 里有 @ 被转为「图片N」)才单独显示一栏
|
||
const hasConvertedPrompt = !!(r.api_prompt && r.api_prompt !== r.prompt);
|
||
const handleCopyTaskId = () => {
|
||
if (!r.ark_task_id) return;
|
||
navigator.clipboard.writeText(r.ark_task_id).then(() => showToast('已复制'));
|
||
};
|
||
|
||
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: 'var(--color-text-light)' }}>任务详情</span>
|
||
<button style={closeBtn} onClick={onClose}>✕</button>
|
||
</div>
|
||
|
||
{/* Body — 左:视频+参考素材 / 右:信息+提示词 */}
|
||
<div style={body}>
|
||
{/* ── 左侧:视频 + 参考素材 ── */}
|
||
<div style={mediaPanel}>
|
||
<MediaArea record={r} />
|
||
{refs.length > 0 && (
|
||
<>
|
||
<div style={sectionTitle}>参考素材({refs.length})</div>
|
||
<div style={refScrollBox}>
|
||
<ReferenceList references={refs} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── 右侧:信息 + 提示词 ── */}
|
||
<div style={infoPanel}>
|
||
{/* 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: 'var(--color-text-tertiary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||
原始错误:{r.raw_error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Info Grid */}
|
||
<div style={{ ...sectionTitle, marginTop: 0 }}>基本信息</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={r.model === 'seedance_2.0_fast' ? 'AirDrama Fast' : 'AirDrama'} />
|
||
<InfoItem label="模式" value={MODE_MAP[r.mode] || r.mode} />
|
||
<InfoItem label="比例" value={r.aspect_ratio || '-'} />
|
||
<InfoItem label="分辨率" value={r.resolution ? r.resolution.toUpperCase() : '-'} />
|
||
<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>
|
||
|
||
{/* 调试信息(开发/客服参考)— 默认收起 */}
|
||
<div style={debugSection}>
|
||
<button
|
||
style={debugToggle}
|
||
onClick={() => setDebugOpen(!debugOpen)}
|
||
type="button"
|
||
>
|
||
<span style={{ display: 'inline-block', width: 12, color: 'var(--color-text-tertiary)' }}>
|
||
{debugOpen ? '▾' : '▸'}
|
||
</span>
|
||
调试信息(开发/客服参考)
|
||
</button>
|
||
{debugOpen && (
|
||
<div style={debugContent}>
|
||
{hasConvertedPrompt && (
|
||
<>
|
||
<div style={debugLabel}>实际发给火山(@素材名被自动转换为「图片N/视频N/音频N」):</div>
|
||
<div style={debugCodeBox}>{r.api_prompt}</div>
|
||
</>
|
||
)}
|
||
{r.ark_task_id && (
|
||
<div style={debugRow}>
|
||
<span style={debugLabel}>火山 Task ID:</span>
|
||
<span style={debugMono}>{r.ark_task_id}</span>
|
||
<button style={debugCopyBtn} onClick={handleCopyTaskId} type="button">复制</button>
|
||
</div>
|
||
)}
|
||
{r.status === 'failed' && r.raw_error && (
|
||
<>
|
||
<div style={debugLabel}>原始错误:</div>
|
||
<div style={debugCodeBox}>{r.raw_error}</div>
|
||
</>
|
||
)}
|
||
{!hasConvertedPrompt && !r.ark_task_id && (r.status !== 'failed' || !r.raw_error) && (
|
||
<div style={debugLabel}>(暂无调试信息)</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 左侧媒体区 — 根据任务状态决定显示什么:
|
||
* - completed + result_url → 视频播放器(controls,不自动播放)
|
||
* - completed - result_url → "视频已生成"占位
|
||
* - failed → RGB 故障字 "生成失败" + 错误原因摘要 + 斜纹底纹
|
||
* - processing / queued → 旋转 spinner + 文字
|
||
*/
|
||
function MediaArea({ record: r }: { record: AdminRecord }) {
|
||
return (
|
||
<div style={mediaFrame}>
|
||
{r.status === 'completed' && r.result_url ? (
|
||
<video
|
||
src={rewriteTosUrl(r.result_url)}
|
||
poster={r.thumbnail_url ? rewriteTosUrl(r.thumbnail_url) : undefined}
|
||
style={mediaVideo}
|
||
controls
|
||
preload="metadata"
|
||
/>
|
||
) : r.status === 'completed' ? (
|
||
<div style={mediaPlaceholder}>
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polygon points="23 7 16 12 23 17 23 7"/>
|
||
<rect x="1" y="5" width="15" height="14" rx="2"/>
|
||
</svg>
|
||
<span>视频已生成</span>
|
||
</div>
|
||
) : r.status === 'failed' ? (
|
||
<FailureGlitch errorMessage={r.error_message} />
|
||
) : (
|
||
<div style={mediaPlaceholder}>
|
||
<svg className="spinner" width="44" height="44" viewBox="0 0 50 50" style={spinnerStyle}>
|
||
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeDasharray="80 40" />
|
||
</svg>
|
||
<span>{r.status === 'queued' ? '排队中' : '生成中'}</span>
|
||
{/* 内联 keyframes — 没有 module.css */}
|
||
<style>{`@keyframes shoot-spin { to { transform: rotate(360deg); } }`}</style>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* RGB 故障字失败态 — "生成失败"主标题用 cyan/magenta text-shadow 偏移
|
||
* 模拟坏掉的 CRT 信号丢失;副标题等宽字体显示错误摘要。
|
||
*/
|
||
function FailureGlitch({ errorMessage }: { errorMessage?: string }) {
|
||
const msg = (errorMessage || 'Generation failed').slice(0, 80);
|
||
return (
|
||
<div style={failBg}>
|
||
<div style={failStripe} aria-hidden="true" />
|
||
<div style={glitchTitle}>生成失败</div>
|
||
<div style={glitchSub}>{msg}</div>
|
||
<div style={{ ...failStripe, top: 'auto', bottom: 0 }} aria-hidden="true" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 11, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
|
||
<div style={{ fontSize: 13, color: 'var(--color-text-light)', wordBreak: 'break-all' }}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────
|
||
// Styles
|
||
// ─────────────────────────────────────
|
||
|
||
const overlay: React.CSSProperties = {
|
||
position: 'fixed', inset: 0, background: 'var(--color-modal-overlay)', display: 'flex',
|
||
alignItems: 'center', justifyContent: 'center', zIndex: 10000,
|
||
};
|
||
|
||
const modal: React.CSSProperties = {
|
||
background: 'var(--color-bg-modal-glass)',
|
||
backdropFilter: 'blur(24px) saturate(180%)',
|
||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||
border: '1px solid var(--color-border-modal-soft)',
|
||
borderRadius: 12,
|
||
width: 1080,
|
||
maxWidth: '95vw',
|
||
maxHeight: '85vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
boxShadow: 'var(--shadow-glass-light)',
|
||
};
|
||
|
||
const header: React.CSSProperties = {
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '16px 24px', borderBottom: '1px solid var(--color-border-modal-soft)',
|
||
flexShrink: 0,
|
||
};
|
||
|
||
const closeBtn: React.CSSProperties = {
|
||
background: 'none', border: 'none', color: 'var(--color-text-tertiary)', fontSize: 16, cursor: 'pointer',
|
||
padding: '4px 8px', borderRadius: 4,
|
||
};
|
||
|
||
const body: React.CSSProperties = {
|
||
flex: 1,
|
||
display: 'flex',
|
||
gap: 24,
|
||
padding: 24,
|
||
overflow: 'hidden', /* infoPanel 内部滚动 */
|
||
minHeight: 0,
|
||
};
|
||
|
||
/* 左侧媒体区 — 固定 480 宽,视频 + 参考素材纵向排列 */
|
||
const mediaPanel: React.CSSProperties = {
|
||
width: 480,
|
||
flexShrink: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'stretch',
|
||
minHeight: 0, /* 给内部 flex 让出收缩空间 */
|
||
};
|
||
|
||
/* 参考素材区:max-height + 内部滚动,避免视频高度被推下去
|
||
Seedance 最多 9 张 → 80px thumb × 5/行 = 1-2 行,250px 给足兜底 */
|
||
const refScrollBox: React.CSSProperties = {
|
||
maxHeight: 250,
|
||
overflowY: 'auto',
|
||
marginTop: 8,
|
||
paddingRight: 4,
|
||
};
|
||
|
||
const mediaFrame: React.CSSProperties = {
|
||
width: '100%',
|
||
aspectRatio: '16 / 9',
|
||
maxHeight: 360,
|
||
background: 'var(--color-bg-video)',
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
border: '1px solid var(--color-border-modal-soft)',
|
||
};
|
||
|
||
const mediaVideo: React.CSSProperties = {
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'contain',
|
||
display: 'block',
|
||
};
|
||
|
||
const mediaPlaceholder: React.CSSProperties = {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12,
|
||
color: 'var(--color-text-on-glass-soft)',
|
||
fontSize: 13,
|
||
};
|
||
|
||
const spinnerStyle: React.CSSProperties = {
|
||
color: 'var(--color-primary)',
|
||
animation: 'shoot-spin 1s linear infinite',
|
||
};
|
||
|
||
/* ── 失败态:RGB 故障字 + 斜纹底 ── */
|
||
const failBg: React.CSSProperties = {
|
||
position: 'absolute',
|
||
inset: 0,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 18,
|
||
/* 半透明红斜纹 — 仿 CRT 信号丢失 */
|
||
background: `
|
||
repeating-linear-gradient(135deg,
|
||
var(--color-danger-bg-soft) 0,
|
||
var(--color-danger-bg-soft) 14px,
|
||
transparent 14px,
|
||
transparent 22px),
|
||
var(--color-bg-elevated)
|
||
`,
|
||
};
|
||
|
||
const failStripe: React.CSSProperties = {
|
||
position: 'absolute',
|
||
top: 0, left: 0, right: 0,
|
||
height: 6,
|
||
background: `repeating-linear-gradient(90deg,
|
||
var(--color-danger) 0,
|
||
var(--color-danger) 6px,
|
||
var(--color-info) 6px,
|
||
var(--color-info) 8px,
|
||
transparent 8px,
|
||
transparent 14px)`,
|
||
opacity: 0.6,
|
||
};
|
||
|
||
const glitchTitle: React.CSSProperties = {
|
||
fontSize: 44,
|
||
fontWeight: 700,
|
||
letterSpacing: '0.12em',
|
||
color: 'var(--color-danger)',
|
||
fontFamily: "'Space Grotesk', 'JetBrains Mono', ui-monospace, monospace",
|
||
/* RGB 偏移:左 cyan 右 magenta */
|
||
textShadow: `
|
||
-2px 0 var(--color-info),
|
||
2px 0 #ff00aa,
|
||
0 0 30px var(--color-danger-bg)
|
||
`,
|
||
textAlign: 'center',
|
||
userSelect: 'none',
|
||
};
|
||
|
||
const glitchSub: React.CSSProperties = {
|
||
fontSize: 12,
|
||
color: 'var(--color-danger)',
|
||
opacity: 0.85,
|
||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||
maxWidth: '85%',
|
||
textAlign: 'center',
|
||
wordBreak: 'break-word',
|
||
lineHeight: 1.6,
|
||
};
|
||
|
||
/* 右侧信息区 — 现有内容整体搬过来 */
|
||
const infoPanel: React.CSSProperties = {
|
||
flex: 1,
|
||
minWidth: 0,
|
||
overflowY: 'auto',
|
||
paddingRight: 4, /* 给滚动条让位 */
|
||
};
|
||
|
||
const statusBadge: React.CSSProperties = {
|
||
padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500,
|
||
};
|
||
|
||
const errorBox: React.CSSProperties = {
|
||
background: 'var(--color-danger-bg-soft)', border: '1px solid var(--color-danger-border)',
|
||
borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: 'var(--color-danger)',
|
||
};
|
||
|
||
const sectionTitle: React.CSSProperties = {
|
||
fontSize: 12, color: 'var(--color-text-tertiary)', 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: 'var(--color-bg-elevated)', borderRadius: 8, padding: 12, fontSize: 13,
|
||
color: 'var(--color-text-monochrome)', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||
maxHeight: 150, overflowY: 'auto',
|
||
};
|
||
|
||
/* ── 调试信息折叠区(开发/客服参考)── */
|
||
const debugSection: React.CSSProperties = {
|
||
marginTop: 20,
|
||
paddingTop: 12,
|
||
borderTop: '1px dashed var(--color-border-modal-soft)',
|
||
};
|
||
const debugToggle: React.CSSProperties = {
|
||
display: 'flex', alignItems: 'center', gap: 4,
|
||
background: 'none', border: 'none', cursor: 'pointer',
|
||
color: 'var(--color-text-tertiary)', fontSize: 11, padding: 0,
|
||
fontFamily: 'inherit',
|
||
};
|
||
const debugContent: React.CSSProperties = {
|
||
marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8,
|
||
};
|
||
const debugLabel: React.CSSProperties = {
|
||
fontSize: 11, color: 'var(--color-text-tertiary)',
|
||
};
|
||
const debugCodeBox: React.CSSProperties = {
|
||
background: 'var(--color-bg-elevated)', borderRadius: 6, padding: 10,
|
||
fontSize: 12, lineHeight: 1.5,
|
||
fontFamily: "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace",
|
||
color: 'var(--color-text-monochrome)',
|
||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||
maxHeight: 200, overflowY: 'auto',
|
||
border: '1px solid var(--color-border-modal-soft)',
|
||
};
|
||
const debugRow: React.CSSProperties = {
|
||
display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||
};
|
||
const debugMono: React.CSSProperties = {
|
||
fontFamily: "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace",
|
||
fontSize: 12, color: 'var(--color-text-on-glass-soft)',
|
||
wordBreak: 'break-all',
|
||
};
|
||
const debugCopyBtn: React.CSSProperties = {
|
||
background: 'var(--color-bg-elevated)',
|
||
border: '1px solid var(--color-border-modal-soft)',
|
||
borderRadius: 4, padding: '2px 8px',
|
||
fontSize: 11, color: 'var(--color-text-on-glass-soft)',
|
||
cursor: 'pointer',
|
||
};
|