diff --git a/.gitignore b/.gitignore
index d2aeedf..4e7e42c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,11 @@
node_modules/
dist/
+# embed-loader 发布物即 dist 文件本身(viewer 同步副本也提交):白名单放行,
+# 其余 dist/ 仍忽略(git 不允许重包含被排除目录的子路径,故逐层放行到 v1/)。
+!packages/embed-loader/dist/
+packages/embed-loader/dist/*
+!packages/embed-loader/dist/v1/
+!packages/embed-loader/dist/v1/loader.js
.turbo/
# @photo/db-ov Prisma client 工件(自定义 generator output;db-cn 走 node_modules 默认位)
packages/db-ov/generated/
diff --git a/apps/viewer/public/embed/v1/loader.js b/apps/viewer/public/embed/v1/loader.js
new file mode 100644
index 0000000..c2a04fb
--- /dev/null
+++ b/apps/viewer/public/embed/v1/loader.js
@@ -0,0 +1 @@
+(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=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},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=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"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.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",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r
+```
+
+- `data-slot`(必填):展示位 publicToken;缺失时加载器静默不渲染任何内容。
+- `data-lang`(可选):`auto`(缺省,按相册 defaultLocale)| `zh-CN` | `en`,非法值回退 `auto`。
+- `data-position`(可选):`right`(缺省)| `left`,悬浮侧。
+- `data-interval`(可选):轮播间隔毫秒,夹取到 3000–30000,缺省 5000。
+
+同一页面引入多个不同 `data-slot` 的标签即嵌入多个展示位;同一 `data-slot` 重复出现时
+后者静默忽略。位置/尺寸/语言等展示位级设置以后台配置为准,data-* 为宿主侧覆盖。
+
+## CSP 要求(宿主页)
+
+| 指令 | 值 | 说明 |
+| --- | --- | --- |
+| `script-src` | `{EMBED_ORIGIN}` | 加载 loader.js |
+| `frame-src` | `{EMBED_ORIGIN}` | 悬浮轮播 iframe(`{EMBED_ORIGIN}/embed/{slot}`) |
+
+组件自身不内联脚本、不加载其他域资源。`frame-ancestors` 由**我们**的 iframe 页在 P5
+配置(限制可嵌入站点),不是宿主的关注点。
+
+## 窄屏行为
+
+viewport 宽度 <480px 时:组件强制降为小尺寸(280px 宽)并默认收起为侧边圆形把手,
+点击把手展开。会话内收起状态记忆在 `sessionStorage`(键 `pl-embed-collapsed:{slot}`),
+刷新后保持收起,不反复弹出。
+
+## 安全模型
+
+- **iframe sandbox**:`allow-scripts allow-popups allow-popups-to-escape-sandbox
+ allow-same-origin`。必须含 `allow-same-origin`:否则 iframe 源为 opaque `null`,
+ 下方 origin 白名单会拒掉自己 iframe 的合法消息,且 iframe 内调公开 API 需要全站
+ ACAO `*`。iframe 对宿主天然跨源(宿主域 ≠ 项目域),`allow-same-origin` 仅使
+ iframe 保有**项目域自己的**源,不会获得宿主页源。
+- **postMessage 双向白名单**:loader 仅接受「`event.source` 等于已登记 iframe 的
+ `contentWindow` 且 `event.origin` 严格等于该 iframe src 源」的消息,且载荷须为
+ `{photoliveEmbed: true, type: resize|collapse|expand, ...}`;resize 高度夹取
+ 60–640px。向 iframe 发消息(把手展开)同样以 iframe 源为目标,绝不使用 `*`。
+- **零信任宿主数据**:slot token 经 encodeURIComponent 进 URL;lang/position/interval
+ 白名单校验后拼入查询串。
+- **存储与 Cookie**:loader 不读写 Cookie、不写宿主 localStorage;仅上述
+ sessionStorage 收起标记。无埋点、无对外数据上报。
+- **DOM 足迹**:仅向 body 追加 wrapper 与收起把手两个 div(全部 inline style,
+ 不注入样式表/类名,不影响宿主 CSS)。
+
+## 产物与发布
+
+- 发布物即文件本身(不走 npm):`packages/embed-loader/dist/v1/loader.js`
+ (esbuild bundle + minify,IIFE,target es2018,≤5KB 目标)。
+- **viewer 构建携带**:`pnpm --filter @photo/embed-loader run sync` 将产物拷贝至
+ `apps/viewer/public/embed/v1/loader.js`(sha256 校验)。viewer 的 vite publicDir
+ 使 dev server 直接可访问、`vite build` 自动将其带入 dist/。两份产物均提交入库,
+ 修改 loader 后需重新 build + sync。
+- 路径 `/embed/v1/` 长期稳定;破坏性变更才会出现 `/embed/v2/`。
+
+## 开发
+
+```bash
+pnpm --filter @photo/embed-loader run build # 打包 dist/v1/loader.js
+pnpm --filter @photo/embed-loader run test # params 纯函数单测(vitest)
+pnpm --filter @photo/embed-loader run sync # 拷贝至 viewer public/(含 hash 校验)
+```
+
+宿主页交互行为(iframe 出现/收起/记忆/窄屏)的端到端覆盖见 viewer Playwright
+`e2e/embed.spec.ts`(P4b Task 4,宿主 fixture 页)。
diff --git a/packages/embed-loader/dist/v1/loader.js b/packages/embed-loader/dist/v1/loader.js
new file mode 100644
index 0000000..c2a04fb
--- /dev/null
+++ b/packages/embed-loader/dist/v1/loader.js
@@ -0,0 +1 @@
+(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=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},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=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"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.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",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r 引入)。
+// 约束:零依赖、零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。
+// 安全模型(postMessage 双向白名单 / sandbox 取舍)见同包 README-embed.md。
+import {
+ parseEmbedConfig,
+ buildEmbedUrl,
+ clampHeight,
+ SIZE_WIDTH,
+} from './params.js';
+
+(function () {
+ 'use strict';
+ try {
+ var w = window;
+ // 已初始化去重:同一页面重复引入 loader 只跑一次(不同 slot 的多个标签在首次运行时全部扫描)。
+ if (w.__photoliveEmbedV1) return;
+ w.__photoliveEmbedV1 = '1';
+
+ var COLLAPSE_KEY = 'pl-embed-collapsed:';
+ var Z_INDEX = '2147483000';
+ var DEFAULT_HEIGHT = 96; // 首条 resize 消息前的占位高度
+
+ 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 {
+ /* Safari 隐私模式等 storage 异常:静默放弃记忆 */
+ }
+ }
+
+ // 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。
+ function selfScript() {
+ if (document.currentScript && document.currentScript.src) return document.currentScript;
+ var all = document.querySelectorAll('script[src*="/embed/v1/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 = {};
+
+ function handleLabel(lang) {
+ if (lang === 'zh-CN') return '展开照片轮播';
+ if (lang === 'en') return 'Show photo carousel';
+ return 'Show photo carousel';
+ }
+
+ function createHandle(inst) {
+ var h = document.createElement('div');
+ var side = inst.config.position === 'left' ? 'left' : 'right';
+ h.setAttribute('role', 'button');
+ h.setAttribute('tabindex', '0');
+ h.setAttribute('aria-label', handleLabel(inst.config.lang));
+ var s = h.style;
+ s.position = 'fixed';
+ s.bottom = '24px';
+ s[side] = '16px';
+ s.width = '44px';
+ s.height = '44px';
+ s.borderRadius = '50%';
+ s.background = 'rgba(15,23,42,0.88)';
+ s.boxShadow = '0 2px 10px rgba(0,0,0,0.3)';
+ s.zIndex = Z_INDEX;
+ s.cursor = 'pointer';
+ s.display = 'flex';
+ s.alignItems = 'center';
+ s.justifyContent = 'center';
+ h.innerHTML =
+ '';
+ function activate(ev) {
+ if (ev.type === 'keydown') {
+ var k = ev.key;
+ if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return;
+ ev.preventDefault();
+ }
+ expandInstance(inst);
+ // 通知 iframe 内部恢复轮播/可见行为;目标源严格限定 iframe 源(绝不 '*')。
+ 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;
+ }
+
+ function collapseInstance(inst) {
+ if (inst.collapsed) return;
+ inst.collapsed = true;
+ inst.wrapper.style.height = '0px';
+ inst.wrapper.style.boxShadow = 'none';
+ if (!inst.handle) {
+ inst.handle = createHandle(inst);
+ document.body.appendChild(inst.handle);
+ }
+ inst.handle.style.display = 'flex';
+ writeCollapsed(inst.config.slot, true);
+ }
+
+ function expandInstance(inst) {
+ if (!inst.collapsed) return;
+ inst.collapsed = false;
+ inst.wrapper.style.height = inst.height + 'px';
+ inst.wrapper.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
+ if (inst.handle) inst.handle.style.display = 'none';
+ writeCollapsed(inst.config.slot, false);
+ }
+
+ function createInstance(origin, config) {
+ var wrapper = document.createElement('div');
+ var side = config.position === 'left' ? 'left' : 'right';
+ var ws = wrapper.style;
+ ws.position = 'fixed';
+ ws.bottom = '24px';
+ ws[side] = '16px';
+ ws.width = SIZE_WIDTH[config.size] + 'px';
+ ws.height = DEFAULT_HEIGHT + 'px';
+ ws.zIndex = Z_INDEX;
+ ws.borderRadius = '12px';
+ ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)';
+ ws.overflow = 'hidden';
+
+ var frame = document.createElement('iframe');
+ // sandbox 必须含 allow-same-origin(评审修正):否则 iframe 为 opaque 源 "null",
+ // loader 的「消息 origin === iframe 源」白名单会拒掉自己的 resize/collapse,
+ // 且 iframe 内取公开 API 需 ACAO *。宿主↔iframe 天然跨源,same-origin 仅使
+ // iframe 保有项目域源,白名单按源+source 双重严格校验兜底。
+ frame.setAttribute(
+ 'sandbox',
+ 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
+ );
+ frame.setAttribute('src', buildEmbedUrl(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,
+ // 白名单基准源:从 iframe src 推导(构建后即固定,等于 EMBED_ORIGIN)。
+ frameOrigin: originOf(frame) || origin,
+ height: DEFAULT_HEIGHT,
+ collapsed: false,
+ handle: null,
+ };
+ instances.push(inst);
+ // 收起记忆 / 窄屏默认收起:会话内不反复弹出(需求 9)。
+ if (readCollapsed(config.slot) || config.collapsedDefault) collapseInstance(inst);
+ }
+
+ // postMessage 白名单:仅 photoliveEmbed===true 且 type∈{resize,collapse,expand},
+ // 且 event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。
+ w.addEventListener('message', function (event) {
+ try {
+ var data = event.data;
+ if (!data || data.photoliveEmbed !== true) return;
+ var type = data.type;
+ if (type !== 'resize' && type !== 'collapse' && type !== 'expand') 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 === 'resize') {
+ var h = clampHeight(data.height);
+ if (h !== null) {
+ inst.height = h;
+ if (!inst.collapsed) inst.wrapper.style.height = h + 'px';
+ }
+ } else if (type === 'collapse') {
+ collapseInstance(inst);
+ } else {
+ expandInstance(inst);
+ }
+ return;
+ }
+ } catch {
+ /* 静默 */
+ }
+ });
+
+ function init() {
+ var self = selfScript();
+ if (!self) return;
+ var embedOrigin = originOf(self);
+ if (!embedOrigin) return;
+ var narrow = false;
+ try {
+ narrow = !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
+ } catch {
+ narrow = false;
+ }
+ // 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖;
+ // 同 data-slot 重复出现时后者静默忽略。
+ var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]');
+ for (var i = 0; i < tags.length; i++) {
+ var config = parseEmbedConfig(tags[i].dataset, { narrow: narrow });
+ if (!config.slot) continue;
+ if (seenSlots[config.slot]) continue;
+ seenSlots[config.slot] = true;
+ createInstance(embedOrigin, config);
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..3e673b4
--- /dev/null
+++ b/packages/embed-loader/src/params.js
@@ -0,0 +1,79 @@
+// packages/embed-loader/src/params.js
+// 纯函数参数模块:loader(esbuild 打包内联)与 vitest 单测共用的唯一参数源。
+// 取值域与缺省严格对齐契约 EmbedSlotSettings(packages/contracts/openapi.yaml),
+// 非法/缺省一律静默回退——loader 对宿主零报错(A11「不破坏宿主」)。
+
+export const POSITION_VALUES = ['left', 'right'];
+export const SIZE_VALUES = ['sm', 'md'];
+export const LANG_VALUES = ['auto', 'zh-CN', 'en'];
+
+export const DEFAULTS = {
+ position: 'right',
+ size: 'md',
+ intervalMs: 5000,
+ lang: 'auto',
+ collapsedDefault: false,
+};
+
+/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档。 */
+export const SIZE_WIDTH = { sm: 280, md: 340 };
+
+export const INTERVAL_MIN = 3000;
+export const INTERVAL_MAX = 30000;
+
+/** resize 消息高度夹取范围(px),越界/非数值返回 null 表示忽略。 */
+export const HEIGHT_MIN = 60;
+export const HEIGHT_MAX = 640;
+
+function oneOf(allowed, value, fallback) {
+ return allowed.indexOf(value) >= 0 ? value : fallback;
+}
+
+/** 轮播间隔(ms):解析失败取缺省,超界夹取到 [3000, 30000]。 */
+export function normalizeInterval(value) {
+ const n = parseInt(value, 10);
+ if (isNaN(n)) return DEFAULTS.intervalMs;
+ return Math.min(INTERVAL_MAX, Math.max(INTERVAL_MIN, n));
+}
+
+/**
+ * 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, interval})。
+ * opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(计划评审缺口:移动端可用)。
+ * data-slot 缺失/空白 → slot 为 null,调用方须静默 no-op。
+ */
+export function parseEmbedConfig(dataset, opts) {
+ const ds = dataset || {};
+ const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : '';
+ const narrow = !!(opts && opts.narrow);
+ return {
+ slot: slot || null,
+ lang: oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang),
+ position: oneOf(POSITION_VALUES, ds.position, DEFAULTS.position),
+ intervalMs: normalizeInterval(ds.interval),
+ size: narrow ? 'sm' : oneOf(SIZE_VALUES, ds.size, DEFAULTS.size),
+ collapsedDefault: narrow ? true : DEFAULTS.collapsedDefault,
+ };
+}
+
+/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 */
+export function buildEmbedUrl(origin, slot, config) {
+ return (
+ origin +
+ '/embed/' +
+ encodeURIComponent(slot) +
+ '?lang=' +
+ encodeURIComponent(config.lang) +
+ '&pos=' +
+ encodeURIComponent(config.position) +
+ '&interval=' +
+ String(config.intervalMs)
+ );
+}
+
+/** resize 高度夹取:非有限数值/缺失 → null(忽略该消息)。 */
+export function clampHeight(value) {
+ if (value === null || value === undefined) return null;
+ const n = Number(value);
+ if (isNaN(n) || !isFinite(n)) return null;
+ return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n)));
+}
diff --git a/packages/embed-loader/src/params.test.js b/packages/embed-loader/src/params.test.js
new file mode 100644
index 0000000..8195718
--- /dev/null
+++ b/packages/embed-loader/src/params.test.js
@@ -0,0 +1,98 @@
+// packages/embed-loader/src/params.test.js
+// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。
+// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。
+import { describe, expect, it } from 'vitest';
+import {
+ buildEmbedUrl,
+ clampHeight,
+ normalizeInterval,
+ parseEmbedConfig,
+} from './params.js';
+
+describe('parseEmbedConfig', () => {
+ it('空 dataset 全缺省(slot null → 调用方 no-op)', () => {
+ const c = parseEmbedConfig({});
+ expect(c).toEqual({
+ slot: null,
+ lang: 'auto',
+ position: 'right',
+ intervalMs: 5000,
+ size: 'md',
+ collapsedDefault: false,
+ });
+ });
+
+ it('data-slot 保留并去首尾空白;非字符串(缺失)为 null', () => {
+ expect(parseEmbedConfig({ slot: ' tok ' }).slot).toBe('tok');
+ expect(parseEmbedConfig({}).slot).toBeNull();
+ expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull();
+ });
+
+ it('lang/position 白名单外静默回退缺省', () => {
+ expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN');
+ expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en');
+ expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto');
+ expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left');
+ expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right');
+ });
+
+ it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => {
+ expect(normalizeInterval('8000')).toBe(8000);
+ expect(normalizeInterval('100')).toBe(3000);
+ expect(normalizeInterval('999999')).toBe(30000);
+ expect(normalizeInterval('abc')).toBe(5000);
+ expect(normalizeInterval(undefined)).toBe(5000);
+ expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000);
+ });
+
+ it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => {
+ const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true });
+ expect(c.size).toBe('sm');
+ expect(c.collapsedDefault).toBe(true);
+ });
+
+ it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => {
+ expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false);
+ });
+});
+
+describe('buildEmbedUrl', () => {
+ it('拼接 lang/pos/interval 查询参数', () => {
+ const url = buildEmbedUrl('https://ov.example.com', 'tok', {
+ lang: 'zh-CN',
+ position: 'left',
+ intervalMs: 7000,
+ });
+ expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000');
+ });
+
+ it('slot token 编码(防路径注入)', () => {
+ const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', {
+ lang: 'auto',
+ position: 'right',
+ intervalMs: 5000,
+ });
+ expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true);
+ expect(url).toContain('lang=auto&pos=right&interval=5000');
+ });
+});
+
+describe('clampHeight', () => {
+ it('正常值原样(取整)返回', () => {
+ expect(clampHeight(200)).toBe(200);
+ expect(clampHeight(200.6)).toBe(201);
+ });
+
+ it('夹取到 [60, 640]', () => {
+ expect(clampHeight(10)).toBe(60);
+ expect(clampHeight(9999)).toBe(640);
+ });
+
+ it('缺失/非数值返回 null(忽略该消息)', () => {
+ expect(clampHeight(null)).toBeNull();
+ expect(clampHeight(undefined)).toBeNull();
+ expect(clampHeight('abc')).toBeNull();
+ expect(clampHeight(NaN)).toBeNull();
+ expect(clampHeight(Infinity)).toBeNull();
+ });
+});
diff --git a/packages/embed-loader/sync-to-viewer.js b/packages/embed-loader/sync-to-viewer.js
new file mode 100644
index 0000000..e279787
--- /dev/null
+++ b/packages/embed-loader/sync-to-viewer.js
@@ -0,0 +1,31 @@
+// packages/embed-loader/sync-to-viewer.js
+// 复制构建产物到 viewer 静态目录:vite publicDir(apps/viewer/public/)在 dev
+// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建脚本
+// 级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader sync。
+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);
+
+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);
+}
+console.log(`[sync] dist/v1/loader.js -> apps/viewer/public/embed/v1/loader.js (sha256 ${srcHash.slice(0, 12)}…)`);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 231429e..1666984 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -280,6 +280,15 @@ importers:
specifier: ^5.6.0
version: 5.6.3
+ packages/embed-loader:
+ devDependencies:
+ esbuild:
+ specifier: ^0.21.5
+ version: 0.21.5
+ vitest:
+ specifier: ^2.1.0
+ version: 2.1.9(@types/node@24.13.5)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+
packages/storage:
dependencies:
'@aws-sdk/client-s3':
@@ -8058,6 +8067,14 @@ snapshots:
optionalDependencies:
vite: 5.4.21(@types/node@22.20.3)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+ '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2))':
+ dependencies:
+ '@vitest/spy': 2.1.9
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+
'@vitest/mocker@4.1.11(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2))':
dependencies:
'@vitest/spy': 4.1.11
@@ -11734,6 +11751,24 @@ snapshots:
- supports-color
- terser
+ vite-node@2.1.9(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3(supports-color@10.2.2)
+ es-module-lexer: 1.7.0
+ pathe: 1.1.2
+ vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite-plugin-compression@0.5.1(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)):
dependencies:
chalk: 4.1.2
@@ -11802,6 +11837,18 @@ snapshots:
sass: 1.104.1
terser: 5.51.2
+ vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.28
+ rollup: 4.63.3
+ optionalDependencies:
+ '@types/node': 24.13.5
+ fsevents: 2.3.3
+ lightningcss: 1.32.0
+ sass: 1.104.1
+ terser: 5.51.2
+
vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2):
dependencies:
esbuild: 0.28.2
@@ -11854,6 +11901,42 @@ snapshots:
- supports-color
- terser
+ vitest@2.1.9(@types/node@24.13.5)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2):
+ dependencies:
+ '@vitest/expect': 2.1.9
+ '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2))
+ '@vitest/pretty-format': 2.1.9
+ '@vitest/runner': 2.1.9
+ '@vitest/snapshot': 2.1.9
+ '@vitest/spy': 2.1.9
+ '@vitest/utils': 2.1.9
+ chai: 5.3.3
+ debug: 4.4.3(supports-color@10.2.2)
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ pathe: 1.1.2
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.1.1
+ tinyrainbow: 1.2.0
+ vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+ vite-node: 2.1.9(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 24.13.5
+ jsdom: 25.0.1
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.13.5)(jsdom@25.0.1)(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)):
dependencies:
'@vitest/expect': 4.1.11