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
+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 {