diff --git a/apps/admin/src/assets/images/ui06-login-visual.jpg b/apps/admin/src/assets/images/ui06-login-visual.jpg index caf284b..6e07a25 100644 Binary files a/apps/admin/src/assets/images/ui06-login-visual.jpg and b/apps/admin/src/assets/images/ui06-login-visual.jpg differ diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index ffb1158..0ff1c6c 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -422,7 +422,7 @@ "bannerPickerHint": "Tap to pick a banner image (shown atop the viewer page)", "bannerSelected": "Banner selected — will upload on save", "bannerUploadFailed": "Banner upload failed", - "shareCardMode": "Share to WeChat / other apps", + "shareCardMode": "Share to...", "sharePosterMode": "Generate QR poster", "posterDownload": "Download poster image", "posterSaved": "Poster saved to gallery", diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 726c6c6..cf8dcaa 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -1985,7 +1985,7 @@ abstract class AppLocalizations { /// No description provided for @shareCardMode. /// /// In zh, this message translates to: - /// **'分享到微信 / 其他应用'** + /// **'分享到...'** String get shareCardMode; /// No description provided for @sharePosterMode. diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 16627e3..4e2e736 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -1015,7 +1015,7 @@ class AppLocalizationsEn extends AppLocalizations { String get bannerUploadFailed => 'Banner upload failed'; @override - String get shareCardMode => 'Share to WeChat / other apps'; + String get shareCardMode => 'Share to...'; @override String get sharePosterMode => 'Generate QR poster'; diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 000b055..ab476db 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -982,7 +982,7 @@ class AppLocalizationsZh extends AppLocalizations { String get bannerUploadFailed => '宣传图上传失败'; @override - String get shareCardMode => '分享到微信 / 其他应用'; + String get shareCardMode => '分享到...'; @override String get sharePosterMode => '生成二维码海报'; diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 41fef11..14a9aae 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -422,7 +422,7 @@ "bannerPickerHint": "点击选择宣传图(展示在观众页顶部)", "bannerSelected": "已选择宣传图——保存时上传", "bannerUploadFailed": "宣传图上传失败", - "shareCardMode": "分享到微信 / 其他应用", + "shareCardMode": "分享到...", "sharePosterMode": "生成二维码海报", "posterDownload": "下载海报图", "posterSaved": "海报已保存到相册", diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index 7b32389..cbe2b0a 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -1,11 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluwx/fluwx.dart'; import 'app/app.dart'; import 'app/locale_controller.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // 微信 SDK(卡片分享):WECHAT_APPID 经 --dart-define 注入(微信开放平台 + // 注册的应用 ID);未配置(空)时跳过注册——分享回退系统分享面板。 + // Android 需保证打包签名与微信平台注册签名一致,否则拉不起微信。 + const wechatAppId = String.fromEnvironment('WECHAT_APPID'); + if (wechatAppId.isNotEmpty) { + await Fluwx().registerApi(appId: wechatAppId, doOnIOS: false); + } // 启动先读语言偏好再 runApp:首帧即正确语言(无「system 闪变」, // P3 Task 10)。读取失败由 readPersisted 内部兜底为 system。 final localeChoice = await LocaleSettings.readPersisted(); diff --git a/apps/mobile/lib/pages/share_qr_dialog.dart b/apps/mobile/lib/pages/share_qr_dialog.dart index df83349..08b2f69 100644 --- a/apps/mobile/lib/pages/share_qr_dialog.dart +++ b/apps/mobile/lib/pages/share_qr_dialog.dart @@ -10,10 +10,12 @@ library; import 'dart:ui' as ui; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fluwx/fluwx.dart'; import 'package:gal/gal.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; @@ -47,17 +49,72 @@ final urlLauncherProvider = Provider( (url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), ); -/// 系统分享注入点(生产=share_plus;测试 override)。 -typedef ShareLauncher = Future Function(String text); +/// 分享请求(卡片字段:微信 SDK 用全集;系统分享回退用 title+url)。 +class ShareCardRequest { + const ShareCardRequest({ + required this.title, + required this.description, + required this.url, + this.thumbnailUrl, + }); + + final String title; + final String description; + final String url; + + /// 卡片缩略图(封面 URL;null=无图,微信显示默认图标)。 + final String? thumbnailUrl; +} + +/// 分享注入点(生产:微信已配置且安装→WXWebpage 卡片;否则系统分享面板。 +/// 测试 override)。 +typedef ShareLauncher = Future Function(ShareCardRequest card); final shareLauncherProvider = Provider( - // share_plus 11:SharePlus.instance.share(旧 Share.share 已废弃)。 - (ref) => - (text) async => SharePlus.instance.share( - ShareParams(text: text, subject: text.split('\n').first), - ), + (ref) => (card) async { + const wechatAppId = String.fromEnvironment('WECHAT_APPID'); + if (wechatAppId.isNotEmpty && await Fluwx().isWeChatInstalled) { + final thumb = await compressShareThumbnail(card.thumbnailUrl); + await Fluwx().share( + WeChatShareWebPageModel( + card.url, + title: card.title, + description: card.description, + thumbData: thumb, + scene: WeChatScene.session, + ), + ); + return; + } + // 回退:系统分享面板(文本=标题+链接;接收应用自行渲染链接)。 + // share_plus 11:SharePlus.instance.share(旧 Share.share 已废弃)。 + await SharePlus.instance.share( + ShareParams(text: '${card.title}\n${card.url}', subject: card.title), + ); + }, ); +/// 微信卡片缩略图:下载→缩到 240px→PNG(微信 thumbData 限制 ≤32KB)。 +Future compressShareThumbnail(String? url) async { + if (url == null) return null; + try { + final resp = await Dio().get>( + url, + options: Options(responseType: ResponseType.bytes), + ); + final data = Uint8List.fromList(resp.data!); + final codec = await ui.instantiateImageCodec(data, targetWidth: 240); + final frame = await codec.getNextFrame(); + final resized = await frame.image.toByteData( + format: ui.ImageByteFormat.png, + ); + return resized?.buffer.asUint8List(); + } on Object { + // 缩略图失败不阻塞分享(无图卡片仍可发) + return null; + } +} + /// 海报保存相册注入点(生产=gal;测试 override)。 typedef PosterSaver = Future Function(ui.Image image); @@ -89,7 +146,28 @@ class ShareQrPage extends ConsumerWidget { String url, String title, ) async { - await ref.read(shareLauncherProvider)('$title\n$url'); + final l10n = AppLocalizations.of(context); + final localeName = l10n.localeName; + final start = formatAlbumDateTime( + album.startAt, + album.timezone, + localeName, + ); + final end = album.endAt == null + ? null + : formatAlbumDateTime(album.endAt!, album.timezone, localeName); + final dateRange = end == null ? start : '$start ~ $end'; + final venue = albumDisplayVenue(album, localeName); + final description = venue == null ? dateRange : '$dateRange · $venue'; + // 缩略图用封面(列表即有签名 URL,减少一次串行请求)。 + await ref.read(shareLauncherProvider)( + ShareCardRequest( + title: title, + description: description, + url: url, + thumbnailUrl: album.cover?.url, + ), + ); } @override diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 16bd0d1..b2082b0 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -418,6 +418,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluwx: + dependency: "direct main" + description: + name: fluwx + sha256: "4b526d281be8560a490bd1b945373a23ab2a12088c8fc5997ffb31cc6fb41082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.2" fuchsia_remote_debug_protocol: dependency: transitive description: flutter diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 8fe8816..4d8c7cb 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: url_launcher: ^6.3.2 share_plus: ^13.3.0 gal: ^2.3.3 + fluwx: ^6.0.2 dev_dependencies: flutter_test: diff --git a/apps/mobile/test/pages/share_qr_page_test.dart b/apps/mobile/test/pages/share_qr_page_test.dart index 17f5241..dda648f 100644 --- a/apps/mobile/test/pages/share_qr_page_test.dart +++ b/apps/mobile/test/pages/share_qr_page_test.dart @@ -64,7 +64,7 @@ void main() { reason: '开始时间(相册时区;结束未定不伪造区间)+ 场地(展示回退链)', ); // UI06 三方式:系统分享(默认)/ 生成二维码海报 / 复制链接。 - expect(find.text('Share to WeChat / other apps'), findsOneWidget); + expect(find.text('Share to...'), findsOneWidget); expect(find.text('Generate QR poster'), findsOneWidget); expect(find.text('Copy link'), findsOneWidget); // 可选中链接(二维码与复制共用同一 URL)。 diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index 59960de..1afeb2a 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -321,11 +321,24 @@ test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', ( const target = inside.locator('a.stream-photo').first(); const photoId = await target.getAttribute('data-photo-id'); expect(photoId).toBeTruthy(); - const popupPromise = page.waitForEvent('popup', { timeout: 5000 }); + // 宿主内弹框(用户决策 2026-09-23):不新开标签,宿主页覆盖层 + 详情 iframe。 + let popupOpened = false; + page.once('popup', () => { + popupOpened = true; + }); await target.click(); - const popup = await popupPromise; - expect(popup.url()).toContain(`/p/${photoId}`); - await popup.close(); + const modalFrame = page.locator('iframe[title="PhotoLive photo detail"]'); + await modalFrame.waitFor({ timeout: 5000 }); + expect(popupOpened).toBe(false); + await expect + .poll(async () => { + const f = page.frames().find((fr) => (fr.url().includes('/p/') ?? false)); + return f?.url() ?? ''; + }) + .toContain(`/p/${photoId}`); + await page.screenshot({ path: `${OUT_ROOT}/actual/server/photo-modal.png` }); + await page.keyboard.press('Escape'); + await expect(modalFrame).toHaveCount(0); }); test('drag beyond 8px cancels click (§4.1)', async ({ page }) => { diff --git a/apps/viewer/index.html b/apps/viewer/index.html index ac67af7..431aedc 100644 --- a/apps/viewer/index.html +++ b/apps/viewer/index.html @@ -5,6 +5,13 @@ 现场相册 + + + + +
diff --git a/apps/viewer/public/embed/v2/loader.js b/apps/viewer/public/embed/v2/loader.js index c3e1f8a..33e96a8 100644 --- a/apps/viewer/public/embed/v2/loader.js +++ b/apps/viewer/public/embed/v2/loader.js @@ -1 +1 @@ -(()=>{var k=["left","right"],de=["sm","md"],L=["auto","zh-CN","en"],ce=["contain","cover"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3};var ue=3e3,me=3e4;var R=0,P=120,W=0,G=240,Z=1,q=2147483e3,j=["resize","collapse","expand","config","destroy"],B=195,y=280,he=8,ge=32,$=16,xe=["carousel","vertical-stream"];function g(t,l,o){return t.indexOf(l)>=0?l:o}function x(t,l,o,f){let u=Number(t);return isNaN(u)||!isFinite(u)?f:Math.min(o,Math.max(l,Math.round(u)))}function d(t,l){let o=t[l];return o!=null&&o!==""}function ve(t){return!t||typeof t!="object"?null:{position:g(k,t.position,p.position),size:g(de,t.size,p.size),intervalMs:x(t.intervalMs,ue,me,p.intervalMs),lang:g(L,t.lang,p.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:p.collapsedDefault,imageFit:g(ce,t.imageFit,p.imageFit),offsetX:x(t.offsetX,R,P,p.offsetX),offsetBottom:x(t.offsetBottom,W,G,p.offsetBottom),zIndex:x(t.zIndex,Z,q,p.zIndex)}}function J(t){let l=Number(t);return isNaN(l)||!isFinite(l)?$:Math.min(ge,Math.max(he,Math.round(l)))}function Q(t,l){let o=t||{},f=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(l&&l.narrow);return{version:2,slot:f||null,lang:d(o,"lang")?g(L,o.lang,p.lang):p.lang,position:d(o,"position")?g(k,o.position,p.position):p.position,speed:d(o,"speed")?J(o.speed):$,reactions:d(o,"reactions")?g(["on","off"],o.reactions,"on")==="on":!0,offsetX:d(o,"offsetX")?x(o.offsetX,R,P,p.offsetX):p.offsetX,offsetBottom:d(o,"offsetBottom")?x(o.offsetBottom,W,G,p.offsetBottom):p.offsetBottom,zIndex:d(o,"zIndex")?x(o.zIndex,Z,q,p.zIndex):p.zIndex,pollMs:d(o,"poll")?x(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:d(o,"lang"),position:d(o,"position"),speed:d(o,"speed"),reactions:d(o,"reactions"),offsetX:d(o,"offsetX"),offsetBottom:d(o,"offsetBottom"),zIndex:d(o,"zIndex")}}}function Y(t){if(!t||typeof t!="object")return null;let l=ve(t);return l===null?null:{...l,displayMode:g(xe,t.displayMode,"carousel"),scrollSpeed:J(t.scrollSpeed),reactionsEnabled:typeof t.reactionsEnabled=="boolean"?t.reactionsEnabled:!0}}function K(t,l){let o=Y(l);if(o===null)return t;let f=t.explicit;return{...t,lang:f.lang?t.lang:o.lang,position:f.position?t.position:o.position,speed:f.speed?t.speed:o.scrollSpeed,reactions:f.reactions?t.reactions:o.reactionsEnabled,offsetX:f.offsetX?t.offsetX:o.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:f.zIndex?t.zIndex:o.zIndex}}function ee(t,l){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let f=l==null?0:Number(l);if(o<=f)return null;let u=Y(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function te(t,l,o){let f=t+"/embed/"+encodeURIComponent(l)+"?v=2&lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.speed&&(f+="&speed="+String(o.speed)),o.explicit.reactions&&!o.reactions&&(f+="&reactions=off"),o.pollMs!==null&&o.pollMs!==void 0&&(f+="&poll="+String(o.pollMs)),f}(function(){"use strict";try{let C=function(e){try{return t.sessionStorage.getItem(l+e)==="1"}catch(r){return!1}},F=function(e,r){try{r?t.sessionStorage.setItem(l+e,"1"):t.sessionStorage.removeItem(l+e)}catch(n){}},w=function(e){try{return t.sessionStorage.getItem(l+e)!==null}catch(r){return!1}},re=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(r){return null}},_=function(e){if(e==="zh-CN")return!0;if(e==="auto")try{return/^zh/i.test(t.navigator.language||"")}catch(r){return!1}return!1},ne=function(e){var r=document.createElement("div");r.setAttribute("role","button"),r.setAttribute("tabindex","0"),r.setAttribute("aria-label",_(e.config.lang)?"\u5C55\u5F00\u7167\u7247\u7A97":"Show photo window");var n=r.style;n.position="fixed",n.height=f+"px",n.width="100px",n.boxSizing="border-box",n.borderRadius="22px",n.border="1px solid #e3eaf2",n.background="#ffffff",n.boxShadow="0 4px 16px rgba(15,23,42,0.125)",n.zIndex=String(e.config.zIndex),n.cursor="pointer",n.display="flex",n.alignItems="center",n.justifyContent="center",n.gap="8px",n.font='400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',n.color="#32618e",n.whiteSpace="nowrap",r.innerHTML=''+(_(e.config.lang)?"\u7167\u7247":"Photos")+"";function s(i){if(i.type==="keydown"){var a=i.key;if(a!=="Enter"&&a!==" "&&a!=="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 r.addEventListener("click",s),r.addEventListener("keydown",s),r},ie=function(e){return e.collapsed?f:e.height},le=function(e){return e.config.explicit.offsetX||e.offsetConfigured?e.config.offsetX:t.innerWidth<=599?12:16},V=function(e){var r=le(e),n=t.innerWidth-r-8,s=t.innerHeight-e.config.offsetBottom-8;return{offsetX:r,width:Math.max(120,Math.min(B,n)),height:Math.max(160,Math.min(y,s)),operable:n>=u&&s>=oe}},v=function(e){var r=V(e);e.height=r.height;var n=e.config.position==="left"?"left":"right",s=n==="left"?"right":"left",i=e.wrapper.style;if(e.collapsed||(i.display="block",i.width=r.width+"px",i.height=r.height+"px"),i.zIndex=String(e.config.zIndex),i[n]=r.offsetX+"px",i[s]="",i.bottom=e.config.offsetBottom+"px",i.bottom="calc("+e.config.offsetBottom+"px + env(safe-area-inset-bottom, 0px))",e.handle){var a=e.handle.style;a.zIndex=String(e.config.zIndex),a[n]=r.offsetX+"px",a[s]=""}return r},A=function(){for(var e={left:[],right:[]},r=0;r0&&z+y>t.innerHeight&&T(M,!1),M.wrapper.style.bottom=z+"px",M.handle&&(M.handle.style.bottom=z+"px"),c=z}},b=function(){if(S!==null||typeof t.requestAnimationFrame!="function"){A();return}S=t.requestAnimationFrame(function(){S=null,A()})},T=function(e,r){e.collapsed||(e.collapsed=!0,e.wrapper.style.display="none",e.handle||(e.handle=ne(e),document.body.appendChild(e.handle),v(e)),e.handle.style.display="flex",r!==!1&&F(e.config.slot,!0))},I=function(e,r){T(e,r),b()},X=function(e){e.collapsed&&(e.collapsed=!1,v(e),e.handle&&(e.handle.style.display="none"),F(e.config.slot,!1),b())},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(n){}var r=m.indexOf(e);r>=0&&m.splice(r,1),delete E[e.config.slot],m.length===0&&t.removeEventListener("message",D),b()}},se=function(e,r,n){e.backendVersion=r,e.offsetConfigured=!0,e.config=K(e.config,n),v(e),!e.userChoice&&!w(e.config.slot)&&(n.collapsedDefault?I(e,!1):fe(e)||X(e)),b()},fe=function(e){return!V(e).operable},pe=function(e,r){var n=document.createElement("div"),s=n.style;s.position="fixed",s.width=B+"px",s.height=y+"px",s.borderRadius="12px",s.border="1px solid rgba(255,255,255,0.80)",s.boxShadow="0 4px 16px rgba(15,23,42,0.16)",s.background="#10161E",s.overflow="hidden",s.setProperty("box-sizing","border-box","important");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,r.slot,r)),i.setAttribute("referrerpolicy","no-referrer-when-downgrade"),i.setAttribute("title","PhotoLive photos");var a=i.style;a.width="100%",a.height="100%",a.border="0",a.display="block",a.setProperty("border","0","important"),a.setProperty("width","100%","important"),a.setProperty("height","100%","important"),a.setProperty("display","block","important"),n.appendChild(i),document.body.appendChild(n);var c={config:r,wrapper:n,frame:i,frameOrigin:N(i)||e,height:y,collapsed:!1,narrow:r.narrow,handle:null,userChoice:!1,backendVersion:0,offsetConfigured:r.explicit.offsetX,destroyed:!1};m.push(c);var h=v(c);return w(r.slot)?C(r.slot)&&I(c,!1):(c.narrow||!h.operable)&&I(c,!1),c},D=function(e){try{var r=e.data;if(!r||r.photoliveEmbed!==!0)return;var n=r.type;if(j.indexOf(n)<0)return;for(var s=0;s{var Z=["left","right"],ve=["sm","md"],j=["auto","zh-CN","en"],be=["contain","cover"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3};var ye=3e3,Ie=3e4;var q=0,$=120,J=0,Q=240,Y=1,K=2147483e3,ee=["resize","collapse","expand","config","destroy"],F=195,C=280,Ee=8,ze=32,te=16,we=["carousel","vertical-stream"];function y(t,s,n){return t.indexOf(s)>=0?s:n}function I(t,s,n,f){let x=Number(t);return isNaN(x)||!isFinite(x)?f:Math.min(n,Math.max(s,Math.round(x)))}function c(t,s){let n=t[s];return n!=null&&n!==""}function Me(t){return!t||typeof t!="object"?null:{position:y(Z,t.position,p.position),size:y(ve,t.size,p.size),intervalMs:I(t.intervalMs,ye,Ie,p.intervalMs),lang:y(j,t.lang,p.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:p.collapsedDefault,imageFit:y(be,t.imageFit,p.imageFit),offsetX:I(t.offsetX,q,$,p.offsetX),offsetBottom:I(t.offsetBottom,J,Q,p.offsetBottom),zIndex:I(t.zIndex,Y,K,p.zIndex)}}function oe(t){let s=Number(t);return isNaN(s)||!isFinite(s)?te:Math.min(ze,Math.max(Ee,Math.round(s)))}function ne(t,s){let n=t||{},f=typeof n.slot=="string"?n.slot.replace(/^\s+|\s+$/g,""):"",x=!!(s&&s.narrow);return{version:2,slot:f||null,lang:c(n,"lang")?y(j,n.lang,p.lang):p.lang,position:c(n,"position")?y(Z,n.position,p.position):p.position,speed:c(n,"speed")?oe(n.speed):te,reactions:c(n,"reactions")?y(["on","off"],n.reactions,"on")==="on":!0,offsetX:c(n,"offsetX")?I(n.offsetX,q,$,p.offsetX):p.offsetX,offsetBottom:c(n,"offsetBottom")?I(n.offsetBottom,J,Q,p.offsetBottom):p.offsetBottom,zIndex:c(n,"zIndex")?I(n.zIndex,Y,K,p.zIndex):p.zIndex,pollMs:c(n,"poll")?I(n.poll,250,6e4,null):null,narrow:x,explicit:{lang:c(n,"lang"),position:c(n,"position"),speed:c(n,"speed"),reactions:c(n,"reactions"),offsetX:c(n,"offsetX"),offsetBottom:c(n,"offsetBottom"),zIndex:c(n,"zIndex")}}}function re(t){if(!t||typeof t!="object")return null;let s=Me(t);return s===null?null:{...s,displayMode:y(we,t.displayMode,"carousel"),scrollSpeed:oe(t.scrollSpeed),reactionsEnabled:typeof t.reactionsEnabled=="boolean"?t.reactionsEnabled:!0}}function ie(t,s){let n=re(s);if(n===null)return t;let f=t.explicit;return{...t,lang:f.lang?t.lang:n.lang,position:f.position?t.position:n.position,speed:f.speed?t.speed:n.scrollSpeed,reactions:f.reactions?t.reactions:n.reactionsEnabled,offsetX:f.offsetX?t.offsetX:n.offsetX,offsetBottom:f.offsetBottom?t.offsetBottom:n.offsetBottom,zIndex:f.zIndex?t.zIndex:n.zIndex}}function ae(t,s){if(!t||typeof t!="object")return null;let n=Number(t.configVersion);if(isNaN(n)||!isFinite(n))return null;let f=s==null?0:Number(s);if(n<=f)return null;let x=re(t.settings);return x===null?null:{configVersion:Math.round(n),settings:x}}function le(t,s,n){let f=t+"/embed/"+encodeURIComponent(s)+"?v=2&lang="+encodeURIComponent(n.lang)+"&pos="+encodeURIComponent(n.position);return n.explicit.speed&&(f+="&speed="+String(n.speed)),n.explicit.reactions&&!n.reactions&&(f+="&reactions=off"),n.pollMs!==null&&n.pollMs!==void 0&&(f+="&poll="+String(n.pollMs)),f}var Se=ee.concat(["open-photo"]);(function(){"use strict";try{let V=function(e){try{return t.sessionStorage.getItem(s+e)==="1"}catch(o){return!1}},k=function(e,o){try{o?t.sessionStorage.setItem(s+e,"1"):t.sessionStorage.removeItem(s+e)}catch(r){}},N=function(e){try{return t.sessionStorage.getItem(s+e)!==null}catch(o){return!1}},fe=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},T=function(e){try{return new URL(e.src,t.location.href).origin}catch(o){return null}},B=function(e){if(e==="zh-CN")return!0;if(e==="auto")try{return/^zh/i.test(t.navigator.language||"")}catch(o){return!1}return!1},pe=function(e){var o=document.createElement("div");o.setAttribute("role","button"),o.setAttribute("tabindex","0"),o.setAttribute("aria-label",B(e.config.lang)?"\u5C55\u5F00\u7167\u7247\u7A97":"Show photo window");var r=o.style;r.position="fixed",r.height=f+"px",r.width="100px",r.boxSizing="border-box",r.borderRadius="22px",r.border="1px solid #e3eaf2",r.background="#ffffff",r.boxShadow="0 4px 16px rgba(15,23,42,0.125)",r.zIndex=String(e.config.zIndex),r.cursor="pointer",r.display="flex",r.alignItems="center",r.justifyContent="center",r.gap="8px",r.font='400 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',r.color="#32618e",r.whiteSpace="nowrap",o.innerHTML=''+(B(e.config.lang)?"\u7167\u7247":"Photos")+"";function l(i){if(i.type==="keydown"){var a=i.key;if(a!=="Enter"&&a!==" "&&a!=="Spacebar")return;i.preventDefault()}e.userChoice=!0,A(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return o.addEventListener("click",l),o.addEventListener("keydown",l),o},de=function(e){return e.collapsed?f:e.height},ue=function(e){return e.config.explicit.offsetX||e.offsetConfigured?e.config.offsetX:t.innerWidth<=599?12:16},D=function(e){var o=ue(e),r=t.innerWidth-o-8,l=t.innerHeight-e.config.offsetBottom-8;return{offsetX:o,width:Math.max(120,Math.min(F,r)),height:Math.max(160,Math.min(C,l)),operable:r>=x&&l>=se}},E=function(e){var o=D(e);e.height=o.height;var r=e.config.position==="left"?"left":"right",l=r==="left"?"right":"left",i=e.wrapper.style;if(e.collapsed||(i.display="block",i.width=o.width+"px",i.height=o.height+"px"),i.zIndex=String(e.config.zIndex),i[r]=o.offsetX+"px",i[l]="",i.bottom=e.config.offsetBottom+"px",i.bottom="calc("+e.config.offsetBottom+"px + env(safe-area-inset-bottom, 0px))",e.handle){var a=e.handle.style;a.zIndex=String(e.config.zIndex),a[r]=o.offsetX+"px",a[l]=""}return o},H=function(){for(var e={left:[],right:[]},o=0;o0&&u+C>t.innerHeight&&L(h,!1),h.wrapper.style.bottom=u+"px",h.handle&&(h.handle.style.bottom=u+"px"),d=u}},z=function(){if(_!==null||typeof t.requestAnimationFrame!="function"){H();return}_=t.requestAnimationFrame(function(){_=null,H()})},L=function(e,o){e.collapsed||(e.collapsed=!0,e.wrapper.style.display="none",e.handle||(e.handle=pe(e),document.body.appendChild(e.handle),E(e)),e.handle.style.display="flex",o!==!1&&k(e.config.slot,!0))},w=function(e,o){L(e,o),z()},A=function(e){e.collapsed&&(e.collapsed=!1,E(e),e.handle&&(e.handle.style.display="none"),k(e.config.slot,!1),z())},ce=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(s+e.config.slot)}catch(r){}var o=v.indexOf(e);o>=0&&v.splice(o,1),delete X[e.config.slot],M(),v.length===0&&t.removeEventListener("message",U),z()}},M=function(){m!==null&&(m.parentNode&&m.parentNode.removeChild(m),m=null,t.removeEventListener("keydown",O))},O=function(e){e.key==="Escape"&&M()},me=function(e,o){if(!(typeof o!="string"||o==="")){var r;try{r=new URL(o)}catch(G){return}if(r.origin===e.frameOrigin){if(m===null){m=document.createElement("div");var l=m.style;l.position="fixed",l.inset="0",l.zIndex=String(e.config.zIndex+1),l.background="rgba(9, 14, 20, 0.6)",l.display="flex",l.alignItems="center",l.justifyContent="center",l.padding="16px",m.addEventListener("pointerdown",function(G){G.target===m&&M()});var i=document.createElement("div"),a=i.style;a.position="relative",a.width="min(92vw, 420px)",a.height="min(86vh, 640px)",a.borderRadius="12px",a.border="1px solid rgba(255,255,255,0.80)",a.background="#10161e",a.boxShadow="0 12px 40px rgba(15,23,42,0.35)",a.overflow="hidden";var d=document.createElement("iframe");d.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),d.setAttribute("referrerpolicy","no-referrer-when-downgrade"),d.setAttribute("title","PhotoLive photo detail");var g=d.style;g.width="100%",g.height="100%",g.border="0",g.display="block",i.appendChild(d);var h=document.createElement("button");h.type="button",h.setAttribute("aria-label",B(e.config.lang)?"\u5173\u95ED\u7167\u7247\u8BE6\u60C5":"Close photo detail");var u=h.style;u.position="absolute",u.top="6px",u.right="6px",u.width="44px",u.height="44px",u.border="0",u.background="transparent",u.color="#647084",u.cursor="pointer",u.padding="0";var S=document.createElement("span"),b=S.style;b.position="absolute",b.top="6px",b.right="6px",b.width="24px",b.height="24px",b.borderRadius="50%",b.background="rgba(255,255,255,0.82)",b.display="flex",b.alignItems="center",b.justifyContent="center",S.textContent="\xD7",S.style.fontSize="19px",S.style.lineHeight="1",h.appendChild(S),h.addEventListener("click",M),i.appendChild(h),m.appendChild(i),document.body.appendChild(m),t.addEventListener("keydown",O),m.__frame=d,h.focus()}m.__frame.setAttribute("src",r.href)}}},ge=function(e,o,r){e.backendVersion=o,e.offsetConfigured=!0,e.config=ie(e.config,r),E(e),!e.userChoice&&!N(e.config.slot)&&(r.collapsedDefault?w(e,!1):he(e)||A(e)),z()},he=function(e){return!D(e).operable},xe=function(e,o){var r=document.createElement("div"),l=r.style;l.position="fixed",l.width=F+"px",l.height=C+"px",l.borderRadius="12px",l.border="1px solid rgba(255,255,255,0.80)",l.boxShadow="0 4px 16px rgba(15,23,42,0.16)",l.background="#10161E",l.overflow="hidden",l.setProperty("box-sizing","border-box","important");var i=document.createElement("iframe");i.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),i.setAttribute("src",le(e,o.slot,o)),i.setAttribute("referrerpolicy","no-referrer-when-downgrade"),i.setAttribute("title","PhotoLive photos");var a=i.style;a.width="100%",a.height="100%",a.border="0",a.display="block",a.setProperty("border","0","important"),a.setProperty("width","100%","important"),a.setProperty("height","100%","important"),a.setProperty("display","block","important"),r.appendChild(i),document.body.appendChild(r);var d={config:o,wrapper:r,frame:i,frameOrigin:T(i)||e,height:C,collapsed:!1,narrow:o.narrow,handle:null,userChoice:!1,backendVersion:0,offsetConfigured:o.explicit.offsetX,destroyed:!1};v.push(d);var g=E(d);return N(o.slot)?V(o.slot)&&w(d,!1):(d.narrow||!g.operable)&&w(d,!1),d},U=function(e){try{var o=e.data;if(!o||o.photoliveEmbed!==!0)return;var r=o.type;if(Se.indexOf(r)<0)return;for(var l=0;l @@ -143,6 +150,7 @@ function openPhoto(photoId: string): void { { ref="streamEl" :photos="SAMPLE_PHOTOS" album-token="preview" + album-title="PhotoLive 相册 · 预览" :photo-url="previewPhotoUrl" :speed="speed" :reactions="reactions" diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index cd8d770..1ba7e9a 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -24,6 +24,8 @@ const props = defineProps<{ photos: PublicEmbedPhoto[]; /** 当前公开相册 token(气泡订阅目标;未解析为 null)。 */ albumToken: string | null; + /** 相册标题(顶部叠加展示,用户决策 2026-09-23)。 */ + albumTitle?: string; /** 观众深链构造器(真实 href;点击经锁定目标受控打开,§4.1)。 */ photoUrl: (photoId: string) => string; /** 自动上滚速度(CSS px/s)。 */ @@ -402,6 +404,11 @@ onBeforeUnmount(() => { > + + +
{{ t('embed.comingSoon') }} @@ -486,6 +493,21 @@ onBeforeUnmount(() => { background: #f6f8fb; object-fit: cover; } +.stream-title { + position: absolute; + top: 11px; + left: 12px; + right: 56px; /* 避让右上收起触区 */ + z-index: 2; + margin: 0; + font: 600 12px/1.3 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif; + color: rgba(255, 255, 255, 0.94); + text-shadow: 0 1px 3px rgba(9, 14, 20, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} /* 收起入口(§2.4;效果示例 .collapse):44×44 触区右上、可视圆 24×24 * 内缩 6px(#ffffffd1 底、− 字形 19px #647084)。 */ .stream-collapse { diff --git a/docs/designs/ui04-implementation/actual/server/photo-modal.png b/docs/designs/ui04-implementation/actual/server/photo-modal.png new file mode 100644 index 0000000..a1d5dda Binary files /dev/null and b/docs/designs/ui04-implementation/actual/server/photo-modal.png differ diff --git a/packages/embed-loader/src/loader-v2.js b/packages/embed-loader/src/loader-v2.js index 7e072f1..55d6a47 100644 --- a/packages/embed-loader/src/loader-v2.js +++ b/packages/embed-loader/src/loader-v2.js @@ -27,6 +27,9 @@ import { V2_HEIGHT, } from './params.js'; +// v2 上行白名单 = v1 白名单 + open-photo(宿主内详情弹框,用户决策 2026-09-23)。 +var V2_MESSAGE_TYPES = MESSAGE_TYPES.concat(['open-photo']); + (function () { 'use strict'; try { @@ -281,10 +284,121 @@ import { var i = instances.indexOf(inst); if (i >= 0) instances.splice(i, 1); delete seenSlots[inst.config.slot]; + closePhotoModal(); if (instances.length === 0) w.removeEventListener('message', onMessage); scheduleRelayout(); } + // ── 照片详情宿主内弹框(用户决策 2026-09-23):点击滚动照片 → 宿主页 + // 覆盖层 + 居中卡片内嵌 viewer 深链 iframe(不新开标签)。安全:URL + // 必须与组件 iframe 同源(frameOrigin)才打开——iframe 被攻破也无法 + // 诱导宿主内嵌任意第三方页面;Esc/遮罩/× 关闭;单实例(重复点击换 src)。 + var photoModal = null; + + function closePhotoModal() { + if (photoModal === null) return; + if (photoModal.parentNode) photoModal.parentNode.removeChild(photoModal); + photoModal = null; + w.removeEventListener('keydown', onModalKeydown); + } + + function onModalKeydown(e) { + if (e.key === 'Escape') closePhotoModal(); + } + + function openPhotoModal(inst, rawUrl) { + if (typeof rawUrl !== 'string' || rawUrl === '') return; + var target; + try { + target = new URL(rawUrl); + } catch { + return; + } + if (target.origin !== inst.frameOrigin) return; // 同源校验(防任意嵌入) + if (photoModal === null) { + photoModal = document.createElement('div'); + var os = photoModal.style; + os.position = 'fixed'; + os.inset = '0'; + os.zIndex = String(inst.config.zIndex + 1); + os.background = 'rgba(9, 14, 20, 0.6)'; + os.display = 'flex'; + os.alignItems = 'center'; + os.justifyContent = 'center'; + os.padding = '16px'; + photoModal.addEventListener('pointerdown', function (e) { + if (e.target === photoModal) closePhotoModal(); + }); + + var card = document.createElement('div'); + var cs = card.style; + cs.position = 'relative'; + cs.width = 'min(92vw, 420px)'; + cs.height = 'min(86vh, 640px)'; + cs.borderRadius = '12px'; + cs.border = '1px solid rgba(255,255,255,0.80)'; + cs.background = '#10161e'; + cs.boxShadow = '0 12px 40px rgba(15,23,42,0.35)'; + cs.overflow = 'hidden'; + + var detailFrame = document.createElement('iframe'); + detailFrame.setAttribute( + 'sandbox', + 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin' + ); + detailFrame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); + detailFrame.setAttribute('title', 'PhotoLive photo detail'); + var dfs = detailFrame.style; + dfs.width = '100%'; + dfs.height = '100%'; + dfs.border = '0'; + dfs.display = 'block'; + card.appendChild(detailFrame); + + var closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.setAttribute('aria-label', isZh(inst.config.lang) ? '关闭照片详情' : 'Close photo detail'); + var bs = closeBtn.style; + bs.position = 'absolute'; + bs.top = '6px'; + bs.right = '6px'; + bs.width = '44px'; + bs.height = '44px'; + bs.border = '0'; + bs.background = 'transparent'; + bs.color = '#647084'; + bs.cursor = 'pointer'; + bs.padding = '0'; + var dot = document.createElement('span'); + var dss = dot.style; + dss.position = 'absolute'; + dss.top = '6px'; + dss.right = '6px'; + dss.width = '24px'; + dss.height = '24px'; + dss.borderRadius = '50%'; + dss.background = 'rgba(255,255,255,0.82)'; + dss.display = 'flex'; + dss.alignItems = 'center'; + dss.justifyContent = 'center'; + dot.textContent = '×'; + dot.style.fontSize = '19px'; + dot.style.lineHeight = '1'; + closeBtn.appendChild(dot); + closeBtn.addEventListener('click', closePhotoModal); + card.appendChild(closeBtn); + + photoModal.appendChild(card); + document.body.appendChild(photoModal); + w.addEventListener('keydown', onModalKeydown); + photoModal.__frame = detailFrame; + // 焦点移入弹框关闭钮:键盘事件落到宿主文档(Esc 可关);否则焦点 + // 残留在小组件 iframe 内,宿主 keydown 收不到。 + closeBtn.focus(); + } + photoModal.__frame.setAttribute('src', target.href); + } + /** 后台设置应用(config 消息已过 parseConfigMessageV2 版本闸):非 data-* * 显式字段立即生效;collapsedDefault 仅在无用户选择时应用。 */ function applyConfig(inst, version, settings) { @@ -374,7 +488,7 @@ import { var data = event.data; if (!data || data.photoliveEmbed !== true) return; var type = data.type; - if (MESSAGE_TYPES.indexOf(type) < 0) return; + if (V2_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; @@ -390,6 +504,9 @@ import { if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings); } else if (type === 'destroy') { destroyInstance(inst); + closePhotoModal(); + } else if (type === 'open-photo') { + openPhotoModal(inst, data.url); } // 'resize':v2 窗口固定,显式忽略(§7.3)。 return;