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:
parent
5f30258112
commit
a1c16be1ea
@ -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({
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
105
web/test/modal-preview.mjs
Normal 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); });
|
||||||
Loading…
x
Reference in New Issue
Block a user