feat(ov,viewer): two-tier download with wechat guidance

P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。

- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
  /download?variant=browse|original。两级开关(A18):browse 需
  downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
  则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
  key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
  现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
  (DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
  计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
  共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
  originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
  凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
  浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
  限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
  复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
  签名 URL 模式)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 18:32:34 +08:00
parent 243b2f8721
commit 2bb6f291d3
25 changed files with 1622 additions and 41 deletions
+283
View File
@@ -0,0 +1,283 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getPhotoDownload,
getPublicPhotos,
type PublicAlbum,
type PublicPhoto,
type PublicPhotoDownload,
type PublicPhotoList,
} from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence } from '../feed/sequence';
import {
isExpired,
isWechatWebview,
useDownload,
type DownloadController,
} from '../download/useDownload';
import { makeAlbum } from './fixtures';
// P4a Task 4 下载(T12 收口):开关→入口可见性矩阵、分体菜单选项、微信 UA
// 判定与引导浮层、过期凭证重取一次、失败提示、复制深链。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
...actual,
getPublicPhotos: vi.fn(),
getPublicPhoto: vi.fn(),
getPhotoDownload: vi.fn(),
};
});
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPhotoDownload = vi.mocked(getPhotoDownload);
const WECHAT_IOS_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.49(0x18003133) NetType/WIFI Language/zh_CN';
const WECHAT_ANDROID_UA =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/122.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49.2500(0x28004935)';
const WECHAT_WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x670c0123) WindowsWechat(0x63090a13)';
const PLAIN_IOS_SAFARI_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const DESKTOP_CHROME_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
function makePhoto(id: string): PublicPhoto {
return {
photoId: id,
width: 800,
height: 500,
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
likeCount: 0,
publishedAt: '2026-10-01T00:00:00.000Z',
};
}
function makeList(items: PublicPhoto[]): PublicPhotoList {
return { items, nextCursor: null, hasMore: false };
}
function credential(url: string, expiresAt: string, variant: 'browse' | 'original'): PublicPhotoDownload {
return { url, expiresAt, variant };
}
const FUTURE = new Date(Date.now() + 14 * 60_000).toISOString();
const PAST = new Date(Date.now() - 60_000).toISOString();
function stubUserAgent(ua: string): void {
Object.defineProperty(window.navigator, 'userAgent', { value: ua, configurable: true });
}
function restoreUserAgent(): void {
// 删除实例自有属性,回落原型 getter(jsdom 原始 UA)。
delete (window.navigator as { userAgent?: string }).userAgent;
}
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
function spyWindowOpen() {
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
}
let openSpy: ReturnType<typeof spyWindowOpen>;
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */
interface HostVm {
albumRef: PublicAlbum | null;
download: DownloadController;
}
/** 宿主:sequence + 相册 ref + 下载控制器 + lightbox(镜像 ActivityPage 组装)。 */
function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
const Host = defineComponent({
components: { PhotoLightbox },
setup() {
const sequence = usePhotoSequence({
token: ref('tok-dl'),
locale: ref('zh-CN'),
category: ref(null),
});
const albumRef = ref<PublicAlbum | null>(album);
const current = ref('p1');
const download = useDownload({
token: ref('tok-dl'),
album: () => albumRef.value,
photoId: () => current.value,
});
return { sequence, photoId: current, download, albumRef };
},
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
});
const wrapper = mount(Host, { global: { plugins: [i18n] } });
mounted.push(wrapper);
return wrapper as VueWrapper & { vm: HostVm };
}
describe('photo download (P4a Task 4)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')]));
mockedGetPhotoDownload.mockReset();
openSpy = spyWindowOpen();
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
restoreUserAgent();
vi.restoreAllMocks();
});
it('detects WeChat webview UAs (iOS/Android/Windows) and passes others', () => {
for (const ua of [WECHAT_IOS_UA, WECHAT_ANDROID_UA, WECHAT_WINDOWS_UA]) {
expect(isWechatWebview(ua)).toBe(true);
}
for (const ua of [PLAIN_IOS_SAFARI_UA, DESKTOP_CHROME_UA, '']) {
expect(isWechatWebview(ua)).toBe(false);
}
});
it('treats expiresAt at-or-before now as expired', () => {
expect(isExpired(PAST)).toBe(true);
expect(isExpired(new Date().toISOString())).toBe(true);
expect(isExpired(FUTURE)).toBe(false);
expect(isExpired('not-a-date')).toBe(false); // 非法时间戳不误判过期
});
it('controller model: switch matrix incl. off/on cascade (original implies browse)', async () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
const controller = wrapper.vm.download;
// on/off(夹具缺省)。
expect(controller.canDownload).toBe(true);
expect(controller.canDownloadOriginal).toBe(false);
const cases: Array<[boolean, boolean, boolean, boolean]> = [
// [downloadEnabled, originalDownloadEnabled, canDownload, canDownloadOriginal]
[true, true, true, true],
[true, false, true, false],
[false, false, false, false],
[false, true, false, false], // 级联:browse 关则 original 项不出现
];
for (const [downloadEnabled, originalDownloadEnabled, can, canOriginal] of cases) {
wrapper.vm.albumRef = makeAlbum({ downloadEnabled, originalDownloadEnabled });
await flushPromises();
expect(controller.canDownload).toBe(can);
expect(controller.canDownloadOriginal).toBe(canOriginal);
}
});
it('toolbar visibility: off/on → no entry; on/off → single button; on/on → split', async () => {
// off/off:无下载入口。
const off = mountLightbox(makeAlbum({ downloadEnabled: false, originalDownloadEnabled: false }));
await flushPromises();
expect(off.find('.download-group').exists()).toBe(false);
// on/off:单按钮,无 caret/菜单。
const browseOnly = mountLightbox(makeAlbum());
await flushPromises();
expect(browseOnly.find('.control.download').exists()).toBe(true);
expect(browseOnly.find('.control.download-caret').exists()).toBe(false);
// on/on:分体按钮(caret 出现,菜单默认收起)。
const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
expect(split.find('.control.download-caret').exists()).toBe(true);
expect(split.find('.download-menu').exists()).toBe(false);
// 主按钮 aria 展开 = false。
expect(split.find('.control.download-caret').attributes('aria-expanded')).toBe('false');
});
it('split menu offers both variants; picking original requests the original endpoint', async () => {
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-original.jpg', FUTURE, 'original'));
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
await wrapper.find('.control.download-caret').trigger('click');
const items = wrapper.findAll('.download-menu button');
expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']);
await items[1]!.trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'original');
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-original.jpg', '_blank', 'noopener,noreferrer');
// 选择后菜单收起。
expect(wrapper.find('.download-menu').exists()).toBe(false);
});
it('main button downloads browse; expired credential is re-requested exactly once', async () => {
// 首响应已过期(缓存/时钟漂移)→ 重取一次 → 打开第二个 URL。
mockedGetPhotoDownload
.mockResolvedValueOnce(credential('http://img.test/p1-browse-stale.jpg', PAST, 'browse'))
.mockResolvedValueOnce(credential('http://img.test/p1-browse-fresh.jpg', FUTURE, 'browse'));
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse-fresh.jpg', '_blank', 'noopener,noreferrer');
});
it('fresh credential opens directly without a second request; failure shows a retryable notice', async () => {
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-browse.jpg', FUTURE, 'browse'));
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer');
expect(wrapper.find('.download-failed').exists()).toBe(false);
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
openSpy.mockClear();
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
await wrapper.find('.control.download').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
expect(openSpy).not.toHaveBeenCalled();
});
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
stubUserAgent(WECHAT_IOS_UA);
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await flushPromises();
expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true);
expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引');
expect(mockedGetPhotoDownload).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
// 知道了:浮层收起(再点击可重新触发)。
await wrapper.find('.guide-dismiss').trigger('click');
expect(wrapper.find('.wechat-guide').exists()).toBe(false);
});
it('guidance copy link writes the current photo deep link with feedback', async () => {
stubUserAgent(WECHAT_IOS_UA);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true });
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.guide-copy').trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith(window.location.href);
expect(wrapper.find('.copy-status').text()).toBe('已复制链接');
});
});
+3
View File
@@ -27,6 +27,9 @@ export function makeAlbum(
],
banner: null,
viewCount: null,
// P4a Task 4:下载两级开关(契约 PublicAlbum 必返字段;缺省 on/off)。
downloadEnabled: true,
originalDownloadEnabled: false,
...rest,
};
}
+17
View File
@@ -12,6 +12,10 @@ export type LikeBatchResponse = components['schemas']['LikeBatchResponse'];
export type VisitReportRequest = components['schemas']['VisitReportRequest'];
export type VisitReportResponse = components['schemas']['VisitReportResponse'];
// P4a Task 4 下载端点类型(downloadPublicPhoto,ov 域;A18 两级开关)。
export type PublicPhotoDownload = components['schemas']['PublicPhotoDownload'];
export type DownloadVariant = components['schemas']['DownloadVariant'];
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
export type SortMode = 'latest' | 'hot';
@@ -146,3 +150,16 @@ export function postAlbumVisit(token: string, body: VisitReportRequest): Promise
body: JSON.stringify(body),
});
}
/** 公开照片下载凭证(P4a Task 4):短期签名 URL,过期经本端点刷新(重取一次)。 */
export function getPhotoDownload(
token: string,
photoId: string,
variant: DownloadVariant,
): Promise<PublicPhotoDownload> {
return apiRequest<PublicPhotoDownload>(
withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}/download`, {
variant,
}),
);
}
+219 -1
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
@@ -20,6 +21,11 @@ import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
//
// 下载(P4a Task 4):工具栏入口按相册两级开关渲染(控制器 canDownload /
// canDownloadOriginal;download prop 缺省不渲染——组件单测/降级组装)。原图
// 可下时为分体按钮(主按钮=浏览图 + ▾ 菜单两选项);微信内置浏览器由控制器
// 判定并改走引导浮层(长按保存 + 复制深链)。
//
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
const props = defineProps<{
@@ -27,6 +33,8 @@ const props = defineProps<{
photoId: string;
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController;
}>();
const emit = defineEmits<{
@@ -263,6 +271,29 @@ function likeCurrent(): void {
props.likes.like(photo.value.photoId);
}
// —— 下载(P4a Task 4):菜单开合随照片切换/关闭复位 ——
const downloadMenuOpen = ref(false);
watch(
() => props.photoId,
() => {
downloadMenuOpen.value = false;
},
);
/** 菜单选项点击:收起菜单后发起对应 variant 下载。 */
function pickDownload(variant: 'browse' | 'original'): void {
downloadMenuOpen.value = false;
void props.download?.download(variant);
}
/** 主按钮:无原图位时直下浏览图;有原图位时也直下浏览图(菜单提供两项)。 */
function downloadBrowse(): void {
downloadMenuOpen.value = false;
void props.download?.download('browse');
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
@@ -348,7 +379,40 @@ onBeforeUnmount(() => {
<span class="like-count">{{ likes.displayFor(photo) }}</span>
</button>
<span v-else class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<!-- P4a 预留:下载 / 分享操作位(P1b 不实现)。 -->
<!-- 下载(P4a Task 4):两级开关驱动——都关无入口;browse 单按钮;
original 可下 → 分体按钮(主=浏览图 + ▾ 菜单两项)。 -->
<div
v-if="download !== undefined && download.canDownload"
class="download-group"
>
<button
type="button"
class="control download"
:disabled="download.busy"
:aria-label="t('download.action')"
@click="downloadBrowse"
>
{{ download.busy ? t('download.busy') : t('download.action') }}
</button>
<button
v-if="download.canDownloadOriginal"
type="button"
class="control download-caret"
:aria-label="t('download.options')"
:aria-expanded="downloadMenuOpen"
@click="downloadMenuOpen = !downloadMenuOpen"
>
▾
</button>
<div v-if="downloadMenuOpen && download.canDownloadOriginal" class="download-menu" role="menu">
<button type="button" role="menuitem" @click="pickDownload('browse')">
{{ t('download.action') }}
</button>
<button type="button" role="menuitem" @click="pickDownload('original')">
{{ t('download.original') }}
</button>
</div>
</div>
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
✕
</button>
@@ -374,6 +438,41 @@ onBeforeUnmount(() => {
</button>
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 下载失败轻提示(端点失败/网络;可重点击重试)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t('download.failed') }}
</p>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
<div
v-if="download !== undefined && download.wechatGuide"
class="wechat-guide"
role="dialog"
aria-modal="true"
:aria-label="t('download.wechatTitle')"
>
<div class="wechat-card">
<h2>{{ t('download.wechatTitle') }}</h2>
<p>{{ t('download.wechatHint') }}</p>
<!-- clipboard 不可用降级:展示可选中文本供手动长按复制。 -->
<p v-if="download.copyNotice === 'failed'" class="copy-fallback">{{ download.shareUrl() }}</p>
<div class="guide-actions">
<button type="button" class="guide-copy" @click="download.copyShareLink()">
{{ t('download.copyLink') }}
</button>
<span v-if="download.copyNotice === 'copied'" class="copy-status" role="status">
{{ t('download.copied') }}
</span>
<span v-else-if="download.copyNotice === 'failed'" class="copy-status" role="status">
{{ t('download.copyFailed') }}
</span>
<button type="button" class="guide-dismiss" @click="download.dismissWechatGuide()">
{{ t('download.dismiss') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -485,6 +584,125 @@ onBeforeUnmount(() => {
border-radius: 6px;
padding: 4px 10px;
}
/* —— 下载(P4a Task 4)—— */
.download-group {
position: relative;
display: inline-flex;
align-items: center;
}
.control.download {
border: 1px solid rgba(249, 250, 251, 0.35);
border-radius: 999px 0 0 999px;
padding: 4px 12px;
font-size: 0.9rem;
}
.control.download:disabled {
opacity: 0.55;
cursor: default;
}
.control.download-caret {
border: 1px solid rgba(249, 250, 251, 0.35);
border-left: none;
border-radius: 0 999px 999px 0;
padding: 4px 8px;
font-size: 0.8rem;
}
.download-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
display: grid;
min-width: 9em;
border-radius: 8px;
background: rgba(17, 24, 39, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
overflow: hidden;
z-index: 2;
}
.download-menu button {
border: none;
background: transparent;
color: #f9fafb;
text-align: left;
padding: 8px 14px;
font-size: 0.9rem;
cursor: pointer;
}
.download-menu button:hover {
background: rgba(255, 255, 255, 0.08);
}
.download-failed {
position: absolute;
bottom: 52px;
left: 50%;
transform: translateX(-50%);
margin: 0;
border-radius: 8px;
background: rgba(185, 28, 28, 0.92);
color: #fef2f2;
padding: 6px 14px;
font-size: 0.875rem;
}
/* —— 微信引导浮层 —— */
.wechat-guide {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.72);
padding: 24px;
}
.wechat-card {
max-width: 420px;
border-radius: 12px;
background: #f9fafb;
color: #111827;
padding: 20px 24px;
}
.wechat-card h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.wechat-card p {
margin: 0 0 12px;
font-size: 0.9rem;
line-height: 1.6;
white-space: pre-line;
}
.copy-fallback {
user-select: all;
word-break: break-all;
background: #e5e7eb;
border-radius: 6px;
padding: 6px 8px;
}
.guide-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.guide-actions button {
border: none;
border-radius: 999px;
padding: 6px 16px;
font-size: 0.9rem;
cursor: pointer;
}
.guide-copy {
background: #1d4ed8;
color: #fff;
}
.guide-dismiss {
background: #e5e7eb;
color: #374151;
}
.copy-status {
font-size: 0.8rem;
color: #6b7280;
}
.control.nav {
position: absolute;
top: 50%;
+147
View File
@@ -0,0 +1,147 @@
// apps/viewer/src/download/useDownload.ts —— 大图下载状态机(P4a Task 4,
// T12 收口)。两级开关(A18)由相册载荷驱动:
// · downloadEnabled=false → 工具栏无下载入口(后端同样 403,前端不渲染);
// · browse-only → 单按钮「下载图片」;
// · originalDownloadEnabled(且 downloadEnabled)→ 分体按钮 + 菜单两项
// (下载图片 / 下载原图——original 是更高级开关,服务端级联校验)。
//
// 下载流:点击 → GET download 端点取短期签名凭证 → window.open 新标签直取
// 对象存储(跨源 fetch+blob 会撞 MinIO presign 的 CORS 面,新标签是本地仿真
// 与生产同走的稳健路径;`<a download>` 对跨源 URL 的文件名属性被浏览器忽略,
// 不采用)。过期重取一次:响应 expiresAt 已过期(缓存/时钟漂移)→ 再请求
// 本端点一次(契约指定的刷新入口),之后无论新旧都打开(服务端时钟偏移的
// 极端情形交由用户重点击)。
//
// 微信内置浏览器(MicroMessenger UA):不支持程序化下载/新标签导航——以
// 引导浮层替代直接下载:长按图片保存指引 + 复制当前照片深链(外部浏览器
// 打开后可正常下载)。clipboard 不可用时降级展示可选中文本供手动长按复制。
import { computed, onBeforeUnmount, reactive, ref, type Ref } from 'vue';
import {
getPhotoDownload,
type DownloadVariant,
type PublicAlbum,
} from '../api/client';
export interface DownloadInputs {
token: Ref<string>;
/** 当前相册载荷(开关来源;未加载 = 视为关闭)。 */
album: () => PublicAlbum | null;
/** 当前大图照片(无当前照片时下载为 no-op)。 */
photoId: () => string | null;
}
/** 微信内置浏览器判定(iOS/Android/桌面微信 UA 均含 MicroMessenger)。 */
export function isWechatWebview(userAgent: string): boolean {
return /MicroMessenger/i.test(userAgent);
}
/** 凭证是否已过期(时钟漂移/缓存响应的防线;≤ now 视为过期)。 */
export function isExpired(expiresAt: string, now: number = Date.now()): boolean {
const timestamp = Date.parse(expiresAt);
return Number.isFinite(timestamp) && timestamp <= now;
}
/**
* 打开下载 URL:新标签直达对象存储。弹窗被拦(window.open 返回 null)→
* 合成 `<a target=_blank>` 点击兜底(仍在用户手势帧内,多数浏览器放行);
* 导航结果不可观测,best-effort。
*/
export function openDownloadUrl(url: string): void {
const opened = window.open(url, '_blank', 'noopener,noreferrer');
if (opened !== null) return;
const anchor = document.createElement('a');
anchor.href = url;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
/** 复制反馈停留毫秒(之后自动消失)。 */
export const COPY_NOTICE_MS = 2500;
export function useDownload(inputs: DownloadInputs) {
const busy = ref(false);
const failed = ref(false);
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
const wechatGuide = ref(false);
const copyNotice = ref<'' | 'copied' | 'failed'>('');
/** 入口可见性:browse 级开关(相册未加载 = 关闭,不渲染入口)。 */
const canDownload = computed(() => inputs.album()?.downloadEnabled === true);
/** 原图项可见性:两级级联(downloadEnabled && originalDownloadEnabled)。 */
const canDownloadOriginal = computed(
() =>
inputs.album()?.downloadEnabled === true &&
inputs.album()?.originalDownloadEnabled === true,
);
/** 当前照片深链(引导浮层复制目标;大图打开期间即当前 URL)。 */
function shareUrl(): string {
return window.location.href;
}
async function download(variant: DownloadVariant): Promise<void> {
if (isWechatWebview(navigator.userAgent)) {
wechatGuide.value = true;
return;
}
const photoId = inputs.photoId();
if (photoId === null || busy.value) return;
busy.value = true;
failed.value = false;
try {
let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
if (isExpired(credential.expiresAt)) {
// 过期凭证:重取一次(契约刷新入口即本端点),仍过期则照开(极端
// 时钟偏移交用户重点击——不做无限重取)。
credential = await getPhotoDownload(inputs.token.value, photoId, variant);
}
openDownloadUrl(credential.url);
} catch {
failed.value = true;
} finally {
busy.value = false;
}
}
function dismissWechatGuide(): void {
wechatGuide.value = false;
}
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function copyShareLink(): Promise<void> {
try {
await navigator.clipboard.writeText(shareUrl());
copyNotice.value = 'copied';
} catch {
copyNotice.value = 'failed';
}
if (copyTimer !== undefined) clearTimeout(copyTimer);
copyTimer = setTimeout(() => {
copyNotice.value = '';
}, COPY_NOTICE_MS);
}
onBeforeUnmount(() => {
if (copyTimer !== undefined) clearTimeout(copyTimer);
});
// reactive 包装解包 ref/computed:模板与调用方直接读布尔(同 useLikes 约定)。
return reactive({
busy,
failed,
wechatGuide,
copyNotice,
canDownload,
canDownloadOriginal,
shareUrl,
download,
dismissWechatGuide,
copyShareLink,
});
}
export type DownloadController = ReturnType<typeof useDownload>;
+14
View File
@@ -19,6 +19,20 @@ export default {
},
lang: { switch: 'Switch language' },
prefs: { reduceMotion: 'Reduce motion' },
download: {
action: 'Download photo',
original: 'Download original',
options: 'Download options',
busy: 'Preparing…',
failed: 'Download failed, please try again',
// WeChat in-app browser guidance (no programmatic download): long-press save + copy link.
wechatTitle: 'How to save this photo',
wechatHint: 'Pages opened inside WeChat cannot download directly. Long-press the photo above to save it, or copy the link and open it in a browser to download.',
copyLink: 'Copy link',
copied: 'Link copied',
copyFailed: 'Copy failed — long-press to select the link below',
dismiss: 'Got it',
},
viewer: {
title: 'Photo viewer',
close: 'Close',
+14
View File
@@ -19,6 +19,20 @@ export default {
},
lang: { switch: '切换语言' },
prefs: { reduceMotion: '减少动画' },
download: {
action: '下载图片',
original: '下载原图',
options: '下载选项',
busy: '准备中…',
failed: '下载失败,请重试',
// 微信内置浏览器引导(不支持程序化下载):长按保存 + 复制链接离场。
wechatTitle: '保存图片指引',
wechatHint: '微信内打开的页面不支持直接下载。请长按上方图片保存到手机,或复制链接后在浏览器中打开再下载。',
copyLink: '复制链接',
copied: '已复制链接',
copyFailed: '复制失败,请长按选择下方链接复制',
dismiss: '知道了',
},
viewer: {
title: '照片查看',
close: '关闭',
+9
View File
@@ -12,6 +12,7 @@ import {
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { useDownload } from '../download/useDownload';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
import PhotoLightbox from '../components/PhotoLightbox.vue';
@@ -222,6 +223,13 @@ const currentPhoto = computed(() => {
return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null;
});
/** 下载控制器(P4a Task 4):开关来自相册载荷,当前照片随大图键控。 */
const download = useDownload({
token,
album: () => album.value,
photoId: () => lightboxPhotoId.value,
});
function showRemovedNotice(): void {
photoRemovedNotice.value = true;
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
@@ -444,6 +452,7 @@ onBeforeUnmount(() => {
v-model:photo-id="lightboxPhotoId"
:sequence="sequence"
:likes="likes"
:download="download"
@removed="onPhotoRemoved"
@close="closeLightbox"
/>