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 @@
-
+
+
+
+
+
{{ t('embed.widgetTitle') }}