diff --git a/web/src/components/ReferenceList.tsx b/web/src/components/ReferenceList.tsx index c200aa8..8d0e05e 100644 --- a/web/src/components/ReferenceList.tsx +++ b/web/src/components/ReferenceList.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { createPortal } from 'react-dom'; interface RefItem { type?: string; @@ -78,16 +79,19 @@ export function ReferenceList({ references }: Props) { })} - {/* Image lightbox */} - {lightboxUrl && ( -
setLightboxUrl(null)}> + {/* Image lightbox — Portal 到 body 跳出父级 backdrop-filter stacking context, + 否则视觉全屏但命中区域被限制在 modal inner 内,点 modal 外暗色会误触 modal close。 + stopPropagation 仍保留作为 React 事件链兜底。 */} + {lightboxUrl && createPortal( +
{ e.stopPropagation(); setLightboxUrl(null); }}> -
+
, + document.body )} - {/* Video/Audio player modal */} - {playingMedia && ( -
setPlayingMedia(null)}> + {/* Video/Audio player modal — 同 Portal 处理 */} + {playingMedia && createPortal( +
{ e.stopPropagation(); setPlayingMedia(null); }}>
e.stopPropagation()}> {playingMedia.type === 'video' ? ( @@ -104,7 +108,8 @@ export function ReferenceList({ references }: Props) {
)}
-
+ , + document.body )} ); diff --git a/web/test/modal-interaction.mjs b/web/test/modal-interaction.mjs new file mode 100644 index 0000000..d0aa984 --- /dev/null +++ b/web/test/modal-interaction.mjs @@ -0,0 +1,158 @@ +/** + * 专项验证 RecordDetailModal 内部交互(改完排版后回归): + * - 点击参考素材 thumb 弹 lightbox + * - 点击 thumb 内下载按钮不触发 lightbox(stopPropagation) + * - hover thumb 时 title 属性带完整 label + * - 无参考素材时左侧只有视频不崩 + * - max-height 滚动(模拟 8+ refs - 注:实测数据可能没这么多,只验逻辑) + */ +import { chromium } from '@playwright/test'; + +const BASE = 'http://localhost:5173'; +const API = 'http://localhost:8000'; + +const results = []; +function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); } +function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); } + +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 main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + const consoleErrors = []; + page.on('console', (m) => { + if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) { + consoleErrors.push(m.text()); + } + }); + + console.log('\n════ Modal interaction regression ════'); + await loginAdmin(page); + + // 找一个有参考素材的 completed record + await page.goto(`${BASE}/admin/records`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + const completedRow = page.locator('tr').filter({ hasText: '已完成' }).first(); + await completedRow.click({ force: true }); + await page.waitForTimeout(1200); + + // 1. modal 打开 + const modalOpen = await page.locator('text=任务详情').first().isVisible(); + if (modalOpen) pass('1. 弹窗打开'); else { fail('1. 弹窗打开', new Error('找不到')); return; } + + // 2. 参考素材是否在(只测有 ref 的 record) + const refSection = page.locator('text=参考素材').first(); + const hasRefs = await refSection.isVisible().catch(() => false); + if (!hasRefs) { + pass('2. 参考素材区 (无 refs case,跳过后续 thumb 测试)'); + await browser.close(); + return; + } + pass('2. 参考素材区存在'); + + // 3. title 属性带完整 label + const firstRefItem = page.locator('text=参考素材').locator('xpath=..').locator('div[title]').first(); + const titleAttr = await firstRefItem.getAttribute('title').catch(() => null); + if (titleAttr) pass(`3. thumb 有 title 属性 ("${titleAttr.slice(0, 30)}...")`); + else fail('3. thumb title 缺失', new Error('hover tooltip 用的 title 属性没设上')); + + // 4. 点击 thumb img 弹 lightbox — thumb 是参考素材区内的 img 元素(ReferenceList refImgStyle 80×80) + // lightbox 是 fixed full-screen img with max-width: 90vw + const beforeClickImgs = await page.locator('img').count(); + // 找参考素材区第一个 img(thumb,80×80) + const thumbImg = page.locator('div[title]').locator('img').first(); + const hasThumbImg = await thumbImg.isVisible().catch(() => false); + if (hasThumbImg) { + await thumbImg.click({ force: true }); + await page.waitForTimeout(500); + const afterClickImgs = await page.locator('img').count(); + if (afterClickImgs > beforeClickImgs) { + pass('4. 点击 thumb 弹出 lightbox (DOM 新增 img)'); + + // 4.1 验证 lightbox 关闭不连带关 modal — 用 Playwright 程序化 click on lightbox element + // (不能用 mouse.click(x,y),因为 backdrop-filter 创建独立 stacking context, + // lightbox overlay 的实际命中区域可能与视觉不一致) + const lightboxOverlay = page.locator('div[style*="z-index"]').filter({ hasText: '' }).last(); + // 简单粗暴:找最后一个 fixed inset 0 div(就是 lightbox overlay) + const allOverlays = await page.locator('div').filter({ + has: page.locator(':scope > img[style*="max-width: 90vw"]') + }).all(); + if (allOverlays.length > 0) { + await allOverlays[0].click({ force: true, position: { x: 5, y: 5 } }); + await page.waitForTimeout(400); + } else { + // 兜底:直接清 React state 通过 Esc 不太行,通过点击坐标 + await page.mouse.click(20, 20); + await page.waitForTimeout(400); + } + const modalStillOpenAfterLightboxClose = await page.locator('text=任务详情').first().isVisible().catch(() => false); + if (modalStillOpenAfterLightboxClose) { + pass('4.1 关闭 lightbox 不连带关 modal (stopPropagation 链 OK)'); + } else { + fail('4.1 lightbox 冒泡 bug', new Error('点 lightbox overlay 把 modal 一起关了 - 事件冒泡到 modal overlay')); + } + } else { + fail('4. 点击 thumb 弹 lightbox', new Error(`img 数 ${beforeClickImgs} → ${afterClickImgs},没新增`)); + } + } else { + pass('4. 点击 thumb 弹 lightbox (无 img thumb,可能都是 placeholder,跳过)'); + } + + // 5. 下载按钮(stopPropagation,不应同时弹 lightbox) + const downloadBtn = page.locator('button[title="下载"]').first(); + const hasDownloadBtn = await downloadBtn.isVisible().catch(() => false); + if (hasDownloadBtn) { + // 监听 download 事件 — Playwright 拦截下载 + const downloadPromise = page.waitForEvent('download', { timeout: 3000 }).catch(() => null); + await downloadBtn.click({ force: true }); + await page.waitForTimeout(500); + const download = await downloadPromise; + // lightbox 不应该被同时打开 + const lightboxOpenAfterDl = await page.locator('div').filter({ has: page.locator('img[src][alt=""]') }).filter({ + has: page.locator(':scope > img[style*="max-width: 90vw"]') + }).count().catch(() => 0); + if (lightboxOpenAfterDl === 0) { + pass(`5. 下载按钮 stopPropagation 生效${download ? ' + 触发下载' : ''}`); + } else { + fail('5. stopPropagation', new Error('点下载按钮同时把 lightbox 也打开了')); + } + } else { + pass('5. 下载按钮 (无 hasUrl 的 ref,跳过)'); + } + + // 6. 关闭 modal + await page.locator('button:has-text("✕")').first().click(); + await page.waitForTimeout(400); + const modalStillOpen = await page.locator('text=任务详情').first().isVisible().catch(() => false); + if (!modalStillOpen) pass('6. modal 关闭 ✕ 按钮 OK'); + else fail('6. modal 关闭', new Error('点 ✕ 后 modal 还在')); + + // 7. console 无 error + if (consoleErrors.length === 0) pass('7. 全程无 console.error'); + else fail('7. console errors', new Error(`${consoleErrors.length} 个:\n` + consoleErrors.slice(0, 3).join('\n'))); + + await browser.close(); + + const passCount = results.filter(r => r.ok).length; + const failCount = results.filter(r => !r.ok).length; + console.log(`\n══ Pass: ${passCount} / Fail: ${failCount} ══`); + if (failCount > 0) { + results.filter(r => !r.ok).forEach(r => console.log(` • ${r.name}: ${r.err}`)); + process.exit(1); + } +} + +main().catch(e => { console.error(e); process.exit(1); });