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