fix(records): lightbox/player overlay 关闭误触外层 modal — Portal + stopPropagation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 11m2s

回归测试发现的真 bug (V1 V2 历史遗留):
点击 ReferenceList 的图片 lightbox 或音视频 player 的 overlay 关闭时,
事件冒泡 + V2 modal backdrop-filter 创建的独立 stacking context 双重作用
导致 RecordDetailModal 也被一起关掉。

根因双重:
  1. lightbox/player overlay 的 onClick 没 stopPropagation,
     React 合成事件冒泡到外层 modal overlay 触发 onClose
  2. V2 modal inner 加了 backdrop-filter: blur 创建独立 stacking context,
     lightbox 视觉上 z 10002 全屏覆盖,但实际命中区域被限制在 modal inner 内;
     点击 lightbox 视觉外的暗色区域 (modal 外) 直接命中 modal overlay → onClose

修复 (ReferenceList.tsx):
  - 用 React.createPortal 把 lightbox + audio-video player overlay 渲染到 document.body
    脱离 modal inner 的 backdrop-filter stacking context,真正全屏
  - overlay onClick 加 e.stopPropagation() 作为 React 事件链兜底
  - playerWrap 内部 stopPropagation 保留(防止点 player 内部冒泡关闭 player)

新增测试 (web/test/modal-interaction.mjs):
  专项验证 RecordDetailModal 内部交互, 7 个用例:
  1. 任务详情弹窗打开 OK
  2. 参考素材区存在 OK
  3. thumb 有 title 属性 (V2 加的 tooltip 真生效)
  4. 点击 thumb 弹出 lightbox (DOM 验证)
  4.1 关闭 lightbox 不连带关 modal (本次 fix 的关键回归)
  5. 下载按钮 stopPropagation + 触发下载
  6. modal 关闭 ✕ 按钮 OK
  7. 全程无 console.error

全套回归通过:
  - TS 编译过
  - modal-interaction 8/8 (含 lightbox 冒泡修复验证)
  - v2-smoke 25/25 (无新挂)
  - vitest 71/162 与基线一致 (无新挂)

🤖 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 16:31:02 +08:00
parent 385e1bb49e
commit 6d683d4e76
2 changed files with 171 additions and 8 deletions

View File

@ -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) {
})}
</div>
{/* Image lightbox */}
{lightboxUrl && (
<div style={overlay} onClick={() => setLightboxUrl(null)}>
{/* Image lightbox Portal body backdrop-filter stacking context,
modal inner , modal modal close
stopPropagation React */}
{lightboxUrl && createPortal(
<div style={overlay} onClick={(e) => { e.stopPropagation(); setLightboxUrl(null); }}>
<img src={lightboxUrl} alt="" style={{ maxWidth: '90vw', maxHeight: '90vh', borderRadius: 8 }} />
</div>
</div>,
document.body
)}
{/* Video/Audio player modal */}
{playingMedia && (
<div style={overlay} onClick={() => setPlayingMedia(null)}>
{/* Video/Audio player modal — 同 Portal 处理 */}
{playingMedia && createPortal(
<div style={overlay} onClick={(e) => { e.stopPropagation(); setPlayingMedia(null); }}>
<div style={playerWrap} onClick={(e) => e.stopPropagation()}>
<button style={playerClose} onClick={() => setPlayingMedia(null)}></button>
{playingMedia.type === 'video' ? (
@ -104,7 +108,8 @@ export function ReferenceList({ references }: Props) {
</div>
)}
</div>
</div>
</div>,
document.body
)}
</>
);

View File

@ -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); });