video-shuoshan/web/src/components/RecordDetailModal.tsx
seaislee1209 6ee5c8ffdb feat(records): api_prompt 永久留痕 + 详情弹窗调试信息折叠区
后端:
- 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>
2026-05-12 18:19:36 +08:00

460 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
};