feat(app,viewer): WeChat card share + 'Share to...' label + site og tags

- 按钮文字「分享到微信/其他应用」→「分享到...」(用户需求)
- 微信卡片分享(fluwx 6):WECHAT_APPID 经 --dart-define 注入;已配置且
  装微信 → WXWebpage 卡片(标题=相册名、描述=日期·地点、缩略图=封面压缩
  240px PNG ≤32KB、点击进观众链接);未配置/未装 → 回退系统分享面板
- viewer index.html 加站点级 og 标签(微信/QQ 抓取链接预览用;每相册动态
  og 需服务端注入,登记后续增强)
- 397/397 全绿、analyze 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 16:05:53 +08:00
parent 77b1519209
commit 5a207b395e
20 changed files with 288 additions and 22 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 93 KiB

+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
@@ -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';
@@ -982,7 +982,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get bannerUploadFailed => '宣传图上传失败';
@override
String get shareCardMode => '分享到微信 / 其他应用';
String get shareCardMode => '分享到...';
@override
String get sharePosterMode => '生成二维码海报';
+1 -1
View File
@@ -422,7 +422,7 @@
"bannerPickerHint": "点击选择宣传图(展示在观众页顶部)",
"bannerSelected": "已选择宣传图——保存时上传",
"bannerUploadFailed": "宣传图上传失败",
"shareCardMode": "分享到微信 / 其他应用",
"shareCardMode": "分享到...",
"sharePosterMode": "生成二维码海报",
"posterDownload": "下载海报图",
"posterSaved": "海报已保存到相册",
+8
View File
@@ -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<void> 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();
+86 -8
View File
@@ -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<UrlLauncher>(
(url) => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
);
/// 系统分享注入点(生产=share_plus;测试 override)。
typedef ShareLauncher = Future<void> 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<void> Function(ShareCardRequest card);
final shareLauncherProvider = Provider<ShareLauncher>(
// 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<Uint8List?> compressShareThumbnail(String? url) async {
if (url == null) return null;
try {
final resp = await Dio().get<List<int>>(
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<void> 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
+8
View File
@@ -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
+1
View File
@@ -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:
@@ -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)。
+17 -4
View File
@@ -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 }) => {
+7
View File
@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 无 JS 时的兜底标题;运行时由 App.vue 按 i18n 覆写 -->
<title>现场相册</title>
<!-- 分享链接卡片的站点级 og 标签(UI06):微信/QQ 等抓取器不执行 JS,
读源码生成链接预览——此处提供全站默认;每相册动态 og(标题/封面)
需服务端按 token 注入,属后续增强。 -->
<meta property="og:site_name" content="PhotoLive" />
<meta property="og:title" content="现场相册 · PhotoLive" />
<meta property="og:description" content="扫码或点击查看活动现场照片直播" />
<meta property="og:type" content="website" />
</head>
<body>
<div id="app"></div>
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -25,7 +25,10 @@ export type EmbedUpMessage =
| { photoliveEmbed: true; type: 'resize'; height: number }
| { photoliveEmbed: true; type: 'collapse' }
| { photoliveEmbed: true; type: 'config'; configVersion: number; settings: EmbedSlotSettings }
| { photoliveEmbed: true; type: 'destroy' };
| { photoliveEmbed: true; type: 'destroy' }
/** 照片详情宿主内弹框(用户决策 2026-09-23):url 为观众端深链(同源
* viewer 产物域,宿主 loader 校验后内嵌 iframe 弹框,不新开标签)。 */
| { photoliveEmbed: true; type: 'open-photo'; url: string };
/** 宿主源(referrer 推导);取不到(被隐去/直接打开)为 null。 */
export function embedParentOrigin(): string | null {
+9 -1
View File
@@ -18,6 +18,7 @@ import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useEmbedFeed } from '../embed/useEmbedFeed';
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
import { postEmbedMessage } from '../embed/postMessage';
import EmbedCarousel from './embed/EmbedCarousel.vue';
import EmbedStream from './embed/EmbedStream.vue';
@@ -131,7 +132,13 @@ function openPhoto(photoId: string): void {
if (albumToken === undefined) return;
const url = new URL(`/a/${encodeURIComponent(albumToken)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
// noopener:新标签页不持有 opener(sandbox allow-popups-to-escape-sandbox 配合)。
// 宿主内弹框(用户决策 2026-09-23):有父窗(loader 嵌入)→ postMessage
// 由宿主弹框内嵌详情页,不跳出当前网站;无父窗(直接打开/预览)→ 新标签
// noopener(sandbox allow-popups-to-escape-sandbox 配合)。
if (window.parent !== window) {
postEmbedMessage({ photoliveEmbed: true, type: 'open-photo', url: url.toString() });
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}
</script>
@@ -143,6 +150,7 @@ function openPhoto(photoId: string): void {
<EmbedStream
:photos="feed.status === 'ready' ? feed.photos : []"
:album-token="feed.album?.publicToken ?? null"
:album-title="feed.album?.title ?? ''"
:photo-url="streamPhotoUrl"
:speed="streamSpeed"
:reactions="streamReactions"
@@ -101,6 +101,7 @@ onBeforeUnmount(() => {
ref="streamEl"
:photos="SAMPLE_PHOTOS"
album-token="preview"
album-title="PhotoLive 相册 · 预览"
:photo-url="previewPhotoUrl"
:speed="speed"
:reactions="reactions"
@@ -24,6 +24,8 @@ const props = defineProps<{
photos: PublicEmbedPhoto[];
/** 当前公开相册 token(气泡订阅目标;未解析为 null)。 */
albumToken: string | null;
/** 相册标题(顶部叠加展示,用户决策 2026-09-23)。 */
albumTitle?: string;
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
photoUrl: (photoId: string) => string;
/** 自动上滚速度(CSS px/s)。 */
@@ -402,6 +404,11 @@ onBeforeUnmount(() => {
>
</a>
</div>
<!-- 顶部相册标题(用户决策 2026-09-23):叠加在照片上(不占独立高度,
照片窗仍是全屏照片带),左侧 12px、避让右侧 44px 收起区;渐变底 +
text-shadow 保证可读;空标题(未解析)不渲染。 -->
<p v-if="albumTitle !== ''" class="stream-title" aria-hidden="true">{{ albumTitle }}</p>
<!-- StateOverlay(§2.2):空相册紧凑空态(收起入口保持可用)。 -->
<div v-if="photos.length === 0" class="stream-empty" role="note">
{{ 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 {
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+118 -1
View File
@@ -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;