feat(records): 任务详情弹窗加视频展示 + RGB 故障字失败态

需求: 消费记录 detail modal 加多一个视频展示框
  - completed + result_url → 视频播放器
  - failed → 失败图标 (RGB 故障字风格)
  - processing/queued → spinner
  现有信息排版整体搬到右侧,布局不动。

后端 (apps/generation/views.py):
  admin_records_view + team_records_view 返回字段加 'result_url'
  (model 早就有 result_url, 只是这俩 list API 没暴露)

前端类型 (web/src/types/index.ts):
  AdminRecord 加 result_url?: string

RecordDetailModal 重构 (web/src/components/RecordDetailModal.tsx):
  - 弹窗宽 560 → 1080 (maxWidth 95vw, maxHeight 85vh)
  - body display: flex 双栏
  - 左侧 480 固定宽 + 16:9 视频框 + object-fit: contain
    (沿用 GenerationCard.resultArea 同一套 sizing 思路,
     不同长宽比视频用 contain 居中 + 黑边补足)
  - 右侧 flex 1, overflow-y auto, 现有内容整体搬过去排版不动
  - 视频用 controls + preload="metadata", 不自动播放, 跟全局音量走

MediaArea 组件分支:
  - completed + result_url: <video controls />
  - completed - result_url: 视频 icon + "视频已生成" 占位
  - failed: FailureGlitch RGB 故障字
  - processing/queued: 旋转 spinner + 文字

FailureGlitch 视觉细节:
  - 标题 "生成失败" 44px Space Grotesk weight 700
    text-shadow: -2px 0 var(--info) cyan, 2px 0 #ff00aa magenta
    + 30px 红色 glow → 模拟 CRT RGB 信号偏移
  - 副标题: 错误原因摘要 (truncate 80 char) 等宽字体
  - 背景: 红色斜纹 + 顶/底彩色条纹 (red/cyan 间隔) 仿信号丢失

验证 (docs/screenshots/v2/modal/):
  completed__{dark,light}.png - 视频框 + 右侧信息
  failed__{dark,light}.png    - RGB 故障字
  TS 编译过, backend 已重启

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-12 14:48:23 +08:00
parent 5f30258112
commit a1c16be1ea
4 changed files with 356 additions and 45 deletions

View File

@ -1818,6 +1818,7 @@ def admin_records_view(request):
'duration': r.duration, 'duration': r.duration,
'seed': r.seed, 'seed': r.seed,
'ark_task_id': r.ark_task_id or '', 'ark_task_id': r.ark_task_id or '',
'result_url': r.result_url or '',
}) })
return Response({ return Response({
@ -1882,6 +1883,7 @@ def team_records_view(request):
'duration': r.duration, 'duration': r.duration,
'seed': r.seed, 'seed': r.seed,
'ark_task_id': r.ark_task_id or '', 'ark_task_id': r.ark_task_id or '',
'result_url': r.result_url or '',
}) })
return Response({ return Response({

View File

@ -1,5 +1,6 @@
import type { AdminRecord } from '../types'; import type { AdminRecord } from '../types';
import { ReferenceList } from './ReferenceList'; import { ReferenceList } from './ReferenceList';
import { rewriteTosUrl } from '../lib/api';
const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = { const STATUS_MAP: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '已完成', color: 'var(--color-success)', bg: 'var(--color-success-bg)' }, completed: { label: '已完成', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
@ -43,7 +44,15 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
<button style={closeBtn} onClick={onClose}></button> <button style={closeBtn} onClick={onClose}></button>
</div> </div>
{/* Body — 左视频 / 右信息 双栏 */}
<div style={body}> <div style={body}>
{/* ── 左:视频 ── */}
<div style={mediaPanel}>
<MediaArea record={r} />
</div>
{/* ── 右:信息(原有内容整体搬过来,排版不动)── */}
<div style={infoPanel}>
{/* Status */} {/* Status */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span> <span style={{ ...statusBadge, color: st.color, background: st.bg }}>{st.label}</span>
@ -56,7 +65,7 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
<div>{r.error_message}</div> <div>{r.error_message}</div>
{r.raw_error && r.raw_error !== r.error_message && ( {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' }}> <div style={{ marginTop: 8, fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{r.raw_error} :{r.raw_error}
</div> </div>
)} )}
</div> </div>
@ -82,22 +91,80 @@ export function RecordDetailModal({ record: r, onClose, showTeam, showCost }: Pr
{/* Prompt */} {/* Prompt */}
<div style={sectionTitle}></div> <div style={sectionTitle}></div>
<div style={promptBox}>{r.prompt || '(无提示词)'}</div> <div style={promptBox}>{r.prompt || '(无提示词)'}</div>
{/* References */} {/* References */}
{refs.length > 0 && ( {refs.length > 0 && (
<> <>
<div style={sectionTitle}>{refs.length}</div> <div style={sectionTitle}>({refs.length})</div>
<ReferenceList references={refs} /> <ReferenceList references={refs} />
</> </>
)} )}
</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)}
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 }) { function InfoItem({ label, value }: { label: string; value: string }) {
return ( return (
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
@ -107,45 +174,181 @@ function InfoItem({ label, value }: { label: string; value: string }) {
); );
} }
// ─────────────────────────────────────
// Styles // Styles
// ─────────────────────────────────────
const overlay: React.CSSProperties = { const overlay: React.CSSProperties = {
position: 'fixed', inset: 0, background: 'var(--color-modal-overlay)', display: 'flex', position: 'fixed', inset: 0, background: 'var(--color-modal-overlay)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10000, alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}; };
const modal: React.CSSProperties = { const modal: React.CSSProperties = {
background: 'var(--color-bg-modal-glass)', background: 'var(--color-bg-modal-glass)',
backdropFilter: 'blur(24px) saturate(180%)', backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)', WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--color-border-modal-soft)', border: '1px solid var(--color-border-modal-soft)',
borderRadius: 12, borderRadius: 12,
width: 560, maxHeight: '80vh', display: 'flex', flexDirection: 'column', width: 1080,
maxWidth: '95vw',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
boxShadow: 'var(--shadow-glass-light)', boxShadow: 'var(--shadow-glass-light)',
}; };
const header: React.CSSProperties = { const header: React.CSSProperties = {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 20px', borderBottom: '1px solid var(--color-border-modal)', padding: '16px 24px', borderBottom: '1px solid var(--color-border-modal-soft)',
flexShrink: 0,
}; };
const closeBtn: React.CSSProperties = { const closeBtn: React.CSSProperties = {
background: 'none', border: 'none', color: 'var(--color-text-tertiary)', fontSize: 16, cursor: 'pointer', background: 'none', border: 'none', color: 'var(--color-text-tertiary)', fontSize: 16, cursor: 'pointer',
padding: '4px 8px', borderRadius: 4, padding: '4px 8px', borderRadius: 4,
}; };
const body: React.CSSProperties = { const body: React.CSSProperties = {
padding: 20, overflowY: 'auto', flex: 1, 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: 'center',
};
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 = { const statusBadge: React.CSSProperties = {
padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500, padding: '4px 12px', borderRadius: 6, fontSize: 13, fontWeight: 500,
}; };
const errorBox: React.CSSProperties = { const errorBox: React.CSSProperties = {
background: 'var(--color-danger-bg-soft)', border: '1px solid var(--color-danger-border)', 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)', borderRadius: 8, padding: 12, marginBottom: 16, fontSize: 13, color: 'var(--color-danger)',
}; };
const sectionTitle: React.CSSProperties = { const sectionTitle: React.CSSProperties = {
fontSize: 12, color: 'var(--color-text-tertiary)', fontWeight: 500, marginBottom: 8, marginTop: 16, fontSize: 12, color: 'var(--color-text-tertiary)', fontWeight: 500, marginBottom: 8, marginTop: 16,
textTransform: 'uppercase', letterSpacing: 1, textTransform: 'uppercase', letterSpacing: 1,
}; };
const infoGrid: React.CSSProperties = { const infoGrid: React.CSSProperties = {
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px 16px',
}; };
const promptBox: React.CSSProperties = { const promptBox: React.CSSProperties = {
background: 'var(--color-bg-elevated)', borderRadius: 8, padding: 12, fontSize: 13, 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', color: 'var(--color-text-monochrome)', lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',

View File

@ -217,6 +217,7 @@ export interface AdminRecord {
duration?: number; duration?: number;
seed?: number; seed?: number;
ark_task_id?: string; ark_task_id?: string;
result_url?: string;
} }
export interface SystemSettings { export interface SystemSettings {

105
web/test/modal-preview.mjs Normal file
View File

@ -0,0 +1,105 @@
/**
* 一次性脚本: RecordDetailModal 双栏新版 成功态 / 失败态 × / = 4
*/
import { chromium } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../../docs/screenshots/v2/modal');
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
async function loginAdmin(page) {
const res = await page.request.post(`${API}/api/v1/auth/login`, {
data: { username: 'admin', password: 'admin123' },
});
const body = await res.json();
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user });
}
async function setTheme(page, theme) {
await page.evaluate((t) => {
localStorage.setItem('airdrama-theme', t);
document.documentElement.dataset.theme = t;
}, theme);
}
async function findRowByStatus(page, statusText) {
// 表格行里有 "已完成" / "失败" 文字的 row,点 username 链接打开 modal
const rows = page.locator('tr').filter({ hasText: statusText });
const count = await rows.count();
if (count === 0) return null;
// 找一行的 username 链接(usernameLink 触发 detail modal)
return rows.first();
}
async function main() {
await mkdir(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('console', (msg) => {
if (msg.type() === 'error' && !/401|404|Failed to load|DevTools/.test(msg.text())) {
console.log(' [console]', msg.text());
}
});
console.log('login admin...');
await loginAdmin(page);
for (const theme of ['dark', 'light']) {
await setTheme(page, theme);
await page.goto(`${BASE}/admin/records`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1500);
// 找成功行 → 点击 username 链接 / 详情按钮打开 modal
const completedRow = await findRowByStatus(page, '已完成');
if (completedRow) {
// RecordsPage tr 自带 onClick → 整行点击
await completedRow.click({ force: true }).catch(() => {});
await page.waitForTimeout(1200);
const modalOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (modalOpen) {
await page.screenshot({ path: resolve(OUT, `completed__${theme}.png`) });
console.log(` ✓ completed__${theme}.png`);
// 关闭
await page.locator('button:has-text("✕")').first().click().catch(() => {});
await page.waitForTimeout(400);
} else {
console.log(` ✗ completed__${theme} — modal 没打开`);
}
} else {
console.log(` - 没找到 已完成 行 (${theme})`);
}
// 找失败行
const failedRow = await findRowByStatus(page, '失败');
if (failedRow) {
await failedRow.click({ force: true }).catch(() => {});
await page.waitForTimeout(1200);
const modalOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false);
if (modalOpen) {
await page.screenshot({ path: resolve(OUT, `failed__${theme}.png`) });
console.log(` ✓ failed__${theme}.png`);
await page.locator('button:has-text("✕")').first().click().catch(() => {});
await page.waitForTimeout(400);
} else {
console.log(` ✗ failed__${theme} — modal 没打开`);
}
} else {
console.log(` - 没找到 失败 行 (${theme})`);
}
}
await browser.close();
console.log('\n✅ done — see docs/screenshots/v2/modal/');
}
main().catch(e => { console.error(e); process.exit(1); });