diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index c0f95e3..fc97976 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -37,6 +37,7 @@ const FIXTURES: Array<[string, number, number]> = [ ]; let slotToken = ''; +let albumToken = ''; async function seedFeed(alias: string): Promise { const album = await seedAlbum({ @@ -47,6 +48,7 @@ async function seedFeed(alias: string): Promise { { locale: 'en', title: `${alias} Album` }, ], }); + albumToken = album.publicToken; for (let i = 0; i < FIXTURES.length; i++) { const [file, width, height] = FIXTURES[i]; const bytes = await readFile(join(HERE, 'fixtures', file)); @@ -102,6 +104,11 @@ async function openHost(page: Page, viewport: { width: number; height: number }) await page.goto(`http://127.0.0.1:${HOST_PORT}`); } +// 文件级 serial:config fullyParallel 会把同文件用例分散到多 worker,各自 +// 执行 beforeAll(种子/夹具服务器/admin 幂等种子并发冲突)。整文件单 worker +// 串行,beforeAll 恰好一次。 +test.describe.configure({ mode: 'serial' }); + test.beforeAll(async () => { slotToken = await seedFeed('ui04v2'); await new Promise((resolve) => { @@ -400,6 +407,121 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', ( }); }); +test.describe.serial('E05 realtime hearts: real burst drives particles', () => { + test('real like from another context spawns heart on the liked photo (EMB-A09)', async ({ page }) => { + 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"]'); + // 悬停冻结(稳定归属判定),取首个可见照片。 + const box = await frame.boundingBox(); + await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); + 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(); + // 真实公开路径点赞(ov 域):服务端广播 likes.burst → 订阅出泡。 + const like = await page.request.post( + `http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, + { + headers: { 'content-type': 'application/json' }, + data: { sessionId: `ui04-e2e-${Date.now()}`, batchId: `batch-${Date.now()}`, delta: 1 }, + }, + ); + expect(like.status()).toBeLessThan(300); + // 心形在 1.6s 动画内出现于 iframe。 + await expect(inside.locator('.heart').first()).toBeVisible({ timeout: 5_000 }); + // 归属正确:心形 top 落在被赞照片交集附近(±80px 容差:起点为交集下侧-10)。 + const heartTop = await inside.locator('.heart').first().boundingBox(); + const photoRect = await embedFrame! + .locator(`a.stream-photo[data-photo-id="${photoId}"]`) + .first() + .boundingBox(); + expect(heartTop).not.toBeNull(); + expect(photoRect).not.toBeNull(); + const frameOriginY = (await frame.boundingBox())!.y ?? 0; + // 统一 frame 坐标:bbox 均为页面坐标,减 frame 顶。期望起点 = 照片交集 + // 下侧 -10(origin),心形自 origin 上浮 ≤110px(动画进行中的实测位置 + // 落在 [origin-130, origin+5] 区间)。 + const heartFrame = heartTop!.y - frameOriginY; + const originFrame = photoRect!.y + photoRect!.height - 10 - frameOriginY; + expect(heartFrame).toBeLessThanOrEqual(originFrame + 5); + expect(heartFrame).toBeGreaterThanOrEqual(originFrame - 130); + }); + + test('embed itself submits no likes or visits (EMB-A15)', async ({ page }) => { + const interactive: string[] = []; + page.on('request', (req) => { + const url = req.url(); + if (url.includes('/likes') || url.includes('/visits')) { + if (req.method() === 'POST') interactive.push(url); + } + }); + await openHost(page, { width: 1440, height: 900 }); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + // 悬停/滚轮/键盘各通道触发后观察一个窗口期。 + const box = await frame.boundingBox(); + await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140); + await page.mouse.wheel(0, 40); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(2_500); + expect(interactive).toEqual([]); + }); +}); + +test.describe.serial('E05 recording: scroll + real like hearts', () => { + test('record 16s scroll with periodic real likes into recordings/', async ({ browser }) => { + test.setTimeout(90_000); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + recordVideo: { dir: join(OUT_ROOT, 'recordings'), size: { width: 1440, height: 900 } }, + }); + const page = await context.newPage(); + try { + currentHtml = ` + UI-04 v2 hearts recording host + +

Host page (v2 hearts recording)

+ + `; + await page.goto(`http://127.0.0.1:${HOST_PORT}`); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + await frame.waitFor(); + await page.waitForTimeout(2_000); + // 每 2.4s 真实点赞一个可见照片(效果示例 simulate 节奏)。 + const embedFrame = page.frames().find((f) => f.url().includes('v=2')); + for (let i = 0; i < 6; i++) { + const photoId = + (await embedFrame!.locator('a.stream-photo').nth(i % 2).getAttribute('data-photo-id')) ?? ''; + if (photoId !== '') { + await page.request + .post(`http://127.0.0.1:8081/v1/public/albums/${albumToken}/photos/${photoId}/likes`, { + headers: { 'content-type': 'application/json' }, + data: { + sessionId: `ui04-rec-${Date.now()}`, + batchId: `batch-rec-${Date.now()}-${i}`, + delta: 1, + }, + }) + .catch(() => undefined); + } + await page.waitForTimeout(2_400); + } + } finally { + const video = page.video(); + await context.close().catch(() => undefined); + if (video !== null) { + const src = await video.path(); + if (src !== null) { + await rename(src, join(OUT_ROOT, 'recordings', 'e05-hearts-16s.webm')).catch(() => undefined); + } + } + } + }); +}); + test.describe.serial('E02 static fidelity: measured styles + screenshots', () => { test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => { await openHost(page, { width: 1440, height: 900 }); diff --git a/apps/viewer/public/embed/v2/loader.js b/apps/viewer/public/embed/v2/loader.js index 3a976e5..f586b7b 100644 --- a/apps/viewer/public/embed/v2/loader.js +++ b/apps/viewer/public/embed/v2/loader.js @@ -1 +1 @@ -(()=>{var k=["left","right"],de=["sm","md"],L=["auto","zh-CN","en"],ce=["contain","cover"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3};var ue=3e3,me=3e4;var R=0,W=120,G=0,P=240,Z=1,q=2147483e3,j=["resize","collapse","expand","config","destroy"],B=195,M=280,ge=8,he=32,$=16,xe=["carousel","vertical-stream"];function h(t,l,o){return t.indexOf(l)>=0?l:o}function x(t,l,o,f){let u=Number(t);return isNaN(u)||!isFinite(u)?f:Math.min(o,Math.max(l,Math.round(u)))}function d(t,l){let o=t[l];return o!=null&&o!==""}function ve(t){return!t||typeof t!="object"?null:{position:h(k,t.position,p.position),size:h(de,t.size,p.size),intervalMs:x(t.intervalMs,ue,me,p.intervalMs),lang:h(L,t.lang,p.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:p.collapsedDefault,imageFit:h(ce,t.imageFit,p.imageFit),offsetX:x(t.offsetX,R,W,p.offsetX),offsetBottom:x(t.offsetBottom,G,P,p.offsetBottom),zIndex:x(t.zIndex,Z,q,p.zIndex)}}function J(t){let l=Number(t);return isNaN(l)||!isFinite(l)?$:Math.min(he,Math.max(ge,Math.round(l)))}function Q(t,l){let o=t||{},f=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(l&&l.narrow);return{version:2,slot:f||null,lang:d(o,"lang")?h(L,o.lang,p.lang):p.lang,position:d(o,"position")?h(k,o.position,p.position):p.position,speed:d(o,"speed")?J(o.speed):$,reactions:d(o,"reactions")?h(["on","off"],o.reactions,"on")==="on":!0,offsetX:d(o,"offsetX")?x(o.offsetX,R,W,p.offsetX):p.offsetX,offsetBottom:d(o,"offsetBottom")?x(o.offsetBottom,G,P,p.offsetBottom):p.offsetBottom,zIndex:d(o,"zIndex")?x(o.zIndex,Z,q,p.zIndex):p.zIndex,pollMs:d(o,"poll")?x(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:d(o,"lang"),position:d(o,"position"),speed:d(o,"speed"),reactions:d(o,"reactions"),offsetX:d(o,"offsetX"),offsetBottom:d(o,"offsetBottom"),zIndex:d(o,"zIndex")}}}function Y(t){if(!t||typeof t!="object")return null;let l=ve(t);return l===null?null:{...l,displayMode:h(xe,t.displayMode,"carousel"),scrollSpeed:J(t.scrollSpeed),reactionsEnabled:typeof t.reactionsEnabled=="boolean"?t.reactionsEnabled:!0}}function K(t,l){let o=Y(l);if(o===null)return t;let f=t.explicit;return{...t,lang:f.lang?t.lang:o.lang,position:f.position?t.position:o.position,speed:f.speed?t.speed:o.scrollSpeed,reactions:f.reactions?t.reactions:o.reactionsEnabled,offsetX:f.offsetX?t.offsetX:o.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:f.zIndex?t.zIndex:o.zIndex}}function ee(t,l){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let f=l==null?0:Number(l);if(o<=f)return null;let u=Y(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function te(t,l,o){let f=t+"/embed/"+encodeURIComponent(l)+"?v=2&lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.speed&&(f+="&speed="+String(o.speed)),o.explicit.reactions&&!o.reactions&&(f+="&reactions=off"),o.pollMs!==null&&o.pollMs!==void 0&&(f+="&poll="+String(o.pollMs)),f}(function(){"use strict";try{let C=function(e){try{return t.sessionStorage.getItem(l+e)==="1"}catch(n){return!1}},F=function(e,n){try{n?t.sessionStorage.setItem(l+e,"1"):t.sessionStorage.removeItem(l+e)}catch(r){}},w=function(e){try{return t.sessionStorage.getItem(l+e)!==null}catch(n){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v2/loader.js"]');return e.length?e[e.length-1]:null},N=function(e){try{return new URL(e.src,t.location.href).origin}catch(n){return null}},_=function(e){if(e==="zh-CN")return!0;if(e==="auto")try{return/^zh/i.test(t.navigator.language||"")}catch(n){return!1}return!1},re=function(e){var n=document.createElement("div");n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label",_(e.config.lang)?"\u5C55\u5F00\u7167\u7247\u7A97":"Show photo window");var r=n.style;r.position="fixed",r.height=f+"px",r.minWidth="88px",r.boxSizing="border-box",r.padding="0 16px",r.borderRadius="22px",r.background="#ffffff",r.boxShadow="0 2px 10px rgba(15,23,42,0.18)",r.zIndex=String(e.config.zIndex),r.cursor="pointer",r.display="flex",r.alignItems="center",r.justifyContent="center",r.gap="6px",r.font='500 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',r.color="#1A73E8",r.whiteSpace="nowrap",n.innerHTML=''+(_(e.config.lang)?"\u7167\u7247":"Photos")+"";function a(i){if(i.type==="keydown"){var s=i.key;if(s!=="Enter"&&s!==" "&&s!=="Spacebar")return;i.preventDefault()}e.userChoice=!0,X(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(c){}}return n.addEventListener("click",a),n.addEventListener("keydown",a),n},ie=function(e){return e.collapsed?f:e.height},le=function(e){return e.config.explicit.offsetX||e.offsetConfigured?e.config.offsetX:e.narrow?12:16},A=function(e){var n=le(e),r=t.innerWidth-n-8,a=t.innerHeight-e.config.offsetBottom-8;return{offsetX:n,width:Math.max(120,Math.min(B,r)),height:Math.max(160,Math.min(M,a)),operable:r>=u&&a>=oe}},z=function(e){var n=A(e);e.height=n.height;var r=e.config.position==="left"?"left":"right",a=r==="left"?"right":"left",i=e.wrapper.style;if(e.collapsed||(i.display="block",i.width=n.width+"px",i.height=n.height+"px"),i.zIndex=String(e.config.zIndex),i[r]=n.offsetX+"px",i[a]="",i.bottom=e.config.offsetBottom+"px",i.bottom="calc("+e.config.offsetBottom+"px + env(safe-area-inset-bottom, 0px))",e.handle){var s=e.handle.style;s.zIndex=String(e.config.zIndex),s[r]=n.offsetX+"px",s[a]=""}return n},V=function(){for(var e={left:[],right:[]},n=0;n0&&y+M>t.innerHeight&&T(I,!1),I.wrapper.style.bottom=y+"px",I.handle&&(I.handle.style.bottom=y+"px"),c=y}},v=function(){if(S!==null||typeof t.requestAnimationFrame!="function"){V();return}S=t.requestAnimationFrame(function(){S=null,V()})},T=function(e,n){e.collapsed||(e.collapsed=!0,e.wrapper.style.display="none",e.handle||(e.handle=re(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",n!==!1&&F(e.config.slot,!0))},b=function(e,n){T(e,n),v()},X=function(e){e.collapsed&&(e.collapsed=!1,z(e),e.handle&&(e.handle.style.display="none"),F(e.config.slot,!1),v())},ae=function(e){if(!e.destroyed){e.destroyed=!0,e.handle&&e.handle.parentNode&&e.handle.parentNode.removeChild(e.handle),e.wrapper.parentNode&&e.wrapper.parentNode.removeChild(e.wrapper);try{t.sessionStorage.removeItem(l+e.config.slot)}catch(r){}var n=m.indexOf(e);n>=0&&m.splice(n,1),delete E[e.config.slot],m.length===0&&t.removeEventListener("message",D),v()}},se=function(e,n,r){e.backendVersion=n,e.offsetConfigured=!0,e.config=K(e.config,r),z(e),!e.userChoice&&!w(e.config.slot)&&(r.collapsedDefault?b(e,!1):fe(e)||X(e)),v()},fe=function(e){return!A(e).operable},pe=function(e,n){var r=document.createElement("div"),a=r.style;a.position="fixed",a.width=B+"px",a.height=M+"px",a.boxSizing="border-box",a.borderRadius="12px",a.border="1px solid rgba(255,255,255,0.80)",a.boxShadow="0 4px 16px rgba(15,23,42,0.16)",a.background="#10161E",a.overflow="hidden";var i=document.createElement("iframe");i.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),i.setAttribute("src",te(e,n.slot,n)),i.setAttribute("referrerpolicy","no-referrer-when-downgrade"),i.setAttribute("title","PhotoLive photos");var s=i.style;s.width="100%",s.height="100%",s.border="0",s.display="block",r.appendChild(i),document.body.appendChild(r);var c={config:n,wrapper:r,frame:i,frameOrigin:N(i)||e,height:M,collapsed:!1,narrow:n.narrow,handle:null,userChoice:!1,backendVersion:0,offsetConfigured:n.explicit.offsetX,destroyed:!1};m.push(c);var g=z(c);return w(n.slot)?C(n.slot)&&b(c,!1):(c.narrow||!g.operable)&&b(c,!1),c},D=function(e){try{var n=e.data;if(!n||n.photoliveEmbed!==!0)return;var r=n.type;if(j.indexOf(r)<0)return;for(var a=0;a{var k=["left","right"],de=["sm","md"],L=["auto","zh-CN","en"],ce=["contain","cover"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3};var ue=3e3,me=3e4;var R=0,W=120,G=0,P=240,Z=1,q=2147483e3,j=["resize","collapse","expand","config","destroy"],B=195,M=280,he=8,ge=32,$=16,xe=["carousel","vertical-stream"];function g(t,l,o){return t.indexOf(l)>=0?l:o}function x(t,l,o,f){let u=Number(t);return isNaN(u)||!isFinite(u)?f:Math.min(o,Math.max(l,Math.round(u)))}function d(t,l){let o=t[l];return o!=null&&o!==""}function ve(t){return!t||typeof t!="object"?null:{position:g(k,t.position,p.position),size:g(de,t.size,p.size),intervalMs:x(t.intervalMs,ue,me,p.intervalMs),lang:g(L,t.lang,p.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:p.collapsedDefault,imageFit:g(ce,t.imageFit,p.imageFit),offsetX:x(t.offsetX,R,W,p.offsetX),offsetBottom:x(t.offsetBottom,G,P,p.offsetBottom),zIndex:x(t.zIndex,Z,q,p.zIndex)}}function J(t){let l=Number(t);return isNaN(l)||!isFinite(l)?$:Math.min(ge,Math.max(he,Math.round(l)))}function Q(t,l){let o=t||{},f=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(l&&l.narrow);return{version:2,slot:f||null,lang:d(o,"lang")?g(L,o.lang,p.lang):p.lang,position:d(o,"position")?g(k,o.position,p.position):p.position,speed:d(o,"speed")?J(o.speed):$,reactions:d(o,"reactions")?g(["on","off"],o.reactions,"on")==="on":!0,offsetX:d(o,"offsetX")?x(o.offsetX,R,W,p.offsetX):p.offsetX,offsetBottom:d(o,"offsetBottom")?x(o.offsetBottom,G,P,p.offsetBottom):p.offsetBottom,zIndex:d(o,"zIndex")?x(o.zIndex,Z,q,p.zIndex):p.zIndex,pollMs:d(o,"poll")?x(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:d(o,"lang"),position:d(o,"position"),speed:d(o,"speed"),reactions:d(o,"reactions"),offsetX:d(o,"offsetX"),offsetBottom:d(o,"offsetBottom"),zIndex:d(o,"zIndex")}}}function Y(t){if(!t||typeof t!="object")return null;let l=ve(t);return l===null?null:{...l,displayMode:g(xe,t.displayMode,"carousel"),scrollSpeed:J(t.scrollSpeed),reactionsEnabled:typeof t.reactionsEnabled=="boolean"?t.reactionsEnabled:!0}}function K(t,l){let o=Y(l);if(o===null)return t;let f=t.explicit;return{...t,lang:f.lang?t.lang:o.lang,position:f.position?t.position:o.position,speed:f.speed?t.speed:o.scrollSpeed,reactions:f.reactions?t.reactions:o.reactionsEnabled,offsetX:f.offsetX?t.offsetX:o.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:f.zIndex?t.zIndex:o.zIndex}}function ee(t,l){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let f=l==null?0:Number(l);if(o<=f)return null;let u=Y(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function te(t,l,o){let f=t+"/embed/"+encodeURIComponent(l)+"?v=2&lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.speed&&(f+="&speed="+String(o.speed)),o.explicit.reactions&&!o.reactions&&(f+="&reactions=off"),o.pollMs!==null&&o.pollMs!==void 0&&(f+="&poll="+String(o.pollMs)),f}(function(){"use strict";try{let C=function(e){try{return t.sessionStorage.getItem(l+e)==="1"}catch(n){return!1}},F=function(e,n){try{n?t.sessionStorage.setItem(l+e,"1"):t.sessionStorage.removeItem(l+e)}catch(r){}},X=function(e){try{return t.sessionStorage.getItem(l+e)!==null}catch(n){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v2/loader.js"]');return e.length?e[e.length-1]:null},N=function(e){try{return new URL(e.src,t.location.href).origin}catch(n){return null}},_=function(e){if(e==="zh-CN")return!0;if(e==="auto")try{return/^zh/i.test(t.navigator.language||"")}catch(n){return!1}return!1},re=function(e){var n=document.createElement("div");n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label",_(e.config.lang)?"\u5C55\u5F00\u7167\u7247\u7A97":"Show photo window");var r=n.style;r.position="fixed",r.height=f+"px",r.width="100px",r.boxSizing="border-box",r.borderRadius="22px",r.border="1px solid #e3eaf2",r.background="#ffffff",r.boxShadow="0 4px 16px rgba(15,23,42,0.125)",r.zIndex=String(e.config.zIndex),r.cursor="pointer",r.display="flex",r.alignItems="center",r.justifyContent="center",r.gap="8px",r.font='400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',r.color="#32618e",r.whiteSpace="nowrap",n.innerHTML=''+(_(e.config.lang)?"\u7167\u7247":"Photos")+"";function a(i){if(i.type==="keydown"){var s=i.key;if(s!=="Enter"&&s!==" "&&s!=="Spacebar")return;i.preventDefault()}e.userChoice=!0,w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(c){}}return n.addEventListener("click",a),n.addEventListener("keydown",a),n},ie=function(e){return e.collapsed?f:e.height},le=function(e){return e.config.explicit.offsetX||e.offsetConfigured?e.config.offsetX:t.innerWidth<=599?12:16},V=function(e){var n=le(e),r=t.innerWidth-n-8,a=t.innerHeight-e.config.offsetBottom-8;return{offsetX:n,width:Math.max(120,Math.min(B,r)),height:Math.max(160,Math.min(M,a)),operable:r>=u&&a>=oe}},z=function(e){var n=V(e);e.height=n.height;var r=e.config.position==="left"?"left":"right",a=r==="left"?"right":"left",i=e.wrapper.style;if(e.collapsed||(i.display="block",i.width=n.width+"px",i.height=n.height+"px"),i.zIndex=String(e.config.zIndex),i[r]=n.offsetX+"px",i[a]="",i.bottom=e.config.offsetBottom+"px",i.bottom="calc("+e.config.offsetBottom+"px + env(safe-area-inset-bottom, 0px))",e.handle){var s=e.handle.style;s.zIndex=String(e.config.zIndex),s[r]=n.offsetX+"px",s[a]=""}return n},A=function(){for(var e={left:[],right:[]},n=0;n0&&y+M>t.innerHeight&&T(I,!1),I.wrapper.style.bottom=y+"px",I.handle&&(I.handle.style.bottom=y+"px"),c=y}},v=function(){if(S!==null||typeof t.requestAnimationFrame!="function"){A();return}S=t.requestAnimationFrame(function(){S=null,A()})},T=function(e,n){e.collapsed||(e.collapsed=!0,e.wrapper.style.display="none",e.handle||(e.handle=re(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",n!==!1&&F(e.config.slot,!0))},b=function(e,n){T(e,n),v()},w=function(e){e.collapsed&&(e.collapsed=!1,z(e),e.handle&&(e.handle.style.display="none"),F(e.config.slot,!1),v())},ae=function(e){if(!e.destroyed){e.destroyed=!0,e.handle&&e.handle.parentNode&&e.handle.parentNode.removeChild(e.handle),e.wrapper.parentNode&&e.wrapper.parentNode.removeChild(e.wrapper);try{t.sessionStorage.removeItem(l+e.config.slot)}catch(r){}var n=m.indexOf(e);n>=0&&m.splice(n,1),delete E[e.config.slot],m.length===0&&t.removeEventListener("message",D),v()}},se=function(e,n,r){e.backendVersion=n,e.offsetConfigured=!0,e.config=K(e.config,r),z(e),!e.userChoice&&!X(e.config.slot)&&(r.collapsedDefault?b(e,!1):fe(e)||w(e)),v()},fe=function(e){return!V(e).operable},pe=function(e,n){var r=document.createElement("div"),a=r.style;a.position="fixed",a.width=B+"px",a.height=M+"px",a.boxSizing="border-box",a.borderRadius="12px",a.border="1px solid rgba(255,255,255,0.80)",a.boxShadow="0 4px 16px rgba(15,23,42,0.16)",a.background="#10161E",a.overflow="hidden";var i=document.createElement("iframe");i.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),i.setAttribute("src",te(e,n.slot,n)),i.setAttribute("referrerpolicy","no-referrer-when-downgrade"),i.setAttribute("title","PhotoLive photos");var s=i.style;s.width="100%",s.height="100%",s.border="0",s.display="block",r.appendChild(i),document.body.appendChild(r);var c={config:n,wrapper:r,frame:i,frameOrigin:N(i)||e,height:M,collapsed:!1,narrow:n.narrow,handle:null,userChoice:!1,backendVersion:0,offsetConfigured:n.explicit.offsetX,destroyed:!1};m.push(c);var h=z(c);return X(n.slot)?C(n.slot)&&b(c,!1):(c.narrow||!h.operable)&&b(c,!1),c},D=function(e){try{var n=e.data;if(!n||n.photoliveEmbed!==!0)return;var r=n.type;if(j.indexOf(r)<0)return;for(var a=0;a { + it('1→1;2–5→2;更大→3;非法→0', () => { + expect(heartsForDelta(1)).toBe(1); + expect(heartsForDelta(2)).toBe(2); + expect(heartsForDelta(5)).toBe(2); + expect(heartsForDelta(6)).toBe(3); + expect(heartsForDelta(100)).toBe(3); + expect(heartsForDelta(0)).toBe(0); + expect(heartsForDelta(-3)).toBe(0); + expect(heartsForDelta(Number.NaN)).toBe(0); + }); +}); + +describe('BurstDeduper(§5.2 TTL+容量)', () => { + it('首见接受、重复拒绝、TTL 过期后重见接受', () => { + const d = new BurstDeduper(1_000); + expect(d.accept('b1', 0)).toBe(true); + expect(d.accept('b1', 100)).toBe(false); + expect(d.accept('b1', 1_200)).toBe(true); // 超 TTL + }); + + it('容量满时驱逐最旧', () => { + const d = new BurstDeduper(60_000, 2); + d.accept('a', 0); + d.accept('b', 1); + d.accept('c', 2); // 驱逐 a + expect(d.accept('a', 3)).toBe(true); // a 已被驱逐 → 重见接受 + expect(d.accept('c', 3)).toBe(false); // c 仍在 + }); +}); + +describe('PendingVisibleCache(§5.2 5s/50)', () => { + it('可见时取出;超时丢弃;同照片累加封顶 3', () => { + const c = new PendingVisibleCache(1_000, 50); + c.add('p1', 2, 0); + c.add('p1', 2, 100); // 合并 4 → 封顶 3 + expect(c.take('p1', 200)).toBe(3); + expect(c.take('p1', 300)).toBe(0); // 已取走 + c.add('p2', 1, 0); + expect(c.take('p2', 2_000)).toBe(0); // 超 TTL 丢弃 + }); +}); + +describe('EmissionThrottle(§5.2 250ms 合并)', () => { + it('冷却内并入队列;窗口到点整批放行;同照片封顶 3', () => { + const t = new EmissionThrottle(250); + const first = t.offer('p1', 1, 0); + expect(first).not.toBeNull(); // 首发立即放行 + expect(t.offer('p2', 2, 100)).toBeNull(); // 冷却内 + expect(t.offer('p2', 3, 150)).toBeNull(); // 合并 5 → 3 + const batch = t.offer('p3', 1, 300); // 窗口到点 + expect(batch).not.toBeNull(); + expect(batch!.get('p2')).toBe(3); + expect(batch!.get('p3')).toBe(1); + expect(batch!.has('p1')).toBe(false); // 首发已放行 + }); +}); + +// ── useEmbedReactions 事件管道(jsdom 挂载 + ingestForTest 注入)── + +const Harness = defineComponent({ + props: { + visible: { type: Array, required: true }, + albumToken: { type: String, default: 'tok123' }, + }, + setup(props) { + const emitted: Array> = []; + const reactions = useEmbedReactions({ + albumToken: ref(props.albumToken), + enabled: ref(true), + active: ref(true), + isPhotoVisible: (id) => props.visible.includes(id), + onEmit: (batch) => emitted.push(batch), + }); + return { reactions, emitted }; + }, + template: '
', +}); + +function burst(photoId: string, delta: number, burstId: string): ServerMessage { + return { type: 'likes.burst', photoId, albumId: 'a1', delta, total: 1, countVersion: 1, burstId, originBatchIds: [] }; +} + +describe('useEmbedReactions 管道(§5.2 归属/去重/节流/pending)', () => { + it('可见照片 burst → 立即出泡;重复 burstId 不加计;delta 抽样生效', async () => { + const wrapper = mount(Harness, { props: { visible: ['p1'] } }); + await new Promise((r) => setTimeout(r, 0)); + wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'b1')); + expect(wrapper.vm.emitted).toHaveLength(1); + expect(wrapper.vm.emitted[0].get('p1')).toBe(1); + wrapper.vm.reactions.ingestForTest(burst('p1', 5, 'b1')); // 重复 burstId → 忽略 + expect(wrapper.vm.emitted).toHaveLength(1); + await new Promise((r) => setTimeout(r, 300)); + wrapper.vm.reactions.ingestForTest(burst('p1', 100, 'b2')); // delta 100 → 3 + expect(wrapper.vm.emitted).toHaveLength(2); + expect(wrapper.vm.emitted[1].get('p1')).toBe(3); + wrapper.unmount(); + }); + + it('非法 delta 忽略;冷却内合并为一批', async () => { + const wrapper = mount(Harness, { props: { visible: ['p1', 'p2'] } }); + await new Promise((r) => setTimeout(r, 0)); + wrapper.vm.reactions.ingestForTest(burst('p1', 0, 'bad')); // delta<1 → 忽略 + expect(wrapper.vm.emitted).toHaveLength(0); + wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'a')); + wrapper.vm.reactions.ingestForTest(burst('p2', 1, 'b')); // 冷却内并入 + expect(wrapper.vm.emitted).toHaveLength(1); + await new Promise((r) => setTimeout(r, 300)); + wrapper.vm.reactions.ingestForTest(burst('p1', 1, 'c')); // 放行合并批 + expect(wrapper.vm.emitted).toHaveLength(2); + expect(wrapper.vm.emitted[1].get('p2')).toBe(1); + wrapper.unmount(); + }); + + it('不可见照片缓存 → 转可见后 500ms 巡检出泡(5s 内)', async () => { + const visibleRef = ref([]); + const wrapper = mount(Harness, { props: { visible: [] } }); + await new Promise((r) => setTimeout(r, 0)); + wrapper.vm.reactions.ingestForTest(burst('hidden-p', 1, 'h1')); + expect(wrapper.vm.emitted).toHaveLength(0); + // 转可见(真实定时器:sweep 间隔 500ms)。 + await wrapper.setProps({ visible: ['hidden-p'] }); + await new Promise((r) => setTimeout(r, 900)); + expect(wrapper.vm.emitted).toHaveLength(1); + expect(wrapper.vm.emitted[0].get('hidden-p')).toBe(1); + void visibleRef; + wrapper.unmount(); + }); +}); diff --git a/apps/viewer/src/__tests__/embed-stream.spec.ts b/apps/viewer/src/__tests__/embed-stream.spec.ts index c15beba..3da4ecc 100644 --- a/apps/viewer/src/__tests__/embed-stream.spec.ts +++ b/apps/viewer/src/__tests__/embed-stream.spec.ts @@ -83,9 +83,9 @@ describe('advanceStream 匀速推进(§3.1)', () => { it('头条目完整滚出 → 退役一次(headIdx+1,offset 减该条高+缝)', () => { const s = stateOf(uniform(6)); const entryH = (500 / 800) * W + 3; // ≈123.6 - advanceStreamFor(s, 8000, 16, W, H); // 128px > 123.6 → 退役 1 条 + advanceStreamFor(s, 8000, 16, W, H); // 初 32+128=160 > 123.6 → 退役 1 条 expect(s.headIdx).toBe(1); - expect(s.offset).toBeCloseTo(128 - entryH, 5); + expect(s.offset).toBeCloseTo(32 + 128 - entryH, 5); // 头实例已轮换;窗口自 p1 起覆盖 758px:p1..p5(618)+p0#1(742)+p1#1(865≥758)。 expect(s.items[0].photoId).toBe('p1'); expect(s.items.at(-1)?.photoId).toBe('p1'); @@ -95,8 +95,9 @@ describe('advanceStream 匀速推进(§3.1)', () => { it('dt 上限 50ms:长停顿不补跑', () => { const s = stateOf(uniform(6)); + const initial = s.offset; // 32(效果示例初始偏移) advanceStream(s, 60_000, 16, W, H); - expect(s.offset).toBeCloseTo(16 * 0.05, 5); + expect(s.offset).toBeCloseTo(initial + 16 * 0.05, 5); }); it('极矮条目一帧跨多条:循环退役至 offset 落回头条内', () => { @@ -199,11 +200,12 @@ describe('useVerticalPhotoStream 装配', () => { }; expect(wrapper.vm.stream.items.value.length).toBeGreaterThan(0); const track = wrapper.vm.trackEl as HTMLElement; - expect(track.style.transform).toBe('translateY(0px)'); - stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进) - expect(track.style.transform).toBe('translateY(-16px)'); - stepFor(1000); + // 初始偏移 32(效果示例 offset=32:顶部露首照上缘片段;多张才有)。 expect(track.style.transform).toBe('translateY(-32px)'); + stepFor(1000); // 16px(stepForTest 单帧 ≤50ms 语义,分块推进) + expect(track.style.transform).toBe('translateY(-48px)'); + stepFor(1000); + expect(track.style.transform).toBe('translateY(-64px)'); wrapper.unmount(); }); diff --git a/apps/viewer/src/embed/stream.ts b/apps/viewer/src/embed/stream.ts index cf97488..51b21bd 100644 --- a/apps/viewer/src/embed/stream.ts +++ b/apps/viewer/src/embed/stream.ts @@ -168,6 +168,9 @@ export interface CreateStreamStateInput { photos: Array<{ photoId: string } & StreamPhotoSize>; innerWidth: number; viewportHeight: number; + /** 初始图内偏移(px):效果示例取 32——窗口顶部露出首照片上缘片段, + * 与设计图的「中途滚动」形态一致;缺省 32。 */ + initialOffset?: number; } /** 由 feed 照片序列创建引擎状态(截尾 MAX_QUEUE;head=最新)。 */ @@ -183,6 +186,14 @@ export function createStreamState(input: CreateStreamStateInput): StreamEngineSt items: [], }; rebuildItems(state, input.innerWidth, input.viewportHeight); + // 单张静态完整展示(§3.2.8):不裁顶部;两张以上才以 32px 初始偏移呈现 + // 「中途滚动」形态(效果示例 offset=32)。 + if (queue.length > 1) { + state.offset = Math.max( + 0, + Math.min(input.initialOffset ?? 32, entryHeight(sizes[0], input.innerWidth) - 1), + ); + } return state; } @@ -241,6 +252,37 @@ export function advanceStreamFor( } } +/** + * 手动滚动(§4.3;效果示例 translate 的双向分支):正/负向平移。正向越过 + * 头条 → 退役补尾;负向越过头部上缘 → 队尾前插回绕(上滑回看刚滚过的 + * 照片,有限窗口语义不变)。窗口重建保持渲染列表与 headIdx 一致。 + */ +export function manualScroll( + state: StreamEngineState, + deltaPx: number, + innerWidth: number, + viewportHeight: number, +): void { + const queueLen = state.queue.length; + if (queueLen === 0) return; + state.offset += deltaPx; + let guard = 0; + while (guard < MAX_DOM_INSTANCES && state.offset >= entryHeight(state.sizes[state.headIdx], innerWidth)) { + state.offset -= entryHeight(state.sizes[state.headIdx], innerWidth); + state.headIdx = (state.headIdx + 1) % queueLen; + if (state.headIdx === 0) state.gen += 1; + guard += 1; + } + guard = 0; + while (state.offset < 0 && guard < MAX_DOM_INSTANCES) { + state.headIdx = (state.headIdx - 1 + queueLen) % queueLen; + if (state.headIdx === queueLen - 1) state.gen -= 1; + state.offset += entryHeight(state.sizes[state.headIdx], innerWidth); + guard += 1; + } + rebuildItems(state, innerWidth, viewportHeight); +} + /** * 数据更新锚定(§6.2):以「首个与视口相交的 photoId + 图内偏移」为锚, * 队列合并(幸存保序、新图入未显示区)后重建状态,保持锚照片屏幕位置不变 diff --git a/apps/viewer/src/embed/useVerticalPhotoStream.ts b/apps/viewer/src/embed/useVerticalPhotoStream.ts index 5a069be..d94f597 100644 --- a/apps/viewer/src/embed/useVerticalPhotoStream.ts +++ b/apps/viewer/src/embed/useVerticalPhotoStream.ts @@ -15,7 +15,7 @@ import { advanceStream, anchorRebuild, createStreamState, - MAX_DOM_INSTANCES, + manualScroll, photoDisplayHeight, PHOTO_GAP_PX, removePhoto, @@ -163,34 +163,13 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) { } /** - * 手动滚动(§4.3):外部(滚轮/拖动/键盘)直接平移当前窗口内容。夹取在 - * 已渲染窗口范围内(有限真实列表语义——不重放全部历史);自动恢复时从 - * 当前 offset 锚点继续(不回顶)。paused 状态下亦可用(reducedMotion 手动 - * 查看路径)。超出窗口尾部时按需补窗(anchor 语义由下次 reanchor 收敛)。 + * 手动滚动(§4.3;效果示例双向 translate 语义):正/负向平移(滚轮/拖动/ + * 键盘),负向队尾前插回绕。纯逻辑在 stream.ts manualScroll。 */ function nudgeOffset(deltaPx: number): void { const s = state.value; if (s === null || s.items.length === 0) return; - let total = 0; - for (const item of s.items) { - total += photoDisplayHeight(s.sizes[item.queueIdx], innerWidth) + PHOTO_GAP_PX; - } - const maxOffset = Math.max(0, total - viewportHeight); - // 头部上界:负向钳 0(已到最新);尾部下界钳 maxOffset(到窗口尾)。 - s.offset = Math.max(0, Math.min(s.offset + deltaPx, maxOffset)); - // 手动平移可能越过退役边界:复用 advance 的退役循环(速度 0 的纯夹取)。 - let guard = 0; - while ( - guard < MAX_DOM_INSTANCES && - s.offset >= photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX - ) { - const headH = - photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX; - s.offset -= headH; - s.headIdx = (s.headIdx + 1) % s.queue.length; - if (s.headIdx === 0) s.gen += 1; - guard += 1; - } + manualScroll(s, deltaPx, innerWidth, viewportHeight); syncRender(); } diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 397935b..4bb3488 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -38,6 +38,7 @@ export default { collapseWindow: 'Collapse photo window', streamPaused: 'Photo scrolling paused', streamResumed: 'Photo scrolling resumed', + streamKeyHint: 'Space pause · ↑↓ scroll · Esc collapse', }, download: { action: 'Download photo', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 49fd15e..e2425e1 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -39,6 +39,7 @@ export default { collapseWindow: '收起照片窗', streamPaused: '照片滚动已暂停', streamResumed: '照片滚动继续', + streamKeyHint: '空格 暂停 · ↑↓ 滚动 · Esc 收起', }, download: { action: '下载图片', diff --git a/apps/viewer/src/pages/EmbedPage.vue b/apps/viewer/src/pages/EmbedPage.vue index 4756e53..b571b44 100644 --- a/apps/viewer/src/pages/EmbedPage.vue +++ b/apps/viewer/src/pages/EmbedPage.vue @@ -142,6 +142,7 @@ function openPhoto(photoId: string): void {
8px 或目标已 - 下线 → 取消点击;photoUrl 失效(换绑)亦拦截; - · 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎 - nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。 - 无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 --> + (EmbedPage 于 v=2 查询时渲染)。结构与效果对齐方案 §2.2 与效果示例 + docs/designs/embed-preview-2026-09-22.html: + · 照片带:原比例、宽满、3px 缝;初始偏移 32px(顶部露首照上缘片段); + · 交互层 §4:暂停原因集合(hidden/offscreen/hover(mouse)/focus/pointer/ + manual/reduced/userPaused/decode-loading),瞬态 500ms 恢复,Space 显式 + 暂停不自动恢复;点击锁定 + >8px 拖动取消(1:1 跟手);滚轮/拖动/方向键 + ±40px 手动(双向回绕);Escape 收起; + · 气泡层 §5:ReactionLayer(真实 likes.burst 驱动,WS 只读订阅经 + useEmbedReactions——不提交点赞不上报浏览 A15); + · 启动闸 §3.1:首窗图片 decode + 500ms 静置后才自动滚动; + · 无 header/footer/页码/箭头/圆点;循环副本对辅助技术隐藏。 +--> @@ -252,6 +339,7 @@ onBeforeUnmount(() => {
{
{{ t('embed.comingSoon') }}
+ + + + {{ statusText }} -
+ + + diff --git a/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm b/docs/designs/ui04-implementation/recordings/e03-scroll-32s.webm index 34a7b8d..739dc1f 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/ui04-implementation/recordings/e05-hearts-16s.webm b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm new file mode 100644 index 0000000..745603e Binary files /dev/null and b/docs/designs/ui04-implementation/recordings/e05-hearts-16s.webm differ diff --git a/docs/plans/progress/UI04-验收记录.md b/docs/plans/progress/UI04-验收记录.md index fc27d04..5c6f6a9 100644 --- a/docs/plans/progress/UI04-验收记录.md +++ b/docs/plans/progress/UI04-验收记录.md @@ -64,7 +64,7 @@ - [x] E02 静态高保真样板(登记见下) - [x] E03 连续滚动(登记见下) - [x] E04 操作和生命周期(登记见下) -- [ ] E05 实时气泡 +- [x] E05 实时气泡(登记见下;含效果示例 embed-preview-2026-09-22.html 对齐) - [ ] E06 管理预览与接入 - [ ] E07 验收(EMB-A01…A16 逐项见第 5 节) @@ -124,6 +124,36 @@ - E04 出口确认:小窗可操作(暂停/手动/键盘/点击四通道),动画不造成点错照片 (点击锁定+拖动取消断言)。 +### 效果示例对齐登记(2026-09-22,用户指定 docs/designs/embed-preview-2026-09-22.html) + +以效果示例为准重调的实现值(方案值域内的具体冻结): +- 初始偏移 32px(顶部露首照上缘片段,示例 offset=32;单张仍完整展示); +- 心形:实心 SVG 心、尺寸 12/16/20 轮转、双色 #FF6B7A/#FF8B98 交替、右缘 + 10/20/30、漂移 {-8,5,8}px、旋转 -5°→6°、scale .8→1.05、上浮 110px、 + 1.6s ease-out(透明度 0/.85/.75/0)、drop-shadow——ReactionLayer 用非 scoped + 样式块(createElement 粒子无 data-v 哈希,scoped 选择器不命中——踩坑登记); + 一批 ≤3 颗、颗间 300ms 错峰、同屏 ≤8; +- 拖动 1:1 跟手(去 0.5 系数);方向键 ±40;聚焦小提示 focus-tip; +- 启动闸:首窗图片 decode + 500ms 静置(decode 原因位); +- 手动滚动双向回绕(示例 translate 负向 prepend 分支——stream.ts manualScroll); +- loader v2:把手 100×44/圆角 22/1px #e3eaf2 边/白底/蓝图标 16+12px 文案; + 默认水平偏移 ≤599px 视口 12px;收起钮 − 字形 19px(#ffffffd1 底)。 + +### E05 登记(2026-09-22) + +- 架构:纯函数 reactions.ts(heartsForDelta 抽样 1→1/2–5→2/更大→3;BurstDeduper + TTL30s/容量200;PendingVisibleCache 5s/50 条;EmissionThrottle 250ms 合并封顶3) + + 编排 useEmbedReactions.ts(复用 RealtimeClient 只读订阅 album.publicToken—— + 不经 useAlbumRealtime 带出观众页副作用;换绑停连清态;失活 10s 宽限断连; + WS 断线不伪造气泡)+ ReactionLayer.vue 粒子层(§5.1 值域 + 示例冻结值)。 +- 归属(§5.2):burst → 照片可见(交集 ≥20px)→ visibleRectOf 交集下侧-10 出泡; + 不可见 → 5s 缓存转可见补放;收起/隐藏/换绑/reducedMotion 清粒子。 +- 单测:embed-reactions.spec.ts 8 用例——全仓 viewer 244 绿。 +- e2e(E05 块 2 用例 + 录屏):真实公开路径点赞(另一上下文 POST likes)→ + 心形出现于被赞照片交集下侧区间(EMB-A09);组件自身零点赞/浏览提交 + (网络断言,EMB-A15);16s 真实点赞节奏录屏 recordings/e05-hearts-16s.webm。 +- E05 出口确认:两上下文真实点赞触发正确图气泡(归属区间断言通过)。 + ## 4 测量与证据索引 (E02+ 起登记:尺寸实测、位移曲线、DOM/连接上限统计、录屏清单。) diff --git a/packages/embed-loader/src/loader-v2.js b/packages/embed-loader/src/loader-v2.js index 3767907..74c353b 100644 --- a/packages/embed-loader/src/loader-v2.js +++ b/packages/embed-loader/src/loader-v2.js @@ -95,8 +95,8 @@ import { return false; } - // 收起把手(§2.4):白底胶囊、蓝色相机图标 + 照片/Photos 文案;全部 - // inline style(零宿主 CSS 污染);有效点击区 ≥44×44。 + // 收起把手(§2.4;效果示例 .handle 精确值):100×44 白底胶囊、圆角 22、 + // 1px #e3eaf2 边、蓝图标 16 + 12px 文案;全部 inline style(零宿主污染)。 function createHandle(inst) { var h = document.createElement('div'); h.setAttribute('role', 'button'); @@ -105,24 +105,24 @@ import { var s = h.style; s.position = 'fixed'; s.height = HANDLE_MIN + 'px'; - s.minWidth = '88px'; + s.width = '100px'; s.boxSizing = 'border-box'; - s.padding = '0 16px'; s.borderRadius = '22px'; + s.border = '1px solid #e3eaf2'; s.background = '#ffffff'; - s.boxShadow = '0 2px 10px rgba(15,23,42,0.18)'; + s.boxShadow = '0 4px 16px rgba(15,23,42,0.125)'; s.zIndex = String(inst.config.zIndex); s.cursor = 'pointer'; s.display = 'flex'; s.alignItems = 'center'; s.justifyContent = 'center'; - s.gap = '6px'; - s.font = '500 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif'; - s.color = '#1A73E8'; + s.gap = '8px'; + s.font = '400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif'; + s.color = '#32618e'; s.whiteSpace = 'nowrap'; h.innerHTML = - '' + + '' + '' + (isZh(inst.config.lang) ? '照片' : 'Photos') + ''; function activate(ev) { if (ev.type === 'keydown') { @@ -153,11 +153,12 @@ import { return inst.collapsed ? HANDLE_MIN : inst.height; } - /** 默认水平偏移:未配置时桌面 16 / 窄屏 12(§2.1)。data-* 显式或后台 - * 配置到达后(offsetConfigured)按配置值。 */ + /** 默认水平偏移:未配置时桌面 16 / ≤599px 视口 12(§2.1;效果示例 + * media(max-width:599px) 档)。data-* 显式或后台配置到达后 + * (offsetConfigured)按配置值。 */ function effectiveOffsetX(inst) { if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX; - return inst.narrow ? 12 : 16; + return w.innerWidth <= 599 ? 12 : 16; } /** wrapper 几何:W=min(195,可用宽)、H=min(280,可用高);不足操作下限时