diff --git a/apps/admin/src/types/api/embedSlot.d.ts b/apps/admin/src/types/api/embedSlot.d.ts index 5787937..e990cde 100644 --- a/apps/admin/src/types/api/embedSlot.d.ts +++ b/apps/admin/src/types/api/embedSlot.d.ts @@ -29,7 +29,11 @@ declare namespace Api { /** 轮播图片适配方式(UI-01:contain=4:3 舞台完整展示;cover=裁切填满) */ type ImageFit = 'contain' | 'cover' - /** 展示设置(响应中字段恒齐全;UI-01 新增字段为可选扩展,响应恒为归一化全集) */ + /** 展示模式(UI-04):carousel=旧版轮播(v1 loader);vertical-stream= + * v2 垂直照片流窗(固定 195×280 连续上滚,忽略 size/intervalMs/imageFit 布局作用) */ + type DisplayMode = 'carousel' | 'vertical-stream' + + /** 展示设置(响应中字段恒齐全;UI-01/UI-04 新增字段为可选扩展,响应恒为归一化全集) */ interface Settings { position: Position size: Size @@ -46,6 +50,12 @@ declare namespace Api { offsetBottom: number /** 宿主页面层级(UI-01;1..2147483000,缺省 1000) */ zIndex: number + /** 展示模式(UI-04;服务端归一恒在,旧记录读取归一为 carousel) */ + displayMode: DisplayMode + /** v2 自动上滚速度 CSS px/秒(8..32,缺省 16;仅 vertical-stream 生效) */ + scrollSpeed: number + /** v2 点赞气泡开关(缺省 true;false 仅隐藏气泡) */ + reactionsEnabled: boolean } /** 展示位条目(管理视角) */ diff --git a/apps/admin/src/utils/system/embedSlots.spec.ts b/apps/admin/src/utils/system/embedSlots.spec.ts index dcd8419..d117a2c 100644 --- a/apps/admin/src/utils/system/embedSlots.spec.ts +++ b/apps/admin/src/utils/system/embedSlots.spec.ts @@ -11,11 +11,13 @@ import { clampIntervalMs, clampOffsetBottom, clampOffsetX, + clampScrollSpeed, clampZIndex, DEFAULT_EMBED_SETTINGS, EMBED_ORIGIN_FALLBACK, embedOriginFromEnv, explicitDataAttrs, + explicitDataAttrsV2, isSlotDraftDirty, type EmbedSettingsDraft } from './embedSlots' @@ -34,7 +36,7 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = { } describe('buildSettingsPayload 表单 → settings 载荷', () => { - it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => { + it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01/UI-04 新字段)', () => { expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({ position: 'right', size: 'md', @@ -44,7 +46,10 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => { imageFit: 'contain', offsetX: 16, offsetBottom: 24, - zIndex: 1000 + zIndex: 1000, + displayMode: 'vertical-stream', + scrollSpeed: 16, + reactionsEnabled: true }) }) @@ -91,6 +96,27 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => { zIndex: 1 }) }) + + it('UI-04 scrollSpeed → 夹取到 8..32(非有限值回退 16)', () => { + expect(clampScrollSpeed(1)).toBe(8) + expect(clampScrollSpeed(64)).toBe(32) + expect(clampScrollSpeed(Number.NaN)).toBe(16) + const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 20 } + expect(buildSettingsPayload(draft).scrollSpeed).toBe(20) + const overflow: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 99 } + expect(buildSettingsPayload(overflow).scrollSpeed).toBe(32) + }) + + it('UI-04 displayMode/reactionsEnabled 变化 → 进编辑载荷(整体替换含新字段)', () => { + const changed: EmbedSettingsDraft = { + ...DEFAULT_EMBED_SETTINGS, + displayMode: 'carousel', + reactionsEnabled: false + } + expect(buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, changed)).toEqual({ + settings: changed + }) + }) }) describe('buildSlotEditPayload 编辑载荷收敛', () => { @@ -140,10 +166,13 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => { }) describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => { - it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => { - expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({}) + it('全默认 settings(carousel)→ 零 data-* 覆盖(裸 v1 引用)', () => { + expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' })).toEqual({}) expect( - buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS }) + buildEmbedSnippet('https://view.example.com', 'tok', { + ...DEFAULT_EMBED_SETTINGS, + displayMode: 'carousel' + }) ).toBe( '' ) @@ -155,6 +184,7 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省 it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => { const settings: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, + displayMode: 'carousel', position: 'left', intervalMs: 8000, imageFit: 'cover' @@ -179,7 +209,10 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省 imageFit: 'cover', offsetX: 40, offsetBottom: 80, - zIndex: 2000 + zIndex: 2000, + displayMode: 'carousel', + scrollSpeed: 24, + reactionsEnabled: false } expect(Object.keys(explicitDataAttrs(settings))).toEqual([ 'data-position', @@ -194,6 +227,40 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省 }) }) +describe('嵌入代码 v2 入口(UI-04:displayMode=vertical-stream → /embed/v2/)', () => { + it('v2 默认 settings → 裸 v2 引用(无 v1 布局 data-*)', () => { + expect(buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })).toBe( + '' + ) + }) + + it('v2 非默认 speed/reactions/位置 → data-speed/data-reactions/data-position(v1 布局字段不生成)', () => { + const settings: EmbedSettingsDraft = { + ...DEFAULT_EMBED_SETTINGS, + position: 'left', + size: 'sm', + intervalMs: 8000, + imageFit: 'cover', + scrollSpeed: 24, + reactionsEnabled: false + } + expect(buildEmbedSnippet('https://view.example.com', 'tok', settings)).toBe( + '' + ) + }) + + it('explicitDataAttrsV2:剥离 v1 布局字段,追加 speed/reactions(仅非默认)', () => { + const base = { ...DEFAULT_EMBED_SETTINGS, position: 'left' as const, size: 'sm' as const } + expect(explicitDataAttrsV2(base)).toEqual({ 'data-position': 'left' }) + expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 32 })).toEqual({ + 'data-speed': '32' + }) + expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, reactionsEnabled: false })).toEqual({ + 'data-reactions': 'off' + }) + }) +}) + describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => { it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => { const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop') diff --git a/apps/admin/src/utils/system/embedSlots.ts b/apps/admin/src/utils/system/embedSlots.ts index 97e3b5f..e2ccd88 100644 --- a/apps/admin/src/utils/system/embedSlots.ts +++ b/apps/admin/src/utils/system/embedSlots.ts @@ -35,13 +35,19 @@ export const EMBED_OFFSET_BOTTOM_MAX = 240 export const EMBED_Z_INDEX_MIN = 1 export const EMBED_Z_INDEX_MAX = 2147483000 +/** 契约 UI-04 v2(vertical-stream)字段边界(对齐 loader params.js v2) */ +export const EMBED_SCROLL_SPEED_MIN = 8 +export const EMBED_SCROLL_SPEED_MAX = 32 + /** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */ export const EMBED_SIZE_WIDTH: Record = { sm: 280, md: 320 } /** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */ export type EmbedSettingsDraft = Api.EmbedSlot.Settings -/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */ +/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库—— + * 唯 UI-04 例外:管理端新建默认 displayMode=vertical-stream(§8.2「默认新建 + * 选择新版」,经表单显式提交;服务端/契约缺省仍是 carousel,存量记录不迁移)。 */ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = { position: 'right', size: 'md', @@ -51,7 +57,10 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = { imageFit: 'contain', offsetX: 16, offsetBottom: 24, - zIndex: 1000 + zIndex: 1000, + displayMode: 'vertical-stream', + scrollSpeed: 16, + reactionsEnabled: true } /** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */ @@ -62,17 +71,21 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string /** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。 * UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖 - * (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 */ + * (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 + * UI-04:displayMode=vertical-stream → 入口用 /embed/v2/loader.js(data-speed/ + * data-reactions 覆盖仅 v2 生成);carousel → v1 入口(v2 字段不生成属性)。 */ export function buildEmbedSnippet( origin: string, slotToken: string, settings?: Api.EmbedSlot.Settings | null ): string { - const attrs = explicitDataAttrs(settings) + const isV2 = settings?.displayMode === 'vertical-stream' + const attrs = isV2 ? explicitDataAttrsV2(settings) : explicitDataAttrs(settings) const attrText = Object.entries(attrs) .map(([name, value]) => ` ${name}="${value}"`) .join('') - return `` + const version = isV2 ? 'v2' : 'v1' + return `` } /** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */ @@ -85,7 +98,8 @@ const DATA_ATTR_NAMES: Partial> = { offsetX: 'data-offset-x', offsetBottom: 'data-offset-bottom', zIndex: 'data-z-index' - // collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则) + // collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则); + // displayMode 不生成(版本由入口 URL 决定,§7.2 v2 不可经旧 data-size 复原)。 } /** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */ @@ -103,6 +117,25 @@ export function explicitDataAttrs( return attrs } +/** v2(vertical-stream)data-* 覆盖表:共用字段同上 + speed/reactions; + * v1 布局字段(size/interval/imageFit)对 v2 无布局作用,不生成(§7.2)。 */ +export function explicitDataAttrsV2( + settings?: Api.EmbedSlot.Settings | null +): Record { + const attrs = explicitDataAttrs(settings) + delete attrs['data-size'] + delete attrs['data-interval'] + delete attrs['data-image-fit'] + if (settings === null || settings === undefined) return attrs + if (settings.scrollSpeed !== DEFAULT_EMBED_SETTINGS.scrollSpeed) { + attrs['data-speed'] = String(settings.scrollSpeed) + } + if (settings.reactionsEnabled !== DEFAULT_EMBED_SETTINGS.reactionsEnabled) { + attrs['data-reactions'] = settings.reactionsEnabled ? 'on' : 'off' + } + return attrs +} + /** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */ export function clampIntervalMs(value: number): number { if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs @@ -127,6 +160,12 @@ export function clampZIndex(value: number): number { return Math.min(EMBED_Z_INDEX_MAX, Math.max(EMBED_Z_INDEX_MIN, Math.round(value))) } +/** scrollSpeed 夹取到契约区间(UI-04:非有限值回退缺省 16) */ +export function clampScrollSpeed(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.scrollSpeed + return Math.min(EMBED_SCROLL_SPEED_MAX, Math.max(EMBED_SCROLL_SPEED_MIN, Math.round(value))) +} + /** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */ export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings { return { @@ -134,7 +173,8 @@ export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.S intervalMs: clampIntervalMs(draft.intervalMs), offsetX: clampOffsetX(draft.offsetX), offsetBottom: clampOffsetBottom(draft.offsetBottom), - zIndex: clampZIndex(draft.zIndex) + zIndex: clampZIndex(draft.zIndex), + scrollSpeed: clampScrollSpeed(draft.scrollSpeed) } } @@ -149,7 +189,10 @@ function settingsChanged(current: Api.EmbedSlot.Settings, next: EmbedSettingsDra current.imageFit !== next.imageFit || current.offsetX !== clampOffsetX(next.offsetX) || current.offsetBottom !== clampOffsetBottom(next.offsetBottom) || - current.zIndex !== clampZIndex(next.zIndex) + current.zIndex !== clampZIndex(next.zIndex) || + current.displayMode !== next.displayMode || + current.scrollSpeed !== clampScrollSpeed(next.scrollSpeed) || + current.reactionsEnabled !== next.reactionsEnabled ) } diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts new file mode 100644 index 0000000..c57590f --- /dev/null +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -0,0 +1,123 @@ +import { createServer, type Server } from 'node:http'; +import { expect, test, type Page } from '@playwright/test'; +import { + cleanupSeededAlbums, + createEmbedSlot, + flushOutbox, + flushSync, + seedAlbum, + seedPhoto, + updateEmbedSlot, +} from './helpers'; + +// UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ← +// viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展: +// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer(本文件首批用例)。 +const HOST_PORT = 3999; +const VIEWER_ORIGIN = 'http://127.0.0.1:5175'; +const MINUTE = 60_000; + +let slotToken = ''; + +async function seedFeed(alias: string): Promise { + const album = await seedAlbum({ + alias, + defaultLocale: 'zh-CN', + translations: [ + { locale: 'zh-CN', title: `${alias} 中文相册` }, + { locale: 'en', title: `${alias} Album` }, + ], + }); + for (let i = 1; i <= 6; i++) { + await seedPhoto(album.id, `${alias}-p${i}`, { + publishedAt: new Date(Date.now() - (7 - i) * MINUTE), + }); + } + await flushSync(album.id); + const slot = await createEmbedSlot(`ui04-v2-${alias}`); + // v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡) + await updateEmbedSlot(slot.id, { + albumId: album.id, + settings: { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + imageFit: 'contain', + offsetX: 16, + offsetBottom: 24, + zIndex: 1000, + displayMode: 'vertical-stream', + scrollSpeed: 16, + reactionsEnabled: true, + }, + }); + await flushOutbox(); + return slot.token; +} + +let currentHtml = ''; +const server: Server = createServer((_req, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(currentHtml); +}); + +async function openHost(page: Page, viewport: { width: number; height: number }): Promise { + await page.setViewportSize(viewport); + currentHtml = ` + + + UI-04 v2 host + +

Host page (v2 stream)

+ + +`; + await page.goto(`http://127.0.0.1:${HOST_PORT}`); +} + +test.beforeAll(async () => { + slotToken = await seedFeed('ui04v2'); + await new Promise((resolve) => server.listen(HOST_PORT, '127.0.0.1', resolve)); +}); + +test.afterAll(async () => { + await cleanupSeededAlbums(); + server.close(); +}); + +test.describe.serial('E01 exit gate: fixed window, no footer', () => { + for (const [name, viewport] of [ + ['desktop 1440x900', { width: 1440, height: 900 }], + ['mobile 390x844', { width: 390, height: 844 }], + ] as const) { + test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => { + const mobile = viewport.width < 480; + await openHost(page, viewport); + const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]'); + const wrapper = frame.locator('..'); + // 手机默认收起(沿用 v1 策略):点击把手展开后测量。 + if (mobile) { + await page.locator('[role="button"]').first().click(); + } + await expect(frame).toBeVisible(); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) + .toBe(195); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) + .toBe(280); + // iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。 + const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); + await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible(); + // v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部 + // scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。 + await expect(inside.locator('.embed-head')).toHaveCount(0); + await expect(inside.locator('.embed-footer')).toHaveCount(0); + await expect(inside.getByText('查看相册')).toHaveCount(0); + await expect(inside.getByText('照片轮播')).toHaveCount(0); + await expect(inside.locator('.stream-viewport')).toHaveCount(1); + }); + } +}); diff --git a/apps/viewer/public/embed/v1/loader.js b/apps/viewer/public/embed/v1/loader.js index 175d2c7..a2c4f2d 100644 --- a/apps/viewer/public/embed/v1/loader.js +++ b/apps/viewer/public/embed/v1/loader.js @@ -1 +1 @@ -(()=>{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],f={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},F={sm:280,md:320},W=3e3,G=3e4,C=60,ce=640,Z=0,q=120,$=0,j=240,J=1,P=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function m(t,i,o,s){let u=Number(t);return isNaN(u)||!isFinite(u)?s:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return m(t,W,G,f.intervalMs)}function Y(t,i){let o=t||{},s=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:s||null,lang:c(o,"lang")?x(L,o.lang,f.lang):f.lang,position:c(o,"position")?x(k,o.position,f.position):f.position,intervalMs:c(o,"interval")?ue(o.interval):f.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,f.size):f.size,collapsedDefault:f.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,f.imageFit):f.imageFit,offsetX:c(o,"offsetX")?m(o.offsetX,Z,q,f.offsetX):f.offsetX,offsetBottom:c(o,"offsetBottom")?m(o.offsetBottom,$,j,f.offsetBottom):f.offsetBottom,zIndex:c(o,"zIndex")?m(o.zIndex,J,P,f.zIndex):f.zIndex,pollMs:c(o,"poll")?m(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,f.position),size:x(H,t.size,f.size),intervalMs:m(t.intervalMs,W,G,f.intervalMs),lang:x(L,t.lang,f.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:f.collapsedDefault,imageFit:x(R,t.imageFit,f.imageFit),offsetX:m(t.offsetX,Z,q,f.offsetX),offsetBottom:m(t.offsetBottom,$,j,f.offsetBottom),zIndex:m(t.zIndex,J,P,f.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let s=t.explicit;return{slot:t.slot,lang:s.lang?t.lang:o.lang,position:s.position?t.position:o.position,intervalMs:s.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":s.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:s.imageFit?t.imageFit:o.imageFit,offsetX:s.offsetX?t.offsetX:o.offsetX,offsetBottom:s.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:s.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:s}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let s=i==null?0:Number(i);if(o<=s)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let s=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(s+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(s+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(s+="&poll="+String(o.pollMs)),s}function re(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(C,Math.round(i)))}(function(){"use strict";try{let B=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(r){return!1}},N=function(e,r){try{r?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(n){}},S=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(r){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(r){return null}},ae=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u7167\u7247":"Photos"},le=function(e){var r=document.createElement("div");r.setAttribute("role","button"),r.setAttribute("tabindex","0"),r.setAttribute("aria-label",ae(e.config.lang));var n=r.style;n.position="fixed",n.height=u+"px",n.minWidth=u+"px",n.boxSizing="border-box",n.padding="0 14px",n.borderRadius=u/2+"px",n.background="rgba(15,23,42,0.88)",n.boxShadow="0 2px 10px rgba(0,0,0,0.3)",n.zIndex=String(e.config.zIndex),n.cursor="pointer",n.display="flex",n.alignItems="center",n.justifyContent="center",n.gap="6px",n.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',n.color="#ffffff",n.whiteSpace="nowrap",r.innerHTML=''+ie(e.config.lang)+"";function p(a){if(a.type==="keydown"){var l=a.key;if(l!=="Enter"&&l!==" "&&l!=="Spacebar")return;a.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return r.addEventListener("click",p),r.addEventListener("keydown",p),r},fe=function(e){return e.collapsed?u:e.height},I=function(e){var r=e.config.position==="left"?"left":"right",n=r==="left"?"right":"left",p=e.narrow?"sm":e.config.size,a=F[p]||F.md,l=t.innerWidth-e.config.offsetX-8;ll&&(a=l);var d=e.wrapper.style;if(d.width=a+"px",d.zIndex=String(e.config.zIndex),d[r]=e.config.offsetX+"px",d[n]="",e.handle){var h=e.handle.style;h.zIndex=String(e.config.zIndex),h[r]=e.config.offsetX+"px",h[n]=""}},T=function(){for(var e={left:[],right:[]},r=0;r0&&z+C>t.innerHeight&&A(y,!1),y.wrapper.style.bottom=z+"px",y.handle&&(y.handle.style.bottom=z+"px"),d=z}},v=function(){if(w!==null||typeof t.requestAnimationFrame!="function"){T();return}w=t.requestAnimationFrame(function(){w=null,T()})},A=function(e,r){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=le(e),document.body.appendChild(e.handle),I(e)),e.handle.style.display="flex",r!==!1&&N(e.config.slot,!0))},b=function(e,r){A(e,r),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},se=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(i+e.config.slot)}catch(n){}var r=g.indexOf(e);r>=0&&g.splice(r,1),delete M[e.config.slot],g.length===0&&t.removeEventListener("message",O),v()}},pe=function(e,r,n){e.backendVersion=r,e.config=ee(e.config,n),I(e),!e.userChoice&&!S(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,r){var n=document.createElement("div"),p=n.style;p.position="fixed",p.bottom=r.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",oe(e,r.slot,r)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var l=a.style;l.width="100%",l.height="100%",l.border="0",l.display="block",n.appendChild(a),document.body.appendChild(n);var d={config:r,wrapper:n,frame:a,frameOrigin:_(a)||e,height:o,collapsed:!1,narrow:r.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return g.push(d),I(d),S(r.slot)?B(r.slot)&&b(d,!1):d.narrow&&b(d,!1),d},O=function(e){try{var r=e.data;if(!r||r.photoliveEmbed!==!0)return;var n=r.type;if(Q.indexOf(n)<0)return;for(var p=0;p{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],s={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},X={sm:280,md:320},W=3e3,G=3e4,B=60,ce=640,Z=0,q=120,P=0,j=240,$=1,J=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function g(t,i,o,f){let u=Number(t);return isNaN(u)||!isFinite(u)?f:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return g(t,W,G,s.intervalMs)}function Y(t,i){let o=t||{},f=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:f||null,lang:c(o,"lang")?x(L,o.lang,s.lang):s.lang,position:c(o,"position")?x(k,o.position,s.position):s.position,intervalMs:c(o,"interval")?ue(o.interval):s.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,s.size):s.size,collapsedDefault:s.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,s.imageFit):s.imageFit,offsetX:c(o,"offsetX")?g(o.offsetX,Z,q,s.offsetX):s.offsetX,offsetBottom:c(o,"offsetBottom")?g(o.offsetBottom,P,j,s.offsetBottom):s.offsetBottom,zIndex:c(o,"zIndex")?g(o.zIndex,$,J,s.zIndex):s.zIndex,pollMs:c(o,"poll")?g(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,s.position),size:x(H,t.size,s.size),intervalMs:g(t.intervalMs,W,G,s.intervalMs),lang:x(L,t.lang,s.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:s.collapsedDefault,imageFit:x(R,t.imageFit,s.imageFit),offsetX:g(t.offsetX,Z,q,s.offsetX),offsetBottom:g(t.offsetBottom,P,j,s.offsetBottom),zIndex:g(t.zIndex,$,J,s.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let f=t.explicit;return{slot:t.slot,lang:f.lang?t.lang:o.lang,position:f.position?t.position:o.position,intervalMs:f.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":f.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:f.imageFit?t.imageFit:o.imageFit,offsetX:f.offsetX?t.offsetX:o.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:f.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:f}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let f=i==null?0:Number(i);if(o<=f)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let f=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(f+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(f+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(f+="&poll="+String(o.pollMs)),f}function ne(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(B,Math.round(i)))}(function(){"use strict";try{let F=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(n){return!1}},N=function(e,n){try{n?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(r){}},w=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(n){return!1}},re=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(n){return null}},le=function(e){var n=e==="zh-CN";if(!n&&e==="auto")try{n=/^zh/i.test(t.navigator.language||"")}catch(r){n=!1}return n?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var n=e==="zh-CN";if(!n&&e==="auto")try{n=/^zh/i.test(t.navigator.language||"")}catch(r){n=!1}return n?"\u7167\u7247":"Photos"},ae=function(e){var n=document.createElement("div");n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label",le(e.config.lang));var r=n.style;r.position="fixed",r.height=u+"px",r.minWidth=u+"px",r.boxSizing="border-box",r.padding="0 14px",r.borderRadius=u/2+"px",r.background="rgba(15,23,42,0.88)",r.boxShadow="0 2px 10px rgba(0,0,0,0.3)",r.zIndex=String(e.config.zIndex),r.cursor="pointer",r.display="flex",r.alignItems="center",r.justifyContent="center",r.gap="6px",r.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',r.color="#ffffff",r.whiteSpace="nowrap",n.innerHTML=''+ie(e.config.lang)+"";function p(l){if(l.type==="keydown"){var a=l.key;if(a!=="Enter"&&a!==" "&&a!=="Spacebar")return;l.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return n.addEventListener("click",p),n.addEventListener("keydown",p),n},se=function(e){return e.collapsed?u:e.height},z=function(e){var n=e.config.position==="left"?"left":"right",r=n==="left"?"right":"left",p=e.narrow?"sm":e.config.size,l=X[p]||X.md,a=t.innerWidth-e.config.offsetX-8;aa&&(l=a);var d=e.wrapper.style;if(d.width=l+"px",d.zIndex=String(e.config.zIndex),d[n]=e.config.offsetX+"px",d[r]="",e.handle){var m=e.handle.style;m.zIndex=String(e.config.zIndex),m[n]=e.config.offsetX+"px",m[r]=""}},T=function(){for(var e={left:[],right:[]},n=0;n0&&y+B>t.innerHeight&&A(I,!1),I.wrapper.style.bottom=y+"px",I.handle&&(I.handle.style.bottom=y+"px"),d=y}},v=function(){if(S!==null||typeof t.requestAnimationFrame!="function"){T();return}S=t.requestAnimationFrame(function(){S=null,T()})},A=function(e,n){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=ae(e),document.body.appendChild(e.handle),z(e)),e.handle.style.display="flex",n!==!1&&N(e.config.slot,!0))},b=function(e,n){A(e,n),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},fe=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(i+e.config.slot)}catch(r){}var n=h.indexOf(e);n>=0&&h.splice(n,1),delete M[e.config.slot],h.length===0&&t.removeEventListener("message",V),v()}},pe=function(e,n,r){e.backendVersion=n,e.config=ee(e.config,r),z(e),!e.userChoice&&!w(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,n){var r=document.createElement("div"),p=r.style;p.position="fixed",p.bottom=n.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var l=document.createElement("iframe");l.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),l.setAttribute("src",oe(e,n.slot,n)),l.setAttribute("referrerpolicy","no-referrer-when-downgrade"),l.setAttribute("title","PhotoLive photos");var a=l.style;a.width="100%",a.height="100%",a.border="0",a.display="block",r.appendChild(l),document.body.appendChild(r);var d={config:n,wrapper:r,frame:l,frameOrigin:_(l)||e,height:o,collapsed:!1,narrow:n.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return h.push(d),z(d),w(n.slot)?F(n.slot)&&b(d,!1):d.narrow&&b(d,!1),d},V=function(e){try{var n=e.data;if(!n||n.photoliveEmbed!==!0)return;var r=n.type;if(Q.indexOf(r)<0)return;for(var p=0;p{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 = {}): EmbedSlotSett offsetX: 16, offsetBottom: 24, zIndex: 1000, + // UI-04 v2 字段(响应恒为归一化全集;旧记录读取归一为 carousel)。 + displayMode: 'carousel', + scrollSpeed: 16, + reactionsEnabled: true, ...overrides, }; } diff --git a/apps/viewer/src/embed/stream.ts b/apps/viewer/src/embed/stream.ts new file mode 100644 index 0000000..8405ad3 --- /dev/null +++ b/apps/viewer/src/embed/stream.ts @@ -0,0 +1,62 @@ +// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。 +// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖); +// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。 + +/** 元数据异常时的高度比例回退(4:3,§2.3)。 */ +export const FALLBACK_RATIO = 4 / 3; + +/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */ +export const MAX_RATIO = 8; + +/** 照片缝宽度(px,§2.2 硬性 3px)。 */ +export const PHOTO_GAP_PX = 3; + +export interface StreamPhotoSize { + width: number; + height: number; +} + +/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */ +export function photoRatio(size: StreamPhotoSize): number { + if ( + Number.isFinite(size.width) && + Number.isFinite(size.height) && + size.width > 0 && + size.height > 0 + ) { + return Math.min(size.height / size.width, MAX_RATIO); + } + return FALLBACK_RATIO; +} + +/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */ +export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number { + return photoRatio(size) * innerWidth; +} + +/** + * 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从 + * startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目 + * 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。 + */ +export function windowCount( + sizes: StreamPhotoSize[], + startIndex: number, + innerWidth: number, + viewportHeight: number, + bufferPx: number, +): number { + if (sizes.length === 0) return 0; + const target = viewportHeight + bufferPx; + let acc = 0; + let count = 0; + for (let i = 0; i < sizes.length; i++) { + const item = sizes[(startIndex + i) % sizes.length]; + acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX; + count += 1; + if (acc >= target) return count; + } + // 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环 + // 副本由编排层处理,本函数不放大队列)。 + return count; +} diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 9ebdc69..51e86ec 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -33,6 +33,9 @@ export default { play: 'Play carousel', prev: 'Previous photo', next: 'Next photo', + // UI-04 v2 vertical photo stream window + streamLabel: 'Event photo window', + collapseWindow: 'Collapse photo window', }, 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 71d0baf..e060484 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -34,6 +34,9 @@ export default { play: '播放轮播', prev: '上一张', next: '下一张', + // —— UI-04 v2 垂直照片流窗 —— + streamLabel: '活动照片窗', + collapseWindow: '收起照片窗', }, download: { action: '下载图片', diff --git a/apps/viewer/src/pages/EmbedPage.vue b/apps/viewer/src/pages/EmbedPage.vue index 1b5e77b..4756e53 100644 --- a/apps/viewer/src/pages/EmbedPage.vue +++ b/apps/viewer/src/pages/EmbedPage.vue @@ -1,13 +1,15 @@ diff --git a/docs/plans/progress/UI04-验收记录.md b/docs/plans/progress/UI04-验收记录.md index 1260df9..000323f 100644 --- a/docs/plans/progress/UI04-验收记录.md +++ b/docs/plans/progress/UI04-验收记录.md @@ -41,12 +41,26 @@ ## 2 版本与兼容策略(E01 定案登记) -(E01 完成后填写:v2 入口形态、路由/参数、新字段清单、v1 保留验证。) +- **入口形态**:新发布物 `/embed/v2/loader.js`(源 src/loader-v2.js,v1 源 loader.js 冻结 + 零改动,双产物同 build/sync 脚本);iframe 复用同一路由 `/embed/:slotToken` + 白名单 + 版本查询 `v=2`(v1 loader 不拼 v → 旧 carousel 分支,旧 v1 接入零变化)。 +- **契约扩展**(全部可选、响应恒归一化全集):EmbedSlotSettings += `displayMode` + (carousel|vertical-stream,默认 carousel)、`scrollSpeed`(8..32 CSS px/s,默认 16)、 + `reactionsEnabled`(默认 true);事件载荷 SlotSettingsPayload 同步可选字段。 +- **缺省语义**:契约/服务端缺省 displayMode=carousel(存量记录读取归一为旧模式, + 不一夜切换);管理端新建默认 vertical-stream 由表单显式提交(§8.2)。 +- **v2 尺寸硬性**:loader 侧 V2_WIDTH/V2_HEIGHT=195/280 常量;size/intervalMs/imageFit + 不进 v2 配置面(data-* 不解析、snippet 不生成、iframe 布局忽略);resize 消息 v2 忽略。 +- **E01 证据**:v2 跨源宿主桌面/手机双端 getBoundingClientRect 实测 195×280(e2e + embed-v2.spec.ts E01 出口门 2 用例);ov 公开端点响应含新字段全集(实测 200); + v1 回归 20 e2e 全绿(EMB-A14 部分证据);loader 36 测试(+9 v2 参数面)。 ## 3 任务进度 - [x] E00 基线(本记录第 1 节) -- [ ] E01 契约和版本骨架 +- [x] E01 契约和版本骨架(第 2 节;改动面:contracts openapi+events+gen、api dto/ + embed.service(cn 管理与 ov 投影共用归一化)、embed-loader params+loader-v2+build/sync、 + viewer EmbedPage v=2 分支+EmbedStream+stream.ts+i18n、admin 类型+utils+snippet v1/v2 版本选择) - [ ] E02 静态高保真样板 - [ ] E03 连续滚动 - [ ] E04 操作和生命周期 diff --git a/eslint.config.js b/eslint.config.js index 09aba8e..027f65f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,8 +19,9 @@ module.exports = tseslint.config( // 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查) '**/generated/', // embed-loader 发布产物拷贝(viewer publicDir 携带,源在 packages/embed-loader - // ——esbuild minify 输出不适用 lint;P4b Task 3 起 viewer lint 曾因此红) - 'apps/viewer/public/embed/v1/', + // ——esbuild minify 输出不适用 lint;P4b Task 3 起 viewer lint 曾因此红; + // UI-04 起 v1/v2 双产物同规则忽略) + 'apps/viewer/public/embed/', // Playwright 运行产物 '**/playwright-report/', '**/test-results/', diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 2406042..9579980 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -4123,6 +4123,31 @@ components: maximum: 2147483000 description: 组件在宿主页面的层级(UI-01)。可缺省。 default: 1000 + displayMode: + type: string + description: | + 展示模式(UI-04):carousel=旧版轮播(v1 loader,footer/舞台结构); + vertical-stream=新版垂直照片流(v2 loader,固定 195×280 连续上滚照片窗, + 忽略 size/intervalMs/imageFit 的布局作用)。可缺省(默认 carousel—— + UI-04 前旧记录读取归一为旧模式;新建走 v2 由管理端显式提交)。 + enum: + - carousel + - vertical-stream + default: carousel + scrollSpeed: + type: integer + minimum: 8 + maximum: 32 + description: | + vertical-stream 自动上滚速度(CSS px/秒,UI-04)。可缺省(默认 16); + 仅 v2 生效,carousel 模式忽略。 + default: 16 + reactionsEnabled: + type: boolean + description: | + vertical-stream 点赞心形气泡开关(UI-04)。可缺省(默认 true); + false 仅隐藏气泡(不产生 WS 订阅),照片滚动不受影响。仅 v2 生效。 + default: true EmbedSlot: type: object diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index f263027..1bd0a14 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -157,6 +157,9 @@ export interface SlotSettingsPayload { offsetX?: number; // 0..120(默认 16) offsetBottom?: number; // 0..240(默认 24) zIndex?: number; // 1..2147483000(默认 1000) + displayMode?: string; // carousel|vertical-stream(默认 carousel,UI-04) + scrollSpeed?: number; // 8..32 CSS px/s(默认 16,UI-04;仅 v2 生效) + reactionsEnabled?: boolean; // 默认 true(UI-04;false 仅隐藏气泡) } // 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union; diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 9e00bc8..45c16c7 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -2123,6 +2123,27 @@ export interface components { * @default 1000 */ zIndex: number; + /** + * @description 展示模式(UI-04):carousel=旧版轮播(v1 loader,footer/舞台结构); + * vertical-stream=新版垂直照片流(v2 loader,固定 195×280 连续上滚照片窗, + * 忽略 size/intervalMs/imageFit 的布局作用)。可缺省(默认 carousel—— + * UI-04 前旧记录读取归一为旧模式;新建走 v2 由管理端显式提交)。 + * @default carousel + * @enum {string} + */ + displayMode: "carousel" | "vertical-stream"; + /** + * @description vertical-stream 自动上滚速度(CSS px/秒,UI-04)。可缺省(默认 16); + * 仅 v2 生效,carousel 模式忽略。 + * @default 16 + */ + scrollSpeed: number; + /** + * @description vertical-stream 点赞心形气泡开关(UI-04)。可缺省(默认 true); + * false 仅隐藏气泡(不产生 WS 订阅),照片滚动不受影响。仅 v2 生效。 + * @default true + */ + reactionsEnabled: boolean; }; /** * @description 展示位条目(管理视角)。albumId/albumTitle 为当前绑定相册(null=未 diff --git a/packages/embed-loader/dist/v1/loader.js b/packages/embed-loader/dist/v1/loader.js index 175d2c7..a2c4f2d 100644 --- a/packages/embed-loader/dist/v1/loader.js +++ b/packages/embed-loader/dist/v1/loader.js @@ -1 +1 @@ -(()=>{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],f={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},F={sm:280,md:320},W=3e3,G=3e4,C=60,ce=640,Z=0,q=120,$=0,j=240,J=1,P=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function m(t,i,o,s){let u=Number(t);return isNaN(u)||!isFinite(u)?s:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return m(t,W,G,f.intervalMs)}function Y(t,i){let o=t||{},s=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:s||null,lang:c(o,"lang")?x(L,o.lang,f.lang):f.lang,position:c(o,"position")?x(k,o.position,f.position):f.position,intervalMs:c(o,"interval")?ue(o.interval):f.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,f.size):f.size,collapsedDefault:f.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,f.imageFit):f.imageFit,offsetX:c(o,"offsetX")?m(o.offsetX,Z,q,f.offsetX):f.offsetX,offsetBottom:c(o,"offsetBottom")?m(o.offsetBottom,$,j,f.offsetBottom):f.offsetBottom,zIndex:c(o,"zIndex")?m(o.zIndex,J,P,f.zIndex):f.zIndex,pollMs:c(o,"poll")?m(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,f.position),size:x(H,t.size,f.size),intervalMs:m(t.intervalMs,W,G,f.intervalMs),lang:x(L,t.lang,f.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:f.collapsedDefault,imageFit:x(R,t.imageFit,f.imageFit),offsetX:m(t.offsetX,Z,q,f.offsetX),offsetBottom:m(t.offsetBottom,$,j,f.offsetBottom),zIndex:m(t.zIndex,J,P,f.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let s=t.explicit;return{slot:t.slot,lang:s.lang?t.lang:o.lang,position:s.position?t.position:o.position,intervalMs:s.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":s.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:s.imageFit?t.imageFit:o.imageFit,offsetX:s.offsetX?t.offsetX:o.offsetX,offsetBottom:s.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:s.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:s}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let s=i==null?0:Number(i);if(o<=s)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let s=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(s+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(s+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(s+="&poll="+String(o.pollMs)),s}function re(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(C,Math.round(i)))}(function(){"use strict";try{let B=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(r){return!1}},N=function(e,r){try{r?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(n){}},S=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(r){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(r){return null}},ae=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u7167\u7247":"Photos"},le=function(e){var r=document.createElement("div");r.setAttribute("role","button"),r.setAttribute("tabindex","0"),r.setAttribute("aria-label",ae(e.config.lang));var n=r.style;n.position="fixed",n.height=u+"px",n.minWidth=u+"px",n.boxSizing="border-box",n.padding="0 14px",n.borderRadius=u/2+"px",n.background="rgba(15,23,42,0.88)",n.boxShadow="0 2px 10px rgba(0,0,0,0.3)",n.zIndex=String(e.config.zIndex),n.cursor="pointer",n.display="flex",n.alignItems="center",n.justifyContent="center",n.gap="6px",n.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',n.color="#ffffff",n.whiteSpace="nowrap",r.innerHTML=''+ie(e.config.lang)+"";function p(a){if(a.type==="keydown"){var l=a.key;if(l!=="Enter"&&l!==" "&&l!=="Spacebar")return;a.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return r.addEventListener("click",p),r.addEventListener("keydown",p),r},fe=function(e){return e.collapsed?u:e.height},I=function(e){var r=e.config.position==="left"?"left":"right",n=r==="left"?"right":"left",p=e.narrow?"sm":e.config.size,a=F[p]||F.md,l=t.innerWidth-e.config.offsetX-8;ll&&(a=l);var d=e.wrapper.style;if(d.width=a+"px",d.zIndex=String(e.config.zIndex),d[r]=e.config.offsetX+"px",d[n]="",e.handle){var h=e.handle.style;h.zIndex=String(e.config.zIndex),h[r]=e.config.offsetX+"px",h[n]=""}},T=function(){for(var e={left:[],right:[]},r=0;r0&&z+C>t.innerHeight&&A(y,!1),y.wrapper.style.bottom=z+"px",y.handle&&(y.handle.style.bottom=z+"px"),d=z}},v=function(){if(w!==null||typeof t.requestAnimationFrame!="function"){T();return}w=t.requestAnimationFrame(function(){w=null,T()})},A=function(e,r){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=le(e),document.body.appendChild(e.handle),I(e)),e.handle.style.display="flex",r!==!1&&N(e.config.slot,!0))},b=function(e,r){A(e,r),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},se=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(i+e.config.slot)}catch(n){}var r=g.indexOf(e);r>=0&&g.splice(r,1),delete M[e.config.slot],g.length===0&&t.removeEventListener("message",O),v()}},pe=function(e,r,n){e.backendVersion=r,e.config=ee(e.config,n),I(e),!e.userChoice&&!S(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,r){var n=document.createElement("div"),p=n.style;p.position="fixed",p.bottom=r.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",oe(e,r.slot,r)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var l=a.style;l.width="100%",l.height="100%",l.border="0",l.display="block",n.appendChild(a),document.body.appendChild(n);var d={config:r,wrapper:n,frame:a,frameOrigin:_(a)||e,height:o,collapsed:!1,narrow:r.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return g.push(d),I(d),S(r.slot)?B(r.slot)&&b(d,!1):d.narrow&&b(d,!1),d},O=function(e){try{var r=e.data;if(!r||r.photoliveEmbed!==!0)return;var n=r.type;if(Q.indexOf(n)<0)return;for(var p=0;p{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],s={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},X={sm:280,md:320},W=3e3,G=3e4,B=60,ce=640,Z=0,q=120,P=0,j=240,$=1,J=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function g(t,i,o,f){let u=Number(t);return isNaN(u)||!isFinite(u)?f:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return g(t,W,G,s.intervalMs)}function Y(t,i){let o=t||{},f=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:f||null,lang:c(o,"lang")?x(L,o.lang,s.lang):s.lang,position:c(o,"position")?x(k,o.position,s.position):s.position,intervalMs:c(o,"interval")?ue(o.interval):s.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,s.size):s.size,collapsedDefault:s.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,s.imageFit):s.imageFit,offsetX:c(o,"offsetX")?g(o.offsetX,Z,q,s.offsetX):s.offsetX,offsetBottom:c(o,"offsetBottom")?g(o.offsetBottom,P,j,s.offsetBottom):s.offsetBottom,zIndex:c(o,"zIndex")?g(o.zIndex,$,J,s.zIndex):s.zIndex,pollMs:c(o,"poll")?g(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,s.position),size:x(H,t.size,s.size),intervalMs:g(t.intervalMs,W,G,s.intervalMs),lang:x(L,t.lang,s.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:s.collapsedDefault,imageFit:x(R,t.imageFit,s.imageFit),offsetX:g(t.offsetX,Z,q,s.offsetX),offsetBottom:g(t.offsetBottom,P,j,s.offsetBottom),zIndex:g(t.zIndex,$,J,s.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let f=t.explicit;return{slot:t.slot,lang:f.lang?t.lang:o.lang,position:f.position?t.position:o.position,intervalMs:f.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":f.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:f.imageFit?t.imageFit:o.imageFit,offsetX:f.offsetX?t.offsetX:o.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:f.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:f}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let f=i==null?0:Number(i);if(o<=f)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let f=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(f+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(f+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(f+="&poll="+String(o.pollMs)),f}function ne(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(B,Math.round(i)))}(function(){"use strict";try{let F=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(n){return!1}},N=function(e,n){try{n?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(r){}},w=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(n){return!1}},re=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(n){return null}},le=function(e){var n=e==="zh-CN";if(!n&&e==="auto")try{n=/^zh/i.test(t.navigator.language||"")}catch(r){n=!1}return n?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var n=e==="zh-CN";if(!n&&e==="auto")try{n=/^zh/i.test(t.navigator.language||"")}catch(r){n=!1}return n?"\u7167\u7247":"Photos"},ae=function(e){var n=document.createElement("div");n.setAttribute("role","button"),n.setAttribute("tabindex","0"),n.setAttribute("aria-label",le(e.config.lang));var r=n.style;r.position="fixed",r.height=u+"px",r.minWidth=u+"px",r.boxSizing="border-box",r.padding="0 14px",r.borderRadius=u/2+"px",r.background="rgba(15,23,42,0.88)",r.boxShadow="0 2px 10px rgba(0,0,0,0.3)",r.zIndex=String(e.config.zIndex),r.cursor="pointer",r.display="flex",r.alignItems="center",r.justifyContent="center",r.gap="6px",r.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',r.color="#ffffff",r.whiteSpace="nowrap",n.innerHTML=''+ie(e.config.lang)+"";function p(l){if(l.type==="keydown"){var a=l.key;if(a!=="Enter"&&a!==" "&&a!=="Spacebar")return;l.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return n.addEventListener("click",p),n.addEventListener("keydown",p),n},se=function(e){return e.collapsed?u:e.height},z=function(e){var n=e.config.position==="left"?"left":"right",r=n==="left"?"right":"left",p=e.narrow?"sm":e.config.size,l=X[p]||X.md,a=t.innerWidth-e.config.offsetX-8;aa&&(l=a);var d=e.wrapper.style;if(d.width=l+"px",d.zIndex=String(e.config.zIndex),d[n]=e.config.offsetX+"px",d[r]="",e.handle){var m=e.handle.style;m.zIndex=String(e.config.zIndex),m[n]=e.config.offsetX+"px",m[r]=""}},T=function(){for(var e={left:[],right:[]},n=0;n0&&y+B>t.innerHeight&&A(I,!1),I.wrapper.style.bottom=y+"px",I.handle&&(I.handle.style.bottom=y+"px"),d=y}},v=function(){if(S!==null||typeof t.requestAnimationFrame!="function"){T();return}S=t.requestAnimationFrame(function(){S=null,T()})},A=function(e,n){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=ae(e),document.body.appendChild(e.handle),z(e)),e.handle.style.display="flex",n!==!1&&N(e.config.slot,!0))},b=function(e,n){A(e,n),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},fe=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(i+e.config.slot)}catch(r){}var n=h.indexOf(e);n>=0&&h.splice(n,1),delete M[e.config.slot],h.length===0&&t.removeEventListener("message",V),v()}},pe=function(e,n,r){e.backendVersion=n,e.config=ee(e.config,r),z(e),!e.userChoice&&!w(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,n){var r=document.createElement("div"),p=r.style;p.position="fixed",p.bottom=n.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var l=document.createElement("iframe");l.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),l.setAttribute("src",oe(e,n.slot,n)),l.setAttribute("referrerpolicy","no-referrer-when-downgrade"),l.setAttribute("title","PhotoLive photos");var a=l.style;a.width="100%",a.height="100%",a.border="0",a.display="block",r.appendChild(l),document.body.appendChild(r);var d={config:n,wrapper:r,frame:l,frameOrigin:_(l)||e,height:o,collapsed:!1,narrow:n.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return h.push(d),z(d),w(n.slot)?F(n.slot)&&b(d,!1):d.narrow&&b(d,!1),d},V=function(e){try{var n=e.data;if(!n||n.photoliveEmbed!==!0)return;var r=n.type;if(Q.indexOf(r)<0)return;for(var p=0;p 引入)。与 v1(src/loader.js)同约束:零依赖、 +// 零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。 +// +// v2 与 v1 的宿主侧差异(方案 §2/§7/§8): +// · 窗口硬性固定 195×280 CSS px(border-box,含 1px 边框);PC/手机同值; +// 仅在视口不足时按 min(195,可用宽)/min(280,可用高) 收缩,绝不 transform +// 缩放;resize 消息不改高度(iframe 内容自适应窗口,窗口不迁就内容); +// · 外壳:圆角 12、边框 1px rgba(255,255,255,0.80)(单圈)、阴影 +// 0 4px 16px rgba(15,23,42,0.16)、底色 #10161E(加载/缺口不露宿主内容); +// · 默认停靠:桌面 right 16 / 手机 right 12,bottom 24 + safe-area-inset; +// offsetX 仅在 data-* 与后台都未配置时按视口取默认; +// · 收起把手:白底胶囊 88–104×44、圆角 22、蓝图标 + 12px「照片/Photos」; +// · 多槽同侧纵排间距 12;可用高度不足时后续槽收起(空间约束≠用户选择); +// · 窄屏(<480px)默认收起沿用 v1 策略;展开后两端尺寸一致(195×280)。 +// 收起按钮本体在 iframe 内(viewer EmbedStream 渲染,§2.4),经 collapse 消息 +// 请求宿主收起——宿主与 iframe 各自清理,语义同 v1。 +import { + parseEmbedConfigV2, + buildEmbedUrlV2, + mergeBackendConfigV2, + parseConfigMessageV2, + MESSAGE_TYPES, + V2_WIDTH, + V2_HEIGHT, +} from './params.js'; + +(function () { + 'use strict'; + try { + var w = window; + // v2 独立标记:同页引入 v1+v2(迁移期)互不干扰。 + if (w.__photoliveEmbedV2) return; + w.__photoliveEmbedV2 = '1'; + + var COLLAPSE_KEY = 'pl-embed-collapsed:'; // 与 v1 共用(同槽同会话记忆一致) + var STACK_GAP = 12; // §8.1 多槽同侧纵排间距 + var HANDLE_MIN = 44; // 收起把手高/最小宽(§2.4) + // 收缩下限:低于此尺寸无法保证照片窗可用 → 改收起入口(§2.1「小到无法 + // 操作时改成收起入口,而不是缩成不可点」)。 + var MIN_OPERABLE_WIDTH = 150; + var MIN_OPERABLE_HEIGHT = 200; + + function readCollapsed(slot) { + try { + return w.sessionStorage.getItem(COLLAPSE_KEY + slot) === '1'; + } catch { + return false; + } + } + function writeCollapsed(slot, collapsed) { + try { + if (collapsed) w.sessionStorage.setItem(COLLAPSE_KEY + slot, '1'); + else w.sessionStorage.removeItem(COLLAPSE_KEY + slot); + } catch { + /* storage 异常:静默放弃记忆 */ + } + } + function hasChoice(slot) { + try { + return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null; + } catch { + return false; + } + } + + function selfScript() { + if (document.currentScript && document.currentScript.src) return document.currentScript; + var all = document.querySelectorAll('script[src*="/embed/v2/loader.js"]'); + return all.length ? all[all.length - 1] : null; + } + function originOf(el) { + try { + return new URL(el.src, w.location.href).origin; + } catch { + return null; + } + } + + var instances = []; + var seenSlots = {}; + var relayoutFrame = null; + + function isZh(lang) { + if (lang === 'zh-CN') return true; + if (lang === 'auto') { + try { + return /^zh/i.test(w.navigator.language || ''); + } catch { + return false; + } + } + return false; + } + + // 收起把手(§2.4):白底胶囊、蓝色相机图标 + 照片/Photos 文案;全部 + // inline style(零宿主 CSS 污染);有效点击区 ≥44×44。 + function createHandle(inst) { + var h = document.createElement('div'); + h.setAttribute('role', 'button'); + h.setAttribute('tabindex', '0'); + h.setAttribute('aria-label', isZh(inst.config.lang) ? '展开照片窗' : 'Show photo window'); + var s = h.style; + s.position = 'fixed'; + s.height = HANDLE_MIN + 'px'; + s.minWidth = '88px'; + s.boxSizing = 'border-box'; + s.padding = '0 16px'; + s.borderRadius = '22px'; + s.background = '#ffffff'; + s.boxShadow = '0 2px 10px rgba(15,23,42,0.18)'; + 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.whiteSpace = 'nowrap'; + h.innerHTML = + '' + + '' + (isZh(inst.config.lang) ? '照片' : 'Photos') + ''; + function activate(ev) { + if (ev.type === 'keydown') { + var k = ev.key; + if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return; + ev.preventDefault(); + } + inst.userChoice = true; + expandInstance(inst); + try { + if (inst.frame.contentWindow) { + inst.frame.contentWindow.postMessage( + { photoliveEmbed: true, type: 'expand' }, + inst.frameOrigin + ); + } + } catch { + /* 静默 */ + } + } + h.addEventListener('click', activate); + h.addEventListener('keydown', activate); + return h; + } + + /** 展开态盒高(收缩后可能 < 280);收起态计把手 44(堆叠依据)。 */ + function effectiveHeight(inst) { + return inst.collapsed ? HANDLE_MIN : inst.height; + } + + /** 默认水平偏移:未配置时桌面 16 / 窄屏 12(§2.1)。data-* 显式或后台 + * 配置到达后(offsetConfigured)按配置值。 */ + function effectiveOffsetX(inst) { + if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX; + return inst.narrow ? 12 : 16; + } + + /** wrapper 几何:W=min(195,可用宽)、H=min(280,可用高);不足操作下限时 + * 由调用方收起(空间约束)。 */ + function measureBox(inst) { + var offsetX = effectiveOffsetX(inst); + var availW = w.innerWidth - offsetX - 8; + var availH = w.innerHeight - inst.config.offsetBottom - 8; + return { + offsetX: offsetX, + width: Math.max(120, Math.min(V2_WIDTH, availW)), + height: Math.max(160, Math.min(V2_HEIGHT, availH)), + operable: availW >= MIN_OPERABLE_WIDTH && availH >= MIN_OPERABLE_HEIGHT, + }; + } + + function applyWrapperBox(inst) { + var box = measureBox(inst); + inst.height = box.height; + var side = inst.config.position === 'left' ? 'left' : 'right'; + var other = side === 'left' ? 'right' : 'left'; + var ws = inst.wrapper.style; + if (!inst.collapsed) { + ws.display = 'block'; + ws.width = box.width + 'px'; + ws.height = box.height + 'px'; + } + ws.zIndex = String(inst.config.zIndex); + ws[side] = box.offsetX + 'px'; + ws[other] = ''; + // 底部偏移 + 安全区(§2.1):不支持 env() 的旧浏览器整条声明失效回退 + // 上一行设置的裸像素值(先写裸值再写 calc,双保险)。 + ws.bottom = inst.config.offsetBottom + 'px'; + ws.bottom = + 'calc(' + inst.config.offsetBottom + 'px + env(safe-area-inset-bottom, 0px))'; + if (inst.handle) { + var hs = inst.handle.style; + hs.zIndex = String(inst.config.zIndex); + hs[side] = box.offsetX + 'px'; + hs[other] = ''; + } + return box; + } + + // 同侧多槽纵排(§8.1):登记顺序自下而上,间距 12;可用高度不足时后续 + // 槽收起(不写用户偏好)。 + function relayout() { + var sides = { left: [], right: [] }; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + sides[inst.config.position === 'left' ? 'left' : 'right'].push(inst); + } + var keys = ['left', 'right']; + for (var s = 0; s < keys.length; s++) { + var list = sides[keys[s]]; + var cursorBottom = 0; + for (var j = 0; j < list.length; j++) { + var it = list[j]; + var bottom = j === 0 ? it.config.offsetBottom : cursorBottom + effectiveHeight(list[j - 1]) + STACK_GAP; + if (j > 0 && bottom + V2_HEIGHT > w.innerHeight) setCollapsed(it, false); + it.wrapper.style.bottom = bottom + 'px'; + if (it.handle) it.handle.style.bottom = bottom + 'px'; + cursorBottom = bottom; + } + } + } + + function scheduleRelayout() { + if (relayoutFrame !== null || typeof w.requestAnimationFrame !== 'function') { + relayout(); + return; + } + relayoutFrame = w.requestAnimationFrame(function () { + relayoutFrame = null; + relayout(); + }); + } + + /** 收起核心(不触发重排调度——relayout 空间不足收起时避免再排)。 */ + function setCollapsed(inst, persist) { + if (inst.collapsed) return; + inst.collapsed = true; + inst.wrapper.style.display = 'none'; + if (!inst.handle) { + inst.handle = createHandle(inst); + document.body.appendChild(inst.handle); + } + inst.handle.style.display = 'flex'; + if (persist !== false) writeCollapsed(inst.config.slot, true); + } + + function collapseInstance(inst, persist) { + setCollapsed(inst, persist); + scheduleRelayout(); + } + + function expandInstance(inst) { + if (!inst.collapsed) return; + inst.collapsed = false; + applyWrapperBox(inst); + if (inst.handle) inst.handle.style.display = 'none'; + writeCollapsed(inst.config.slot, false); + scheduleRelayout(); + } + + /** 服务端终止展示(停用/解绑/404/410):移除照片窗与把手,不留空壳。 */ + function destroyInstance(inst) { + if (inst.destroyed) return; + inst.destroyed = true; + if (inst.handle && inst.handle.parentNode) inst.handle.parentNode.removeChild(inst.handle); + if (inst.wrapper.parentNode) inst.wrapper.parentNode.removeChild(inst.wrapper); + try { + w.sessionStorage.removeItem(COLLAPSE_KEY + inst.config.slot); + } catch { + /* 静默 */ + } + var i = instances.indexOf(inst); + if (i >= 0) instances.splice(i, 1); + delete seenSlots[inst.config.slot]; + if (instances.length === 0) w.removeEventListener('message', onMessage); + scheduleRelayout(); + } + + /** 后台设置应用(config 消息已过 parseConfigMessageV2 版本闸):非 data-* + * 显式字段立即生效;collapsedDefault 仅在无用户选择时应用。 */ + function applyConfig(inst, version, settings) { + inst.backendVersion = version; + inst.offsetConfigured = true; // 后台 settings 恒为全集 → offsetX 已「配置」 + inst.config = mergeBackendConfigV2(inst.config, settings); + applyWrapperBox(inst); + if (!inst.userChoice && !hasChoice(inst.config.slot)) { + if (settings.collapsedDefault) collapseInstance(inst, false); + else if (!spaceConstrained(inst)) expandInstance(inst); + } + scheduleRelayout(); + } + + /** 当前视口是否空间不足(不足时默认收起而非塞下不可用的窗口)。 */ + function spaceConstrained(inst) { + return !measureBox(inst).operable; + } + + function createInstance(origin, config) { + var wrapper = document.createElement('div'); + var ws = wrapper.style; + ws.position = 'fixed'; + ws.width = V2_WIDTH + 'px'; + ws.height = V2_HEIGHT + 'px'; + ws.boxSizing = 'border-box'; + ws.borderRadius = '12px'; + ws.border = '1px solid rgba(255,255,255,0.80)'; + ws.boxShadow = '0 4px 16px rgba(15,23,42,0.16)'; + ws.background = '#10161E'; + ws.overflow = 'hidden'; + + var frame = document.createElement('iframe'); + // sandbox 同 v1(含 allow-same-origin:iframe 保有项目域源,白名单按 + // 源+source 双重严格校验;跨域隔离与链接新页安全不变)。 + frame.setAttribute( + 'sandbox', + 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin' + ); + frame.setAttribute('src', buildEmbedUrlV2(origin, config.slot, config)); + frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); + frame.setAttribute('title', 'PhotoLive photos'); + var fs = frame.style; + fs.width = '100%'; + fs.height = '100%'; + fs.border = '0'; + fs.display = 'block'; + wrapper.appendChild(frame); + document.body.appendChild(wrapper); + + var inst = { + config: config, + wrapper: wrapper, + frame: frame, + frameOrigin: originOf(frame) || origin, + height: V2_HEIGHT, + collapsed: false, + narrow: config.narrow, + handle: null, + userChoice: false, + backendVersion: 0, + offsetConfigured: config.explicit.offsetX, + destroyed: false, + }; + instances.push(inst); + var box = applyWrapperBox(inst); + // 收起记忆 / 默认(窄屏或空间不足默认收起;用户选择优先,不反复弹出)。 + if (hasChoice(config.slot)) { + if (readCollapsed(config.slot)) collapseInstance(inst, false); + } else if (inst.narrow || !box.operable) { + collapseInstance(inst, false); + } + return inst; + } + + // postMessage 白名单:同 v1 双重严格校验(event.source 命中已登记 iframe + // 且 event.origin 严格等于该 iframe 源)。v2 忽略 resize(窗口固定)。 + function onMessage(event) { + try { + var data = event.data; + if (!data || data.photoliveEmbed !== true) return; + var type = data.type; + if (MESSAGE_TYPES.indexOf(type) < 0) return; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue; + if (event.origin !== inst.frameOrigin) return; + if (type === 'collapse') { + inst.userChoice = true; + collapseInstance(inst, true); + } else if (type === 'expand') { + inst.userChoice = true; + expandInstance(inst); + } else if (type === 'config') { + var parsed = parseConfigMessageV2(data, inst.backendVersion); + if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings); + } else if (type === 'destroy') { + destroyInstance(inst); + } + // 'resize':v2 窗口固定,显式忽略(§7.3)。 + return; + } + } catch { + /* 静默 */ + } + } + + function isNarrow() { + try { + return !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches; + } catch { + return false; + } + } + + function onViewportChange() { + var narrow = isNarrow(); + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + if (inst.narrow !== narrow) { + inst.narrow = narrow; + if (narrow && !inst.userChoice && !hasChoice(inst.config.slot)) { + collapseInstance(inst, false); + } + } + applyWrapperBox(inst); + } + scheduleRelayout(); + } + + function init() { + var self = selfScript(); + if (!self) return; + var embedOrigin = originOf(self); + if (!embedOrigin) return; + var narrow = isNarrow(); + var tags = document.querySelectorAll('script[src*="/embed/v2/loader.js"]'); + for (var i = 0; i < tags.length; i++) { + var config = parseEmbedConfigV2(tags[i].dataset, { narrow: narrow }); + if (!config.slot) continue; + if (seenSlots[config.slot]) continue; + seenSlots[config.slot] = true; + createInstance(embedOrigin, config); + } + w.addEventListener('message', onMessage); + w.addEventListener('resize', onViewportChange); + w.addEventListener('orientationchange', onViewportChange); + } + + if (document.body) { + init(); + } else { + document.addEventListener('DOMContentLoaded', init); + } + } catch { + /* 静默:任何异常不得破坏宿主页 */ + } +})(); diff --git a/packages/embed-loader/src/params.js b/packages/embed-loader/src/params.js index 7fcec3b..ded2d31 100644 --- a/packages/embed-loader/src/params.js +++ b/packages/embed-loader/src/params.js @@ -44,6 +44,20 @@ export const Z_INDEX_MAX = 2147483000; /** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */ export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy']; +// ── UI-04 v2(vertical-stream 照片流窗)固定几何与参数域 ── + +/** v2 固定窗尺寸(CSS px,方案 §2.1 硬性要求:PC/手机同值,含边框)。 */ +export const V2_WIDTH = 195; +export const V2_HEIGHT = 280; + +/** v2 自动上滚速度域(CSS px/秒)。 */ +export const SPEED_MIN = 8; +export const SPEED_MAX = 32; +export const SPEED_DEFAULT = 16; + +/** v2 展示模式取值(settings.displayMode;loader 侧仅接受白名单值)。 */ +export const DISPLAY_MODES = ['carousel', 'vertical-stream']; + function oneOf(allowed, value, fallback) { return allowed.indexOf(value) >= 0 ? value : fallback; } @@ -195,3 +209,122 @@ export function clampHeight(value) { if (isNaN(n) || !isFinite(n)) return null; return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n))); } + +// ── UI-04 v2 参数解析(独立函数:v1 parseEmbedConfig 行为零改动)── +// v2 与 v1 共用 data-* 显式 > 后台设置 > 默认的优先级模型;差异: +// · 尺寸硬性固定 195×280(size/imageFit/intervalMs 不再影响布局,仅仍透传 +// 给 iframe 侧的 v1 兼容字段——实际上 v2 iframe 忽略它们); +// · 新参数 data-speed(8..32)与 data-reactions(on|off); +// · 窄屏(<480px)仅默认收起,不降尺寸(§2.1:展开后两端同值)。 + +function clampSpeed(value) { + const n = Number(value); + if (isNaN(n) || !isFinite(n)) return SPEED_DEFAULT; + return Math.min(SPEED_MAX, Math.max(SPEED_MIN, Math.round(n))); +} + +/** + * v2 宿主 script data-* 配置解析(dataset 形如 {slot, lang, position, speed, + * reactions, offsetX, offsetBottom, zIndex, poll})。explicit 表语义同 v1: + * 只记 data-* 真正提供的字段,缺省字段允许后台设置生效。 + */ +export function parseEmbedConfigV2(dataset, opts) { + const ds = dataset || {}; + const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : ''; + const narrow = !!(opts && opts.narrow); + return { + version: 2, + slot: slot || null, + lang: provided(ds, 'lang') ? oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang) : DEFAULTS.lang, + position: provided(ds, 'position') + ? oneOf(POSITION_VALUES, ds.position, DEFAULTS.position) + : DEFAULTS.position, + speed: provided(ds, 'speed') ? clampSpeed(ds.speed) : SPEED_DEFAULT, + reactions: provided(ds, 'reactions') + ? oneOf(['on', 'off'], ds.reactions, 'on') === 'on' + : true, + offsetX: provided(ds, 'offsetX') ? clampInt(ds.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX) : DEFAULTS.offsetX, + offsetBottom: provided(ds, 'offsetBottom') + ? clampInt(ds.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom) + : DEFAULTS.offsetBottom, + zIndex: provided(ds, 'zIndex') ? clampInt(ds.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex) : DEFAULTS.zIndex, + pollMs: provided(ds, 'poll') ? clampInt(ds.poll, 250, 60000, null) : null, + narrow: narrow, + explicit: { + lang: provided(ds, 'lang'), + position: provided(ds, 'position'), + speed: provided(ds, 'speed'), + reactions: provided(ds, 'reactions'), + offsetX: provided(ds, 'offsetX'), + offsetBottom: provided(ds, 'offsetBottom'), + zIndex: provided(ds, 'zIndex'), + }, + }; +} + +/** + * v2 后台设置归一(config 消息 / 公开端点 settings):在 v1 全集基础上裁决 + * v2 字段(displayMode/scrollSpeed/reactionsEnabled);displayMode 仅接受 + * vertical-stream 才对 v2 loader 有意义(carousel 值表示后台切回旧模式—— + * v2 loader 不自毁,但忽略该字段;管理端切回旧模式应同时把站点 script 换回 + * v1 入口,见 README-embed)。整体非对象 → null。 + */ +export function normalizeBackendSettingsV2(value) { + if (!value || typeof value !== 'object') return null; + const base = normalizeBackendSettings(value); + if (base === null) return null; + return { + ...base, + displayMode: oneOf(DISPLAY_MODES, value.displayMode, 'carousel'), + scrollSpeed: clampSpeed(value.scrollSpeed), + reactionsEnabled: typeof value.reactionsEnabled === 'boolean' ? value.reactionsEnabled : true, + }; +} + +/** v2 后台设置合并(同 mergeBackendConfig 语义,作用于 v2 字段面)。 */ +export function mergeBackendConfigV2(config, settings) { + const s = normalizeBackendSettingsV2(settings); + if (s === null) return config; + const e = config.explicit; + return { + ...config, + lang: e.lang ? config.lang : s.lang, + position: e.position ? config.position : s.position, + speed: e.speed ? config.speed : s.scrollSpeed, + reactions: e.reactions ? config.reactions : s.reactionsEnabled, + offsetX: e.offsetX ? config.offsetX : s.offsetX, + offsetBottom: e.offsetBottom ? config.offsetBottom : s.offsetBottom, + zIndex: e.zIndex ? config.zIndex : s.zIndex, + }; +} + +/** v2 iframe config 消息裁决(同 parseConfigMessage,v2 settings 归一)。 */ +export function parseConfigMessageV2(data, currentVersion) { + if (!data || typeof data !== 'object') return null; + const version = Number(data.configVersion); + if (isNaN(version) || !isFinite(version)) return null; + const local = currentVersion === null || currentVersion === undefined ? 0 : Number(currentVersion); + if (version <= local) return null; + const settings = normalizeBackendSettingsV2(data.settings); + if (settings === null) return null; + return { configVersion: Math.round(version), settings }; +} + +/** + * v2 iframe src:{origin}/embed/{slot}?v=2&lang=&pos=(槽 token 编码防注入)。 + * speed/reactions 仅显式提供时拼接(缺省不覆盖后台设置);poll 同 v1。 + */ +export function buildEmbedUrlV2(origin, slot, config) { + let url = + origin + + '/embed/' + + encodeURIComponent(slot) + + '?v=2&lang=' + + encodeURIComponent(config.lang) + + '&pos=' + + encodeURIComponent(config.position); + if (config.explicit.speed) url += '&speed=' + String(config.speed); + if (config.explicit.reactions && !config.reactions) url += '&reactions=off'; + if (config.pollMs !== null && config.pollMs !== undefined) url += '&poll=' + String(config.pollMs); + return url; +} diff --git a/packages/embed-loader/src/params.test.js b/packages/embed-loader/src/params.test.js index c66c620..7b0ed79 100644 --- a/packages/embed-loader/src/params.test.js +++ b/packages/embed-loader/src/params.test.js @@ -6,12 +6,17 @@ import { describe, expect, it } from 'vitest'; import { MESSAGE_TYPES, buildEmbedUrl, + buildEmbedUrlV2, clampHeight, mergeBackendConfig, + mergeBackendConfigV2, normalizeBackendSettings, + normalizeBackendSettingsV2, normalizeInterval, parseConfigMessage, + parseConfigMessageV2, parseEmbedConfig, + parseEmbedConfigV2, SIZE_WIDTH, } from './params.js'; @@ -308,3 +313,111 @@ describe('clampHeight', () => { expect(clampHeight(Infinity)).toBeNull(); }); }); + +// ── UI-04 v2(vertical-stream)参数面 ── + +describe('parseEmbedConfigV2', () => { + it('空 dataset 全缺省:v2 字段默认 + explicit 全 false', () => { + const c = parseEmbedConfigV2({ slot: 'tok' }, {}); + expect(c.version).toBe(2); + expect(c.slot).toBe('tok'); + expect(c.lang).toBe('auto'); + expect(c.position).toBe('right'); + expect(c.speed).toBe(16); + expect(c.reactions).toBe(true); + expect(c.offsetX).toBe(16); + expect(c.offsetBottom).toBe(24); + expect(c.zIndex).toBe(1000); + expect(c.pollMs).toBeNull(); + expect(c.explicit).toEqual({ + lang: false, + position: false, + speed: false, + reactions: false, + offsetX: false, + offsetBottom: false, + zIndex: false, + }); + }); + + it('data-speed 夹取 8..32;非法回退 16', () => { + expect(parseEmbedConfigV2({ slot: 'a', speed: '1' }, {}).speed).toBe(8); + expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).speed).toBe(24); + expect(parseEmbedConfigV2({ slot: 'a', speed: '99' }, {}).speed).toBe(32); + expect(parseEmbedConfigV2({ slot: 'a', speed: 'abc' }, {}).speed).toBe(16); + expect(parseEmbedConfigV2({ slot: 'a' }, {}).explicit.speed).toBe(false); + expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).explicit.speed).toBe(true); + }); + + it('data-reactions 仅 on/off;其他回退 true', () => { + expect(parseEmbedConfigV2({ slot: 'a', reactions: 'off' }, {}).reactions).toBe(false); + expect(parseEmbedConfigV2({ slot: 'a', reactions: 'on' }, {}).reactions).toBe(true); + expect(parseEmbedConfigV2({ slot: 'a', reactions: 'yes' }, {}).reactions).toBe(true); + }); + + it('v1 布局字段(size/interval/imageFit)不在 v2 配置面(195×280 硬性)', () => { + const c = parseEmbedConfigV2({ slot: 'a', size: 'md', interval: '8000', imageFit: 'cover' }, {}); + expect(c.size).toBeUndefined(); + expect(c.intervalMs).toBeUndefined(); + expect(c.imageFit).toBeUndefined(); + }); +}); + +describe('normalizeBackendSettingsV2 / mergeBackendConfigV2', () => { + it('v2 字段归一:displayMode 白名单 / speed 夹取 / reactions 布尔回退', () => { + const s = normalizeBackendSettingsV2({ + position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false, + imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000, + displayMode: 'vertical-stream', scrollSpeed: 20, reactionsEnabled: false, + }); + expect(s.displayMode).toBe('vertical-stream'); + expect(s.scrollSpeed).toBe(20); + expect(s.reactionsEnabled).toBe(false); + expect(normalizeBackendSettingsV2({ displayMode: 'weird', scrollSpeed: 999, reactionsEnabled: 'x' }).displayMode).toBe('carousel'); + expect(normalizeBackendSettingsV2({ scrollSpeed: 999 }).scrollSpeed).toBe(32); + expect(normalizeBackendSettingsV2({ reactionsEnabled: 'x' }).reactionsEnabled).toBe(true); + expect(normalizeBackendSettingsV2(null)).toBeNull(); + }); + + it('合并优先级:data-* 显式保持,缺省字段取后台值', () => { + const config = parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}); + const merged = mergeBackendConfigV2(config, { + position: 'left', size: 'md', intervalMs: 5000, lang: 'en', collapsedDefault: true, + imageFit: 'contain', offsetX: 40, offsetBottom: 80, zIndex: 2000, + displayMode: 'vertical-stream', scrollSpeed: 12, reactionsEnabled: false, + }); + expect(merged.speed).toBe(24); // 显式 data-speed 胜出 + expect(merged.reactions).toBe(false); // 后台(未显式) + expect(merged.position).toBe('left'); + expect(merged.lang).toBe('en'); + expect(merged.offsetX).toBe(40); + expect(merged.offsetBottom).toBe(80); + expect(merged.zIndex).toBe(2000); + }); +}); + +describe('parseConfigMessageV2 / buildEmbedUrlV2', () => { + it('config 消息:版本闸(低于等于忽略)+ 非法忽略', () => { + const settings = { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false }; + expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings }, 1)).not.toBeNull(); + expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 1, settings }, 1)).toBeNull(); + expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 'x', settings }, 0)).toBeNull(); + expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings: null }, 0)).toBeNull(); + }); + + it('URL:v=2 恒在;speed/reactions 仅显式拼接;poll 透传', () => { + const base = parseEmbedConfigV2({ slot: 'tok' }, {}); + expect(buildEmbedUrlV2('https://e.test', 'tok', base)).toBe( + 'https://e.test/embed/tok?v=2&lang=auto&pos=right', + ); + const explicit = parseEmbedConfigV2({ slot: 'tok', speed: '20', reactions: 'off', poll: '500' }, {}); + expect(buildEmbedUrlV2('https://e.test', 'tok', explicit)).toBe( + 'https://e.test/embed/tok?v=2&lang=auto&pos=right&speed=20&reactions=off&poll=500', + ); + }); + + it('槽 token 编码防注入(路径段转义)', () => { + const c = parseEmbedConfigV2({ slot: 'a/b?c' }, {}); + expect(buildEmbedUrlV2('https://e.test', 'a/b?c', c)).toContain('/embed/a%2Fb%3Fc?'); + }); +}); diff --git a/packages/embed-loader/sync-to-viewer.js b/packages/embed-loader/sync-to-viewer.js index e279787..bb38aa9 100644 --- a/packages/embed-loader/sync-to-viewer.js +++ b/packages/embed-loader/sync-to-viewer.js @@ -1,31 +1,36 @@ // packages/embed-loader/sync-to-viewer.js // 复制构建产物到 viewer 静态目录:vite publicDir(apps/viewer/public/)在 dev -// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建脚本 -// 级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader sync。 +// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建 +// 脚本级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader +// sync(先 build)。UI-04 起 v1/v2 双产物。 import { createHash } from 'node:crypto'; import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); -const src = resolve(here, 'dist/v1/loader.js'); -const dest = resolve(here, '../../apps/viewer/public/embed/v1/loader.js'); - -if (!existsSync(src)) { - console.error('[sync] missing dist/v1/loader.js — run `pnpm build` first'); - process.exit(1); -} - -mkdirSync(dirname(dest), { recursive: true }); -cpSync(src, dest); +const targets = [ + { src: 'dist/v1/loader.js', dest: '../../apps/viewer/public/embed/v1/loader.js' }, + { src: 'dist/v2/loader.js', dest: '../../apps/viewer/public/embed/v2/loader.js' }, +]; function sha256(p) { return createHash('sha256').update(readFileSync(p)).digest('hex'); } -const srcHash = sha256(src); -if (srcHash !== sha256(dest)) { - console.error('[sync] hash mismatch after copy — viewer copy diverged'); - process.exit(1); +for (const { src: srcRel, dest: destRel } of targets) { + const src = resolve(here, srcRel); + const dest = resolve(here, destRel); + if (!existsSync(src)) { + console.error(`[sync] missing ${srcRel} — run \`pnpm build\` first`); + process.exit(1); + } + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest); + const srcHash = sha256(src); + if (srcHash !== sha256(dest)) { + console.error(`[sync] hash mismatch after copy — ${destRel} diverged`); + process.exit(1); + } + console.log(`[sync] ${srcRel} -> ${destRel} (sha256 ${srcHash.slice(0, 12)}…)`); } -console.log(`[sync] dist/v1/loader.js -> apps/viewer/public/embed/v1/loader.js (sha256 ${srcHash.slice(0, 12)}…)`); diff --git a/services/api/src/embed/dto.ts b/services/api/src/embed/dto.ts index 97abcdf..6a4b05c 100644 --- a/services/api/src/embed/dto.ts +++ b/services/api/src/embed/dto.ts @@ -46,6 +46,11 @@ export const EMBED_OFFSET_BOTTOM_MAX = 240; export const EMBED_Z_INDEX_MIN = 1; export const EMBED_Z_INDEX_MAX = 2147483000; +/** UI-04 新增字段枚举与边界(vertical-stream 展示模式,全部可选)。 */ +const EMBED_SLOT_DISPLAY_MODES = ['carousel', 'vertical-stream'] as const; +export const EMBED_SCROLL_SPEED_MIN = 8; +export const EMBED_SCROLL_SPEED_MAX = 32; + export class EmbedSlotSettingsDto { @IsIn(EMBED_SLOT_POSITIONS) position!: (typeof EMBED_SLOT_POSITIONS)[number]; @@ -91,6 +96,25 @@ export class EmbedSlotSettingsDto { @Min(EMBED_Z_INDEX_MIN) @Max(EMBED_Z_INDEX_MAX) zIndex?: number; + + // —— UI-04 可选扩展(vertical-stream 展示模式;旧 JSON 兼容同 UI-01 模式)—— + + /** 展示模式:carousel=旧轮播(缺省);vertical-stream=v2 垂直照片流。 */ + @IsOptional() + @IsIn(EMBED_SLOT_DISPLAY_MODES) + displayMode?: (typeof EMBED_SLOT_DISPLAY_MODES)[number]; + + /** v2 自动上滚速度(CSS px/秒,8..32);缺省 16。 */ + @IsOptional() + @IsInt() + @Min(EMBED_SCROLL_SPEED_MIN) + @Max(EMBED_SCROLL_SPEED_MAX) + scrollSpeed?: number; + + /** v2 点赞心形气泡开关;缺省 true(false 仅隐藏气泡)。 */ + @IsOptional() + @IsBoolean() + reactionsEnabled?: boolean; } export class EmbedSlotCreateRequestDto { diff --git a/services/api/src/embed/embed.service.ts b/services/api/src/embed/embed.service.ts index 16fdd23..c44de82 100644 --- a/services/api/src/embed/embed.service.ts +++ b/services/api/src/embed/embed.service.ts @@ -52,7 +52,7 @@ export type EmbedSlotSettingsResponse = const PUBLIC_TOKEN_LENGTH = 24; const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; -/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01 新字段同列)。 */ +/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01/UI-04 新字段同列)。 */ export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = { position: 'right', size: 'md', @@ -63,6 +63,12 @@ export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = { offsetX: 16, offsetBottom: 24, zIndex: 1000, + // UI-04:displayMode 缺省 carousel——UI-04 前旧记录读取归一为旧模式; + // 新建走 v2 由管理端表单显式提交 vertical-stream(契约默认与服务端缺省 + // 一致保持保守,避免存量展示位一夜切换新布局)。 + displayMode: 'carousel', + scrollSpeed: 16, + reactionsEnabled: true, }; /** @@ -109,6 +115,10 @@ function normalizeSettings( offsetBottom: settings.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom, zIndex: settings.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex, + displayMode: settings.displayMode ?? DEFAULT_EMBED_SLOT_SETTINGS.displayMode, + scrollSpeed: settings.scrollSpeed ?? DEFAULT_EMBED_SLOT_SETTINGS.scrollSpeed, + reactionsEnabled: + settings.reactionsEnabled ?? DEFAULT_EMBED_SLOT_SETTINGS.reactionsEnabled, }; } @@ -132,6 +142,10 @@ export function normalizeStoredSettings( offsetX: row.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX, offsetBottom: row.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom, zIndex: row.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex, + displayMode: row.displayMode ?? DEFAULT_EMBED_SLOT_SETTINGS.displayMode, + scrollSpeed: row.scrollSpeed ?? DEFAULT_EMBED_SLOT_SETTINGS.scrollSpeed, + reactionsEnabled: + row.reactionsEnabled ?? DEFAULT_EMBED_SLOT_SETTINGS.reactionsEnabled, }; }