diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index fb4928c..a6519e9 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index 366b356..911289d 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -313,20 +313,26 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', ( await openHost(page, { width: 1440, height: 900 }); const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); await frame.waitFor(); - const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); - // 先悬停冻结(避免点击瞬间目标位移——点击锁定的正是防错图语义)。 + + // 悬停冻结 + 坐标直击当前命中的照片(.first() 可能已滚出视口——flaky 源)。 const box = await frame.boundingBox(); - await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); + const cx = (box!.x ?? 0) + 90; + const cy = (box!.y ?? 0) + 140; + await page.mouse.move(cx, cy); await page.waitForTimeout(400); - const target = inside.locator('a.stream-photo').first(); - const photoId = await target.getAttribute('data-photo-id'); - expect(photoId).toBeTruthy(); + const embedFrame0 = page.frames().find((f) => f.url().includes('v=2')); + expect(embedFrame0).toBeDefined(); + const photoId = await embedFrame0!.evaluate(([_x, _y]) => { + const el = document.elementFromPoint(_x, _y)?.closest('a.stream-photo'); + return el?.getAttribute('data-photo-id') ?? ''; + }, [90, 140] as const); + expect(photoId).not.toBe(''); // 宿主内弹框(用户决策 2026-09-23):不新开标签,宿主页覆盖层 + 详情 iframe。 let popupOpened = false; page.once('popup', () => { popupOpened = true; }); - await target.click(); + await page.mouse.click(cx, cy); const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]'); await modalFrame.waitFor({ timeout: 5000 }); expect(popupOpened).toBe(false); @@ -432,8 +438,12 @@ test.describe.serial('E05 realtime hearts: real burst drives particles', () => { await page.waitForTimeout(500); const embedFrame = page.frames().find((f) => f.url().includes('v=2')); expect(embedFrame).toBeDefined(); - const photoId = await embedFrame!.locator('a.stream-photo').first().getAttribute('data-photo-id'); - expect(photoId).toBeTruthy(); + // 坐标命中当前可见照片(.first() 可能已滚出视口——事件心只对可见照片出泡)。 + const photoId = await embedFrame!.evaluate(() => { + const el = document.elementFromPoint(90, 140)?.closest('a.stream-photo'); + return el?.getAttribute('data-photo-id') ?? ''; + }); + expect(photoId).not.toBe(''); // 真实公开路径点赞(ov 域):服务端广播 likes.burst → 订阅出泡。 const like = await page.request.post( `http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, diff --git a/apps/viewer/e2e/verify-mfix2.mjs b/apps/viewer/e2e/verify-mfix2.mjs deleted file mode 100644 index d2c1c39..0000000 --- a/apps/viewer/e2e/verify-mfix2.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { createServer } from 'node:http'; -import { chromium } from '@playwright/test'; -const server = createServer((_q, res) => { - res.setHeader('content-type', 'text/html; charset=utf-8'); - res.end(` - `); -}); -await new Promise((r) => server.listen(3980, '127.0.0.1', r)); -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); -await page.goto('http://127.0.0.1:3980'); -const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); -await frame.waitFor({ timeout: 20000 }); -await page.waitForTimeout(3000); -const f = page.frames().find((fr) => fr.url().includes('v=2')); -const offset = async () => { - const t = await f.locator('.stream-track').evaluate((el) => el.style.transform); - return Number.parseFloat((/translateY\((-?[\d.]+)px\)/.exec(t) ?? ['0', '0'])[1]); -}; -const box = await frame.boundingBox(); -const cx = (box ? box.x : 0) + 90, cy = (box ? box.y : 0) + 150; -const scrolling = async (ms = 1000) => { const a = await offset(); await page.waitForTimeout(ms); return a !== (await offset()); }; - -console.log('1. baseline =', await scrolling()); - -// 窗内拖动后,鼠标移开再验证(窗内 up 后鼠标停在窗内属正常 hover 暂停) -await page.mouse.move(cx, cy); await page.mouse.down(); -await page.mouse.move(cx, cy + 40, { steps: 4 }); await page.mouse.up(); -await page.mouse.move(30, 30); await page.waitForTimeout(1500); -console.log('2. after in-frame drag (pointer away) =', await scrolling()); - -// 窗外释放 -await page.mouse.move(cx, cy); await page.mouse.down(); -await page.mouse.move(cx + 120, cy + 200, { steps: 4 }); await page.mouse.up(); -await page.waitForTimeout(3500); -console.log('3. after outside-release =', await scrolling()); - -// 长跑 20s -const s1 = await offset(); await page.waitForTimeout(20000); const s2 = await offset(); -console.log('4. 20s moved =', Math.abs(s2 - s1).toFixed(0), 'px'); - -// hover 语义仍正常 -await page.mouse.move(cx, cy); await page.waitForTimeout(800); -const h1 = await offset(); await page.waitForTimeout(800); const h2 = await offset(); -await page.mouse.move(30, 30); await page.waitForTimeout(1500); -const h3 = await offset(); await page.waitForTimeout(800); const h4 = await offset(); -console.log('5. hover pause =', h1 === h2, '; leave resume =', h3 !== h4); -server.close(); await browser.close(); diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index b51ffe9..c3b95ba 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -217,40 +217,30 @@ function scheduleResume(): void { function onPointerDown(event: PointerEvent): void { if (event.pointerType === 'mouse' && event.button !== 0) return; + lastPointerTs = Date.now(); pointerDownY = event.clientY; pointerMoved = 0; const anchor = (event.target as HTMLElement).closest('a.stream-photo'); lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null); setReason('pointer', true); - // 指针捕获 + 看门狗(滚动卡死修复 2026-09-23):按下后拖出窗外松开时, - // pointerup 发生在宿主文档——iframe 内任何监听都收不到(capture 不跨文档), - // 'pointer' 暂停原因会永久残留 → 滚动停死。看门狗 2s 强制恢复是确定性兜底; - // capture/window 监听让 iframe 内的释放更快恢复。 - try { - viewportEl.value?.setPointerCapture(event.pointerId); - } catch { - /* 指针已释放等:忽略 */ - } + // 看门狗(滚动卡死修复 2026-09-23):按下后拖出窗外松开时,pointerup 发生 + // 在宿主文档——iframe 内任何监听都收不到,'pointer'/'manual'/'hover' 暂停 + // 原因会永久残留 → 滚动停死。2s 强制恢复是确定性兜底。注意不能用 + // setPointerCapture:捕获会把 pointerup/click 重定向到视口元素——照片 + // 的 click 处理收不到点击(曾致详情弹框打不开),已移除。 if (pointerWatchdog !== null) clearTimeout(pointerWatchdog); pointerWatchdog = setTimeout(() => { pointerWatchdog = null; pointerDownY = null; - // 统一清指针族原因(滚动卡死第三层修复 2026-09-23):pointer capture 挂起 - // 期间 pointerleave 不派发,而窗外释放使 up 永不送达 iframe → capture 不 - // 释放 → hover 原因同样永久卡住。看门狗兜底:释放捕获 + 清 - // pointer/manual/hover 三因(窗内正常路径由各事件自行清理,不受影响)。 - try { - const el = viewportEl.value; - if (el !== null && el.hasPointerCapture(event.pointerId)) { - el.releasePointerCapture(event.pointerId); - } - } catch { - /* 指针已不在:忽略 */ - } + // 统一清指针族原因(滚动卡死第三层修复 2026-09-23):拖出窗外后 leave + // 可能未派发(焦点/边界事件在 iframe 间不可靠),hover 原因同样可能卡住。 + // 看门狗兜底清 pointer/manual/hover/focus 四因(窗内正常路径由各事件 + // 自行清理,不受影响)。 hoverPointerIsMouse = false; setReason('pointer', false); setReason('manual', false); setReason('hover', false); + setReason('focus', false); scheduleResume(); }, POINTER_WATCHDOG_MS); } @@ -363,7 +353,14 @@ function onPointerLeave(event: PointerEvent): void { scheduleResume(); }, RESUME_DELAY_MS); } +/** 最近一次指针交互时刻(focusin 归因用)。 */ +let lastPointerTs = 0; + function onFocusIn(): void { + // 鼠标点击/拖动会把焦点带到窗内元素(focusin 副作用)——此时暂停会永久 + // 卡住(鼠标移开不清键盘焦点,滚动停死根因 2026-09-23)。仅键盘导航 + // (Tab/方向键进入,非指针后短窗)才触发 focus 暂停(§4.2 本意)。 + if (Date.now() - lastPointerTs < 500) return; setReason('focus', true); } function onFocusOut(): void { @@ -387,6 +384,7 @@ function onWindowBlur(): void { pointerDownY = null; setReason('pointer', false); setReason('manual', false); + setReason('focus', false); } onMounted(() => { diff --git a/docs/designs/ui04-implementation/actual/server/photo-modal.png b/docs/designs/ui04-implementation/actual/server/photo-modal.png index 431a3bd..8303072 100644 Binary files a/docs/designs/ui04-implementation/actual/server/photo-modal.png and b/docs/designs/ui04-implementation/actual/server/photo-modal.png differ diff --git a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm index 15bcb82..8f01596 100644 Binary files a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm and b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm differ diff --git a/docs/designs/ui06-implementation/release-after-login.png b/docs/designs/ui06-implementation/release-after-login.png new file mode 100644 index 0000000..6a3fbba Binary files /dev/null and b/docs/designs/ui06-implementation/release-after-login.png differ diff --git a/docs/designs/ui06-implementation/release-check.png b/docs/designs/ui06-implementation/release-check.png new file mode 100644 index 0000000..5b8b853 Binary files /dev/null and b/docs/designs/ui06-implementation/release-check.png differ