feat(viewer): UI-02 dark immersive photo viewer with thumbnail strip

V04 (方案 §5):PhotoLightbox 模板重构为深色沉浸布局——顶栏(关闭/位置
计数/⋯ 减少动画菜单)+ 舞台(contain 大图 + 边缘 ‹› + 粒子/互动占位层)+
缩略图条 + 底部操作栏;业务逻辑(序列导航/缩放手势/签名刷新/下载/点赞)
原样保留。

- 新增 viewer/position.ts:§5.2 位置计数真实回退(solo→照片预览;无
  total 未到末页→已加载 N 张;全部加载→i/n;不伪造总数)
- 新增 ThumbnailStrip:当前 ±3 窗口(≤7 项)、主色 2px 描边、thumb 变体、
  滚入可见(遵守减少动画)、点击选择不新增历史
- 新增 PhotoActionBar:Like(coral 心形+计数+连续点赞提示)/Share/
  Download 均分大触控;下载关闭整项移除;原图二级菜单(▾ + 长按)
- 模态可用性:body 滚动锁、焦点进入/困住/归还触发元素、Esc 先收菜单、
  菜单内左右键不抢占、dvh + 安全区、chrome 区手势/滚轮隔离
- 分享 V04 最小实现(复制当前深链,support/clipboard 从 AlbumHeader
  提取共用);navigator.share 编排属 Task 5
- zh-CN/en 新词条;vitest 新增 23 例(位置回退/缩略图/操作栏/焦点/滚锁)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 21:58:09 +08:00
parent 3d89fcdb99
commit 89f45896b7
14 changed files with 1536 additions and 340 deletions
+12 -12
View File
@@ -187,16 +187,16 @@ describe('photo download (P4a Task 4)', () => {
// 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);
expect(browseOnly.find('.download-main').exists()).toBe(true);
expect(browseOnly.find('.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-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');
expect(split.find('.download-caret').attributes('aria-expanded')).toBe('false');
});
it('split menu offers both variants; picking original requests the original endpoint', async () => {
@@ -204,9 +204,9 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
await wrapper.find('.control.download-caret').trigger('click');
await wrapper.find('.download-caret').trigger('click');
const items = wrapper.findAll('.download-menu button');
expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']);
expect(items.map((item) => item.text())).toEqual(['浏览图', '原图']);
await items[1]!.trigger('click');
await flushPromises();
@@ -224,7 +224,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
@@ -236,7 +236,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer');
@@ -245,7 +245,7 @@ describe('photo download (P4a Task 4)', () => {
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
openSpy.mockClear();
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
expect(openSpy).not.toHaveBeenCalled();
@@ -259,7 +259,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载');
expect(wrapper.find('.download-failed').text()).not.toContain('下载失败');
@@ -270,7 +270,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true);
expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引');
@@ -289,7 +289,7 @@ describe('photo download (P4a Task 4)', () => {
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.control.download').trigger('click');
await wrapper.find('.download-main').trigger('click');
await wrapper.find('.guide-copy').trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith(window.location.href);
+166 -15
View File
@@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,8 +15,10 @@ import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击
// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。
vi.mock('../api/client', async (importOriginal) => {
@@ -44,8 +49,9 @@ let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
/** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 */
function mountLightbox(photoId: string): VueWrapper {
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。
* solo:深链兜底条目(ensureInSequence 追加在序列尾部,自然位置未知)。 */
function mountLightbox(photoId: string, options: { solo?: PublicPhoto } = {}): VueWrapper {
const Host = defineComponent({
components: { PhotoLightbox },
setup() {
@@ -54,6 +60,7 @@ function mountLightbox(photoId: string): VueWrapper {
locale: ref('zh-CN'),
category: ref(null),
});
if (options.solo !== undefined) sequence.ensureInSequence(options.solo);
const current = ref(photoId);
return { sequence, photoId: current };
},
@@ -95,7 +102,7 @@ function keydown(key: string): void {
/** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */
function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void {
wrapper.find('.lightbox').element.dispatchEvent(
wrapper.find('.photo-viewer').element.dispatchEvent(
new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }),
);
}
@@ -115,7 +122,7 @@ describe('photo lightbox (Task 8)', () => {
vi.restoreAllMocks();
});
it('renders the current browse url with counter and like count', async () => {
it('renders the current browse url with position indicator and like count', async () => {
mockedGetPublicPhotos.mockResolvedValue(
makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]),
);
@@ -123,8 +130,11 @@ describe('photo lightbox (Task 8)', () => {
await flushPromises();
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
expect(wrapper.find('.counter').text()).toBe('2 / 3');
expect(wrapper.find('.likes').text()).toBe('7 次点赞');
// 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
// 底部操作栏(V04):Like 计数 + Like/Share 标签(无下载控制器 → 下载位省略)。
expect(wrapper.find('.like-count').text()).toBe('7');
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
});
it('navigates with buttons and arrow keys, keeping the list order', async () => {
@@ -150,17 +160,18 @@ describe('photo lightbox (Task 8)', () => {
expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined();
});
it('closes on Esc, close button and backdrop click', async () => {
it('closes on Esc, close button and empty-stage click', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
keydown('Escape');
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
await wrapper.find('.control.close').trigger('click');
await wrapper.find('.viewer-close').trigger('click');
expect(lightbox(wrapper).emitted('close')).toHaveLength(2);
await wrapper.find('.backdrop').trigger('click');
// 舞台空白区(目标=容器自身,非 <img>)点击关闭。
await wrapper.find('.image-container').trigger('click');
expect(lightbox(wrapper).emitted('close')).toHaveLength(3);
});
@@ -198,10 +209,10 @@ describe('photo lightbox (Task 8)', () => {
expect(img(wrapper).attributes('style')).toContain('scale(1)');
});
it('swipe navigates when flat; drag on backdrop does not close', async () => {
it('swipe navigates when flat; drag on stage does not close', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
const root = wrapper.find('.lightbox');
const root = wrapper.find('.photo-viewer');
// 左滑 80px → 下一张。
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
@@ -210,11 +221,11 @@ describe('photo lightbox (Task 8)', () => {
await flushPromises();
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
// 拖拽距离超过阈值后收尾在背景上:click 不关闭。
// 拖拽距离超过阈值后收尾在舞台上:click 不关闭。
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 }));
root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 }));
await wrapper.find('.backdrop').trigger('click');
await wrapper.find('.image-container').trigger('click');
expect(lightbox(wrapper).emitted('close')).toBeUndefined();
});
@@ -297,3 +308,143 @@ describe('photo lightbox (Task 8)', () => {
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3');
});
});
describe('photo lightbox dark viewer (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
mockedGetPublicPhoto.mockReset();
document.body.style.overflow = '';
});
afterEach(() => {
while (mounted.length > 0) {
mounted.pop()?.unmount();
}
vi.restoreAllMocks();
document.body.style.overflow = '';
document.documentElement.className = '';
});
it('shows the loaded-count fallback while more pages remain (no fake total)', async () => {
let resolvePage!: (value: PublicPhotoList) => void;
mockedGetPublicPhotos.mockReset();
mockedGetPublicPhotos
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'))
.mockReturnValueOnce(
new Promise<PublicPhotoList>((resolve) => {
resolvePage = resolve;
}),
);
const wrapper = mountLightbox('p2');
await flushPromises();
// 未到末页且接口无 total → 「已加载 N 张」(§5.2),不显示 i/n。
expect(wrapper.find('.position-indicator').text()).toBe('已加载 2 张');
resolvePage(makeList([makePhoto('p3')], null));
await flushPromises();
// 全部加载完成 → 当前筛选序列 i / n。
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
});
it('shows photo preview for an unpositioned solo deep-link entry', async () => {
const wrapper = mountLightbox('solo-9', { solo: makePhoto('solo-9') });
await flushPromises();
// solo 自然位置未知(首页加载后仍追加在尾部)→ 「照片预览」,不伪造排名。
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
});
it('renders the thumbnail strip window and selects without extra history entries', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
// 3 张全部落在 ±3 窗口内;当前项描边 + aria-current;点击只上抛切换。
const thumbs = wrapper.findAll('.thumb');
expect(thumbs.map((thumb) => thumb.find('img').attributes('src'))).toEqual([
'http://img.test/p1-thumb.jpg?s=1',
'http://img.test/p2-thumb.jpg?s=1',
'http://img.test/p3-thumb.jpg?s=1',
]);
expect(thumbs[1].classes()).toContain('current');
expect(thumbs[1].attributes('aria-current')).toBe('true');
await thumbs[0].trigger('click');
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p1']]);
});
it('more menu exposes the reduce-motion toggle; Escape closes the menu before the viewer', async () => {
const wrapper = mountLightbox('p2');
await flushPromises();
await wrapper.find('.viewer-more').trigger('click');
expect(wrapper.find('.more-popover').exists()).toBe(true);
const item = wrapper.find('.more-item');
expect(item.text()).toContain('减少动画');
expect(item.attributes('aria-checked')).toBe('false');
await item.trigger('click');
expect(wrapper.find('.more-item').attributes('aria-checked')).toBe('true');
// 还原模块级偏好单例,避免影响后续用例。
await wrapper.find('.more-item').trigger('click');
keydown('Escape');
await flushPromises();
expect(wrapper.find('.more-popover').exists()).toBe(false); // 先收菜单
expect(lightbox(wrapper).emitted('close')).toBeUndefined(); // 不关查看器
keydown('Escape');
await flushPromises();
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
});
it('moves focus into the dialog on open and returns it to the trigger on close', async () => {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
const wrapper = mountLightbox('p2');
// jsdom 下脱离文档的元素 focus() 不改 activeElement:赶在调度器微任务
// (rootEl watcher 调 focus)之前同步挂进文档。
document.body.appendChild(wrapper.element);
await flushPromises();
const root = wrapper.find('.photo-viewer');
expect(root.attributes('role')).toBe('dialog');
expect(root.attributes('aria-modal')).toBe('true');
expect(root.attributes('tabindex')).toBe('-1');
expect(root.attributes('aria-label')).toBe('照片查看器');
expect(document.activeElement).toBe(root.element);
// 手动卸载(绕过 mounted 清单)验证焦点归还触发元素。
const index = mounted.indexOf(wrapper);
if (index >= 0) mounted.splice(index, 1);
wrapper.unmount();
expect(document.activeElement).toBe(trigger);
trigger.remove();
});
it('locks body scroll while open and restores the previous value on close', async () => {
document.body.style.overflow = 'auto';
const wrapper = mountLightbox('p2');
await flushPromises();
expect(document.body.style.overflow).toBe('hidden');
const index = mounted.indexOf(wrapper);
if (index >= 0) mounted.splice(index, 1);
wrapper.unmount();
expect(document.body.style.overflow).toBe('auto');
});
it('applies dvh viewport height and safe-area insets for the immersive layout', () => {
// vitest 不加载 SFC 样式:源级断言(与 viewer-shell 的 token 源级断言同法)。
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'),
'utf8',
);
expect(source).toContain('100dvh');
expect(source).toContain('env(safe-area-inset-top, 0px)');
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
});
});
@@ -0,0 +1,117 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PhotoActionBar from '../components/viewer/PhotoActionBar.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
// UI-02 §5.1/§5.4 底部操作栏单测:Like/Share/Download 三键与标签、计数
// Intl 格式化、下载关闭时移除整项(剩余均分)、原图二级菜单(▾ 与长按)、
// 事件上抛(like/share/download(variant))。
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountBar(props: {
likeCount: number;
canDownload?: boolean;
canDownloadOriginal?: boolean;
isLiking?: boolean;
}): VueWrapper {
const wrapper = mount(PhotoActionBar, {
props: {
canDownload: false,
canDownloadOriginal: false,
isLiking: false,
...props,
},
global: { plugins: [i18n] },
});
mounted.push(wrapper);
return wrapper;
}
describe('photo action bar (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('renders like/share/download as evenly distributed slots with formatted count and hint', () => {
const wrapper = mountBar({ likeCount: 1286, canDownload: true });
// 三个 action-slot flex 均分(下载隐藏时由剩余 slot 自然重排,见下例)。
expect(wrapper.findAll('.action-slot')).toHaveLength(3);
const labels = wrapper.findAll('.action-label').map((label) => label.text());
expect(labels).toEqual(['点赞', '分享', '下载']);
// 计数 Intl 分组 + tabular-nums 容器;Like 下方次级提示(§5.1)。
expect(wrapper.find('.like-count').text()).toBe('1,286');
expect(wrapper.find('.like-hint').text()).toBe('连续点击,为照片点赞');
});
it('removes the download action entirely when downloads are disabled', () => {
const wrapper = mountBar({ likeCount: 0, canDownload: false });
expect(wrapper.find('.download-group').exists()).toBe(false);
expect(wrapper.findAll('.action-slot')).toHaveLength(2); // 剩余两键均分栏宽
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
});
it('emits like and share from the large tap targets', async () => {
const wrapper = mountBar({ likeCount: 3 });
const buttons = wrapper.findAll('.action-btn');
expect(buttons).toHaveLength(2);
await buttons[0].trigger('click');
await buttons[1].trigger('click');
expect(wrapper.emitted('like')).toHaveLength(1);
expect(wrapper.emitted('share')).toHaveLength(1);
expect(wrapper.emitted('download')).toBeUndefined();
});
it('downloads the browse variant directly when original download is disabled', async () => {
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: false });
expect(wrapper.find('.download-caret').exists()).toBe(false);
await wrapper.find('.download-group .action-btn').trigger('click');
expect(wrapper.emitted('download')).toEqual([['browse']]);
});
it('offers browse/original choices via the caret menu', async () => {
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
const caret = wrapper.find('.download-caret');
expect(caret.attributes('aria-haspopup')).toBe('menu');
await caret.trigger('click');
const menu = wrapper.find('.download-menu');
expect(menu.attributes('role')).toBe('menu');
expect(menu.text()).toContain('浏览图');
expect(menu.text()).toContain('原图');
const items = menu.findAll('button');
await items[1].trigger('click');
expect(wrapper.emitted('download')).toEqual([['original']]);
});
it('long-pressing the download button opens the variant menu instead of firing browse', async () => {
vi.useFakeTimers();
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
const button = wrapper.find('.download-group .action-btn');
await button.trigger('pointerdown');
vi.advanceTimersByTime(500);
await nextTick(); // 定时器改状态后等一帧再查 DOM
expect(wrapper.find('.download-menu').exists()).toBe(true);
// 长按已展开菜单:紧随的 click 被吞掉,不触发默认浏览图下载。
await button.trigger('click');
expect(wrapper.emitted('download')).toBeUndefined();
await wrapper.find('.download-menu button').trigger('click');
expect(wrapper.emitted('download')).toEqual([['browse']]);
});
});
@@ -0,0 +1,115 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ThumbnailStrip, { type ThumbnailItem } from '../components/viewer/ThumbnailStrip.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import { reducedMotion } from '../ui/prefs';
// UI-02 §5.3 缩略图条单测:±3 窗口(最多 7 项,首尾自然收缩)、当前项描边与
// aria-current、点击上抛 select(无浏览器历史副作用)、选中项滚入可见范围
// (遵守减少动画)、无溢出不渲染渐隐提示。
function makeItems(count: number): ThumbnailItem[] {
return Array.from({ length: count }, (_, i) => ({
photoId: `p${i + 1}`,
thumbUrl: `http://img.test/p${i + 1}-thumb.jpg`,
}));
}
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountStrip(photos: ThumbnailItem[], currentPhotoId: string | null): VueWrapper {
const wrapper = mount(ThumbnailStrip, {
props: { photos, currentPhotoId },
global: { plugins: [i18n] },
});
mounted.push(wrapper);
return wrapper;
}
function thumbIds(wrapper: VueWrapper): (string | undefined)[] {
return wrapper.findAll('.thumb').map((thumb) => thumb.find('img').attributes('src'));
}
describe('thumbnail strip (UI-02 V04)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
reducedMotion.value = false;
vi.restoreAllMocks();
});
it('renders the current ±3 window (max 7 items) centered on the current photo', () => {
const wrapper = mountStrip(makeItems(12), 'p6');
// p6 的 ±3 → p3..p9 共 7 项;thumb 变体 URL。
expect(thumbIds(wrapper)).toEqual([
'http://img.test/p3-thumb.jpg',
'http://img.test/p4-thumb.jpg',
'http://img.test/p5-thumb.jpg',
'http://img.test/p6-thumb.jpg',
'http://img.test/p7-thumb.jpg',
'http://img.test/p8-thumb.jpg',
'http://img.test/p9-thumb.jpg',
]);
});
it('clamps the window at both edges without wrapping or extra fetching', () => {
const head = mountStrip(makeItems(12), 'p1');
expect(thumbIds(head)[0]).toBe('http://img.test/p1-thumb.jpg');
expect(thumbIds(head)).toHaveLength(7);
const tail = mountStrip(makeItems(12), 'p12');
expect(thumbIds(tail)).toHaveLength(7);
expect(thumbIds(tail).at(-1)).toBe('http://img.test/p12-thumb.jpg');
// 少于 7 项全量展示(不加载整个相册补位)。
const short = mountStrip(makeItems(3), 'p2');
expect(thumbIds(short)).toHaveLength(3);
});
it('highlights the current item with the primary border and aria-current', () => {
const wrapper = mountStrip(makeItems(5), 'p3');
const current = wrapper.findAll('.thumb')[2];
expect(current.classes()).toContain('current');
expect(current.attributes('aria-current')).toBe('true');
expect(wrapper.findAll('.thumb')[0].attributes('aria-current')).toBeUndefined();
// 无障碍名称带序号(第 {n} 张)。
expect(current.attributes('aria-label')).toBe('第 3 张');
});
it('emits select with the photoId on click (no browser history side effects)', async () => {
const wrapper = mountStrip(makeItems(9), 'p5');
// p5 的 ±3 窗口从 p2 起。
await wrapper.findAll('.thumb')[0].trigger('click');
expect(wrapper.emitted('select')).toEqual([['p2']]);
});
it('scrolls the selected item into view, honoring the reduced-motion preference', async () => {
const scrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
reducedMotion.value = false;
const wrapper = mountStrip(makeItems(12), 'p6');
await nextTick(); // flush:'post' 滚动 watcher 在渲染后微任务执行
expect(scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth', inline: 'center', block: 'nearest' }),
);
scrollIntoView.mockClear();
reducedMotion.value = true;
await wrapper.setProps({ currentPhotoId: 'p8' });
expect(scrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'auto', inline: 'center' }),
);
});
it('hides the overflow fades when the track is not scrollable', () => {
// jsdom 布局为 0:scrollWidth === clientWidth → 两侧均无渐隐提示。
const wrapper = mountStrip(makeItems(3), 'p2');
expect(wrapper.find('.thumb-fade').exists()).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { positionDisplay } from '../viewer/position';
// UI-02 §5.2 位置计数形态判定(纯数据层):三种真实回退 + 前瞻 total 形态。
// 组件侧当前传 total=null(公开端点游标分页无总数);lightbox.spec 覆盖
// loaded / index / preview 三种模式的端到端渲染。
describe('viewer position display fallbacks (UI-02 §5.2)', () => {
it('shows i / total only when the API provides an accurate total', () => {
expect(positionDisplay({ index: 11, loaded: 24, total: 248, atEnd: false, solo: false })).toEqual({
kind: 'total',
current: 12,
total: 248,
});
});
it('shows the loaded count while more pages remain and no total exists', () => {
expect(positionDisplay({ index: 2, loaded: 24, total: null, atEnd: false, solo: false })).toEqual({
kind: 'loaded',
count: 24,
});
});
it('shows i / n of the filtered sequence once all pages are loaded', () => {
expect(positionDisplay({ index: 4, loaded: 30, total: null, atEnd: true, solo: false })).toEqual({
kind: 'index',
current: 5,
total: 30,
});
});
it('never fabricates a position for unpositioned or solo deep-link entries', () => {
// solo:即使有准确 total,也不知道目标在筛选序列中的自然位置。
expect(positionDisplay({ index: 24, loaded: 25, total: 248, atEnd: true, solo: true })).toEqual({
kind: 'preview',
});
// 下标未知(-1,防御分支)。
expect(positionDisplay({ index: -1, loaded: 3, total: null, atEnd: true, solo: false })).toEqual({
kind: 'preview',
});
});
});
@@ -122,7 +122,7 @@ describe('viewer url sync and deep link (Task 8)', () => {
scrollTo.mockClear();
keydown('Escape');
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 650);
expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace
});
@@ -137,13 +137,13 @@ describe('viewer url sync and deep link (Task 8)', () => {
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
await wrapper.findAll('.cell')[0].trigger('click');
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(true);
expect(wrapper.find('.photo-viewer').exists()).toBe(true);
scrollTo.mockClear();
router.back(); // 等价浏览器后退(popstate 异步派发)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
expect(scrollTo).toHaveBeenCalledWith(0, 320);
});
@@ -164,15 +164,15 @@ describe('viewer url sync and deep link (Task 8)', () => {
const wrapper = mountApp();
await flushPromises();
// 单图先行:列表仍在加载,大图已可看(solo 序列 1/1)。
// 单图先行:列表仍在加载,大图已可看(solo 位置未知 → 「照片预览」,§5.2 不伪造排名)。
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5');
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1');
expect(wrapper.find('.counter').text()).toBe('1 / 1');
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
// 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。
resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'));
await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5));
expect(wrapper.find('.counter').text()).toBe('5 / 5');
expect(wrapper.find('.position-indicator').text()).toBe('5 / 5');
// 序列已构成:左切到前一张。
keydown('ArrowLeft');
@@ -189,7 +189,7 @@ describe('viewer url sync and deep link (Task 8)', () => {
const wrapper = mountApp();
await flushPromises();
expect(wrapper.find('.lightbox').exists()).toBe(false);
expect(wrapper.find('.photo-viewer').exists()).toBe(false);
const notice = wrapper.find('.deep-link-notice[role="alert"]');
expect(notice.text()).toContain('照片不存在或已下线');
expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码
+501 -281
View File
@@ -1,39 +1,48 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DownloadVariant } from '../api/client';
import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes';
import PhotoActionBar from './viewer/PhotoActionBar.vue';
import ThumbnailStrip, { type ThumbnailItem } from './viewer/ThumbnailStrip.vue';
import { copyText } from '../support/clipboard';
import { reducedMotion, toggleReducedMotion } from '../ui/prefs';
import { positionDisplay } from '../viewer/position';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
// 末尾且 hasMore 时取下一页扩展)+ 滚轮/双指缩放与平移(受控 CSS transform,数学
// 见 viewer/zoom.ts)+ 签名过期静默重取(复用 sequence.refreshPhotoUrls)。
// 大图查看器(Task 8 业务编排 + UI-02 V04 深色沉浸布局):
// · 布局层级(§5.1):顶栏(关闭 × / 位置计数 / ⋯ 菜单)→ 图片舞台(contain
// 大图 + 边缘 ‹ › 切换 + 粒子/互动占位层)→ 缩略图条 → 底部操作栏;
// · 业务逻辑不变:browse URL 展示、保持列表序列的左右切换(越过末尾且
// hasMore 时翻页扩展)、zoom.ts 缩放/平移、签名过期静默重取(复用
// sequence.refreshPhotoUrls)、下载两级开关与微信引导(useDownload)、
// 点赞乐观计数(useLikes);
// · 位置计数(§5.2 真实回退):见 viewer/position.ts——深链 solo 显示
// 「照片预览」,无 total 未到末页显示「已加载 N 张」,全部加载完成才
// 显示筛选序列 i/n;当前 API 无 total,不伪造总数;
// · 模态可用性:打开锁 body 滚动、焦点进入对话框并困于其内(Tab 循环)、
// 关闭/卸载时焦点回到触发元素;Esc 先收菜单再关查看器;左右键在菜单
// 焦点内不抢占(§5.3);
// · 关闭回位(滚动锚点 savedScrollY)与 URL push/replace/back 语义由
// ActivityPage 负责(见 ActivityPage 注释)。
//
// 状态归属:序列数据/分页在 ActivityPage 创建的 PhotoSequence(共享 composable),
// 本组件只持视图态(缩放/平移、加载失败标记、扩展中标记);当前照片以 photoId
// (v-model)为准而非下标——序列追加/solo 归位/条目移除都会移动下标,photoId 键
// 控让重排自动保持指向同一张照片。
//
// 手势(Pointer Events,无第三方依赖):
// · 双指:捏合缩放(transform 不动点 = 手势中心)+ 中点漂移平移;
// 手势(Pointer Events,无第三方依赖;与 V04 布局的协作):
// · 手势绑定在根元素,但顶栏/缩略图条/底部操作栏(data-lightbox-chrome)
// 内的指针不参与手势——条带横滚、按钮点击不会误触轻扫切图;
// · 双指:捏合缩放(不动点 = 手势中心)+ 中点漂移平移;
// · 单指且 scale>1:拖拽平移;scale=1:横向轻扫切换上一张/下一张;
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
//
// 下载(P4a Task 4):工具栏入口按相册两级开关渲染(控制器 canDownload /
// canDownloadOriginal;download prop 缺省不渲染——组件单测/降级组装)。原图
// 可下时为分体按钮(主按钮=浏览图 + ▾ 菜单两选项);微信内置浏览器由控制器
// 判定并改走引导浮层(长按保存 + 复制深链)。
//
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
// · 双击(dblclick / 触控双 tap):复位缩放;滚轮:以光标为不动点缩放
// (缩略图条 data-native-scroll 区域内的滚轮留给条带横滚);
// · 舞台空白区(非图片)点击关闭;手势位移后的 click 不关闭。
const props = defineProps<{
sequence: PhotoSequence;
photoId: string;
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
/** 点赞控制器(Task 10):缺省时 Like 位退化为静态计数(组件单测/降级组装)。 */
likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省不渲染下载位(组件单测/降级组装)。 */
/** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController;
}>();
@@ -54,6 +63,28 @@ const hasPrev = computed(() => index.value > 0);
// 末张且仍有下一页 → 允许继续向右(导航触发序列扩展)。
const hasNext = computed(() => index.value < total.value - 1 || props.sequence.hasMore);
// —— 位置计数(§5.2):形态判定在 viewer/position.ts,此处只拼接文案 ——
const positionText = computed(() => {
const display = positionDisplay({
index: index.value,
loaded: photos.value.length,
// 当前公开端点为游标分页(CursorPageMeta 无 total)——不伪造总数;
// 接口将来提供准确 total 时改传实值即得「12 / 248」形态。
total: null,
atEnd: !props.sequence.hasMore,
solo: props.sequence.isSolo(props.photoId),
});
switch (display.kind) {
case 'preview':
return t('viewer.photoPreview');
case 'loaded':
return t('viewer.loadedCount', { count: display.count });
default:
return `${display.current} / ${display.total}`;
}
});
// —— 视图状态(按 photoId;切换照片即复位) ——
/** 图片单次重试的缓存击穿键:重试按钮点击后 +1 强制 <img> 重挂载重触发加载。 */
@@ -76,24 +107,30 @@ watch(
const zoom = reactive<ZoomState>({ ...resetZoom() });
const rootEl = ref<HTMLElement | null>(null);
/** 舞台(图片容器):缩放不动点坐标系的原点(图片经 flex 居中于其中)。 */
const stageEl = ref<HTMLElement | null>(null);
const imgStyle = computed(() => ({
transform: `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.scale})`,
}));
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 容器中心。 */
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 舞台中心。 */
function center(): { x: number; y: number } {
const rect = rootEl.value?.getBoundingClientRect();
const rect = stageEl.value?.getBoundingClientRect();
if (rect === undefined) return { x: 0, y: 0 };
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function onWheel(event: WheelEvent): void {
// 缩略图条等原生滚动区(data-native-scroll)内的滚轮留给自身横滚。
if (event.target instanceof HTMLElement && event.target.closest('[data-native-scroll]') !== null) return;
event.preventDefault();
const { x, y } = center();
Object.assign(zoom, zoomAt({ ...zoom }, wheelFactor(event.deltaY), event.clientX - x, event.clientY - y));
}
function onDblClick(): void {
function onDblClick(event: MouseEvent): void {
if (inChrome(event.target)) return;
Object.assign(zoom, resetZoom());
}
@@ -104,11 +141,21 @@ let pinchDistance = 0;
let pinchMid = { x: 0, y: 0 };
let swipe: { x: number; y: number; dx: number; dy: number } | null = null;
let lastTap = { time: 0, x: 0, y: 0 };
/** 手势期间发生过位移:抑制手势收尾时的背景 click 误关闭。 */
/** 手势期间发生过位移:抑制手势收尾时的舞台 click 误关闭。 */
let dragged = false;
const SWIPE_MIN_DISTANCE = 50;
/** 顶栏/缩略图条/底部操作栏(data-lightbox-chrome):不参与手势与双击复位。 */
function inChrome(target: EventTarget | null): boolean {
return target instanceof HTMLElement && target.closest('[data-lightbox-chrome]') !== null;
}
/** 菜单(role=menu)焦点内左右键不抢占切图(§5.3)。 */
function inMenu(target: EventTarget | null): boolean {
return target instanceof HTMLElement && target.closest('[role="menu"]') !== null;
}
function pointerPair(): { a: { x: number; y: number }; b: { x: number; y: number } } | null {
if (pointers.size < 2) return null;
const [first, second] = pointers.values();
@@ -121,6 +168,7 @@ function distance(a: { x: number; y: number }, b: { x: number; y: number }): num
}
function onPointerDown(event: PointerEvent): void {
if (inChrome(event.target)) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
dragged = false;
@@ -167,7 +215,7 @@ function onPointerMove(event: PointerEvent): void {
}
if (zoom.scale > MIN_SCALE) {
// 单指平移(scale>1)。
// 单指平移(scale>1):平移期间不触发轻扫切图(§5.3)。
Object.assign(zoom, panBy({ ...zoom }, current.x - previous.x, current.y - previous.y));
dragged = true;
} else if (swipe !== null) {
@@ -204,23 +252,52 @@ function onPointerEnd(event: PointerEvent): void {
}
}
function onBackdropClick(): void {
if (dragged) return; // 手势收尾的 click 不关闭
/** 舞台空白区(目标=容器自身,非图片/占位)点击关闭;手势位移后的 click 不关闭。 */
function onStageClick(event: MouseEvent): void {
if (dragged) return;
if (event.target !== event.currentTarget) return;
emit('close');
}
// —— 键盘 ——
// —— 键盘:Esc 关闭(菜单开时先收菜单)、←→ 切换、Tab 焦点困于对话框 ——
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
if (moreMenuOpen.value) {
moreMenuOpen.value = false;
return;
}
emit('close');
} else if (event.key === 'ArrowLeft') {
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
if (inMenu(event.target)) return;
event.preventDefault();
void goTo(index.value - 1);
} else if (event.key === 'ArrowRight') {
void goTo(event.key === 'ArrowLeft' ? index.value - 1 : index.value + 1);
}
}
/** Tab 循环限制在对话框内(模态焦点不穿透,§5.3)。 */
function onTrapTab(event: KeyboardEvent): void {
const root = rootEl.value;
if (root === null) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
);
if (focusables.length === 0) {
event.preventDefault();
void goTo(index.value + 1);
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !root.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !root.contains(active)) {
event.preventDefault();
first.focus();
}
}
@@ -243,6 +320,14 @@ async function goTo(targetIndex: number): Promise<void> {
emit('update:photoId', target.photoId);
}
/** 缩略图条选择:与手势/键盘共用 goTo 入口(update:photoId → 父级 replace 路由,
* 不新增浏览器历史)。 */
function selectThumbnail(photoId: string): void {
if (photoId === props.photoId) return;
const targetIndex = photos.value.findIndex((item) => item.photoId === photoId);
if (targetIndex >= 0) void goTo(targetIndex);
}
// 相邻预取(±1 browse URL)+ 临近末尾预拉下一页,让右切更顺滑。
watch(
() => [props.photoId, photos.value] as const,
@@ -266,32 +351,54 @@ function preload(url: string): void {
// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 ——
const displayLikeCount = computed(() => {
if (photo.value === null) return 0;
return props.likes !== undefined ? props.likes.displayFor(photo.value) : photo.value.likeCount;
});
function likeCurrent(): void {
if (photo.value === null || props.likes === undefined) return;
props.likes.like(photo.value.photoId);
}
// —— 下载(P4a Task 4):菜单开合随照片切换/关闭复位 ——
// —— 下载(P4a Task 4):入口与二级菜单在 PhotoActionBar,控制器编排在此 ——
const downloadMenuOpen = ref(false);
const canDownload = computed(() => props.download !== undefined && props.download.canDownload);
const canDownloadOriginal = computed(() => canDownload.value && props.download !== undefined && props.download.canDownloadOriginal);
watch(
() => props.photoId,
() => {
downloadMenuOpen.value = false;
},
);
/** 菜单选项点击:收起菜单后发起对应 variant 下载。 */
function pickDownload(variant: 'browse' | 'original'): void {
downloadMenuOpen.value = false;
function onDownloadVariant(variant: DownloadVariant): void {
void props.download?.download(variant);
}
/** 主按钮:无原图位时直下浏览图;有原图位时也直下浏览图(菜单提供两项)。 */
function downloadBrowse(): void {
downloadMenuOpen.value = false;
void props.download?.download('browse');
// —— 分享(V04 最小实现:复制当前照片深链——路由已同步 /p/:photoId;
// navigator.share 编排与语言参数属 Task 5/V05) ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
const shareUrlText = ref('');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
async function shareCurrent(): Promise<void> {
shareUrlText.value = window.location.href;
const copied = await copyText(shareUrlText.value);
shareState.value = copied ? 'copied' : 'failed';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (copied) {
shareResetTimer = setTimeout(() => {
shareState.value = 'idle';
}, 3000);
}
}
// —— 顶栏「⋯」更多菜单(§5.1:必须有实际功能——减少动画开关) ——
const moreMenuOpen = ref(false);
const moreRootEl = ref<HTMLElement | null>(null);
function onOuterPointerDown(event: PointerEvent): void {
if (moreMenuOpen.value && moreRootEl.value !== null && !moreRootEl.value.contains(event.target as Node)) {
moreMenuOpen.value = false;
}
}
// —— 签名过期/下线处理 ——
@@ -324,12 +431,38 @@ function retryFailed(): void {
retryCount.value += 1; // 强制 <img> 重挂载,重新触发加载/错误链
}
// —— 模态可用性:body 滚动锁 + 焦点进入/困住/归还(§5.1/§5.3) ——
let savedBodyOverflow = '';
let savedFocus: HTMLElement | null = null;
let focusClaimed = false;
// 根节点渲染时(深链下序列就位前组件先挂载、photo 后到):焦点进入对话框。
watch(rootEl, (el) => {
if (el === null || focusClaimed) return;
focusClaimed = true;
savedFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
el.focus();
});
const thumbItems = computed<ThumbnailItem[]>(() =>
photos.value.map((item) => ({ photoId: item.photoId, thumbUrl: item.thumb.url })),
);
onMounted(() => {
window.addEventListener('keydown', onKeydown);
document.addEventListener('pointerdown', onOuterPointerDown, true);
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; // 锁背景滚动(§5.1)
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.body.style.overflow = savedBodyOverflow;
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus();
});
</script>
@@ -337,112 +470,135 @@ onBeforeUnmount(() => {
<div
v-if="photo !== null"
ref="rootEl"
class="lightbox"
class="photo-viewer"
role="dialog"
aria-modal="true"
:aria-label="t('viewer.title')"
@wheel.prevent="onWheel"
:aria-label="t('viewer.photoViewer')"
tabindex="-1"
@wheel="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerEnd"
@pointercancel="onPointerEnd"
@pointerleave="onPointerEnd"
@dblclick="onDblClick"
@keydown.tab="onTrapTab"
>
<div class="backdrop" @click="onBackdropClick" />
<img
v-if="!failedIds.has(photo.photoId)"
:key="`${photo.photoId}:${retryCount}`"
class="photo"
:src="photo.browse.url"
:alt="t('route.photoLabel')"
:style="imgStyle"
draggable="false"
@error="onImgError"
>
<div v-else class="img-failed" role="alert">
<p>{{ t('viewer.loadFailed') }}</p>
<button type="button" @click="retryFailed">{{ t('album.retry') }}</button>
</div>
<header class="bar">
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
<button
v-if="likes !== undefined"
type="button"
class="control like-button"
:aria-label="t('viewer.like')"
@click="likeCurrent"
>
<span :key="likes.popNonce(photo.photoId)" class="heart" aria-hidden="true">♥</span>
<span class="like-count">{{ likes.displayFor(photo) }}</span>
<!-- 顶栏(§5.1:52-56px 深色半透明):关闭 / 位置计数 / ⋯ 更多菜单 -->
<header class="viewer-topbar" data-lightbox-chrome>
<button type="button" class="viewer-close" :aria-label="t('viewer.close')" @click="emit('close')">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 6l12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
<span v-else class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<!-- 下载(P4a Task 4):两级开关驱动——都关无入口;browse 单按钮;
original 可下 → 分体按钮(主=浏览图 + ▾ 菜单两项)。 -->
<div
v-if="download !== undefined && download.canDownload"
class="download-group"
>
<p class="position-indicator">{{ positionText }}</p>
<div ref="moreRootEl" class="more-menu">
<button
type="button"
class="control download"
:disabled="download.busy"
:aria-label="t('download.action')"
@click="downloadBrowse"
class="viewer-more"
:aria-label="t('viewer.more')"
aria-haspopup="menu"
:aria-expanded="moreMenuOpen"
@click="moreMenuOpen = !moreMenuOpen"
>
{{ download.busy ? t('download.busy') : t('download.action') }}
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="19" cy="12" r="2" fill="currentColor" />
</svg>
</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') }}
<div v-if="moreMenuOpen" class="more-popover" role="menu" :aria-label="t('viewer.more')">
<button
type="button"
class="more-item"
role="menuitemcheckbox"
:aria-checked="reducedMotion"
@click="toggleReducedMotion"
>
<span class="more-check" aria-hidden="true">{{ reducedMotion ? '✓' : '' }}</span>
{{ t('viewer.reduceMotion') }}
</button>
</div>
</div>
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
✕
</button>
</header>
<button
type="button"
class="control nav prev"
:disabled="!hasPrev"
:aria-label="t('viewer.previous')"
@click="goTo(index - 1)"
>
‹
</button>
<button
type="button"
class="control nav next"
:disabled="!hasNext"
:aria-label="t('viewer.next')"
@click="goTo(index + 1)"
>
›
</button>
<!-- 图片舞台(flex:1):contain 大图 + 边缘切换 + 粒子/互动占位层 -->
<main class="viewer-stage">
<div ref="stageEl" class="image-container" @click="onStageClick">
<img
v-if="!failedIds.has(photo.photoId)"
:key="`${photo.photoId}:${retryCount}`"
class="photo"
:src="photo.browse.url"
:alt="t('route.photoLabel')"
:style="imgStyle"
draggable="false"
@error="onImgError"
>
<div v-else class="img-failed" role="alert">
<p>{{ t('viewer.loadFailed') }}</p>
<button type="button" @click="retryFailed">{{ t('album.retry') }}</button>
</div>
</div>
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<button
type="button"
class="nav prev"
:disabled="!hasPrev"
:aria-label="t('viewer.previous')"
@click="goTo(index - 1)"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
class="nav next"
:disabled="!hasNext"
:aria-label="t('viewer.next')"
@click="goTo(index + 1)"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案;可重点击重试)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t(download.failed) }}
</p>
<!-- 心形粒子层(Task 5 / V05 填充):装饰性、不可交互 -->
<div class="heart-particles-zone" aria-hidden="true" style="pointer-events: none" />
<!-- 跨照片互动提示层(Task 5 / V05 填充):底部操作栏上方安全区 -->
<div class="interaction-notice-zone" />
<div class="stage-notices">
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 分享反馈轻提示(复制成功/失败;失败保留可选中文本) -->
<p v-if="shareState !== 'idle'" class="share-status" role="status">
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
<span v-if="shareState === 'failed'" class="share-url">{{ shareUrlText }}</span>
</p>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t(download.failed) }}
</p>
</div>
</main>
<!-- 缩略图条(§5.3):当前 ±3 窗口;点击选择(不新增历史) -->
<ThumbnailStrip :photos="thumbItems" :current-photo-id="photoId" @select="selectThumbnail" />
<!-- 底部操作栏(§5.1:88-104px + 安全区):Like / Share / Download -->
<footer class="viewer-actionbar" data-lightbox-chrome>
<PhotoActionBar
:like-count="displayLikeCount"
:like-pop="likes !== undefined && photo !== null ? likes.popNonce(photo.photoId) : 0"
:can-download="canDownload"
:can-download-original="canDownloadOriginal"
@like="likeCurrent"
@share="shareCurrent"
@download="onDownloadVariant"
/>
</footer>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
<div
@@ -477,21 +633,130 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
.lightbox {
/* —— UI-02 V04 深色沉浸布局(§5.1):dvh + 安全区;token 经祖先 .viewer-shell
* 提供,独立挂载(无 shell)时 var() 回退同值 —— */
.photo-viewer {
position: fixed;
inset: 0;
z-index: 100;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--viewer-dark-bg, #0e1218);
color: #ffffff;
user-select: none;
}
.photo-viewer:focus {
outline: none;
}
/* 顶栏:grid 三列(44px | 1fr 居中计数 | 44px),半透明深色。 */
.viewer-topbar {
flex: none;
display: grid;
grid-template-columns: var(--viewer-touch-target, 44px) 1fr var(--viewer-touch-target, 44px);
align-items: center;
min-height: 52px;
padding: 0 8px;
padding-top: env(safe-area-inset-top, 0px);
background: rgba(14, 18, 24, 0.72);
}
.viewer-close,
.viewer-more {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target, 44px);
height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 50%;
background: transparent;
color: #ffffff;
cursor: pointer;
}
.viewer-close:hover,
.viewer-more:hover {
background: rgba(255, 255, 255, 0.1);
}
.viewer-close:focus-visible,
.viewer-more:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.viewer-close svg,
.viewer-more svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.position-indicator {
margin: 0;
text-align: center;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.92);
font-variant-numeric: tabular-nums;
}
.more-menu {
position: relative;
justify-self: end;
}
.more-popover {
position: absolute;
top: calc(100% + 2px);
right: 0;
z-index: 6;
display: grid;
min-width: 176px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(22, 28, 36, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 4px;
}
.more-item {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 8px;
background: transparent;
color: #ffffff;
padding: 0 12px;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
}
.more-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.more-item:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.more-check {
display: inline-block;
width: 1em;
color: var(--viewer-primary, #1a73e8);
font-weight: 700;
}
/* 舞台:占剩余空间;contain 完整大图(不裁切);手势区 touch-action none。 */
.viewer-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
touch-action: none;
}
.image-container {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
user-select: none;
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(17, 24, 39, 0.92);
}
.photo {
position: relative;
@@ -515,139 +780,118 @@ onBeforeUnmount(() => {
padding: 4px 12px;
cursor: pointer;
}
.bar {
.nav {
position: absolute;
top: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target, 44px);
height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
cursor: pointer;
}
.nav:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.nav:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.nav:disabled {
opacity: 0.3;
cursor: default;
}
.nav svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav.prev {
left: 8px;
}
.nav.next {
right: 8px;
}
/* 粒子/互动占位层(Task 5 填充):舞台右侧上浮 / 操作栏上方安全区。 */
.heart-particles-zone {
position: absolute;
inset: 0;
overflow: hidden;
}
.interaction-notice-zone {
position: absolute;
bottom: 12px;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
pointer-events: none;
}
.stage-notices {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 10px 16px;
color: #f9fafb;
background: linear-gradient(rgba(0, 0, 0, 0.45), transparent);
gap: 8px;
pointer-events: none;
z-index: 4;
}
.counter {
font-variant-numeric: tabular-nums;
}
.likes {
.loading {
margin: 0;
color: #d1d5db;
font-size: 0.875rem;
}
.like-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 12px;
font-size: 0.9rem;
border: 1px solid rgba(249, 250, 251, 0.35);
.share-status {
margin: 0;
border-radius: var(--viewer-radius-pill, 20px);
background: rgba(22, 28, 36, 0.92);
color: #f3f4f6;
padding: 6px 16px;
font-size: 0.8125rem;
pointer-events: auto;
max-width: min(92%, 480px);
}
.like-button .heart {
color: #ef4444;
display: inline-block;
}
/* :key 重挂触发心跳放大;「减少动画」时禁用(数字仍即时跳动)。 */
.like-button .heart {
animation: heart-pop 400ms ease-out;
}
.like-count {
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.viewer-reduced-motion .like-button .heart {
animation: none;
}
.control {
border: none;
background: rgba(17, 24, 39, 0.55);
color: #f9fafb;
cursor: pointer;
}
.control:disabled {
opacity: 0.35;
cursor: default;
}
.control.close {
margin-left: auto;
font-size: 1.1rem;
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);
.share-url {
user-select: all;
word-break: break-all;
color: #9fc3f5;
}
.download-failed {
position: absolute;
bottom: 52px;
left: 50%;
transform: translateX(-50%);
margin: 0;
border-radius: 8px;
border-radius: var(--viewer-radius-pill, 20px);
background: rgba(185, 28, 28, 0.92);
color: #fef2f2;
padding: 6px 14px;
font-size: 0.875rem;
padding: 6px 16px;
font-size: 0.8125rem;
}
/* 底部操作栏容器:安全区 padding;按钮编排(Like/Share/Download)在
* PhotoActionBar(§5.4:下载关闭移除整项并均分剩余栏宽)。 */
.viewer-actionbar {
flex: none;
padding: 6px 12px;
padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 18, 24, 0.72);
}
/* —— 微信引导浮层 —— */
.wechat-guide {
position: absolute;
inset: 0;
z-index: 3;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
@@ -703,28 +947,4 @@ onBeforeUnmount(() => {
font-size: 0.8rem;
color: #6b7280;
}
.control.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
line-height: 1;
border-radius: 999px;
width: 44px;
height: 44px;
}
.control.nav.prev {
left: 12px;
}
.control.nav.next {
right: 12px;
}
.loading {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
margin: 0;
color: #d1d5db;
}
</style>
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { PublicAlbum, SortMode } from '../../api/client';
import { copyText } from '../../support/clipboard';
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
@@ -130,31 +131,6 @@ const albumUrl = computed(() => {
return new URL(path, window.location.origin).href;
});
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 走传统复制兜底
}
try {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const copied = document.execCommand('copy');
area.remove();
return copied;
} catch {
return false;
}
}
async function share(): Promise<void> {
const copied = await copyText(albumUrl.value);
shareState.value = copied ? 'copied' : 'failed';
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DownloadVariant } from '../../api/client';
// UI-02 §5.1/§5.4 底部操作栏(V04 新组件):Like(coral 心形 + 累计数 +
// 「连续点击」提示)/ Share / Download 三个均匀分布的大触控目标(≥44px)。
// 下载关闭时整项移除,剩余按钮按 flex 均分自然重排;原图仅
// canDownloadOriginal 时提供二级选项(长按主按钮或 ▾ 菜单:浏览图/原图)。
// 本组件只发事件——点赞/分享/下载的业务编排在父级(PhotoLightbox)。
const props = withDefaults(
defineProps<{
/** 当前照片展示计数(已含乐观 pending)。 */
likeCount: number;
/** 点赞动画 nonce(LikesController.popNonce):变化即重挂心形重播心跳。 */
likePop?: number;
canDownload?: boolean;
canDownloadOriginal?: boolean;
/** 点赞提交中(预留:连续点赞不禁用,仅视觉态)。 */
isLiking?: boolean;
}>(),
{ likePop: 0, canDownload: false, canDownloadOriginal: false, isLiking: false },
);
const emit = defineEmits<{
like: [];
share: [];
download: [variant: DownloadVariant];
}>();
const { t, locale } = useI18n();
const likeCountText = computed(() => new Intl.NumberFormat(locale.value).format(props.likeCount));
// —— 原图二级菜单:▾ 点击或主按钮长按(500ms)展开;点击直接下浏览图 ——
const LONG_PRESS_MS = 500;
const menuOpen = ref(false);
let pressTimer: ReturnType<typeof setTimeout> | undefined;
/** 长按已展开菜单:吞掉紧随的 click,避免长按判定又触发默认下载。 */
let suppressClick = false;
function startPress(): void {
if (!props.canDownloadOriginal) return;
pressTimer = setTimeout(() => {
menuOpen.value = true;
suppressClick = true;
}, LONG_PRESS_MS);
}
function cancelPress(): void {
if (pressTimer !== undefined) {
clearTimeout(pressTimer);
pressTimer = undefined;
}
}
function onDownloadClick(): void {
cancelPress();
if (suppressClick) {
suppressClick = false;
return;
}
menuOpen.value = false;
emit('download', 'browse');
}
function pickVariant(variant: DownloadVariant): void {
menuOpen.value = false;
emit('download', variant);
}
onBeforeUnmount(cancelPress);
</script>
<template>
<div class="action-bar">
<div class="action-slot like-slot">
<button
type="button"
class="action-btn"
:aria-label="t('viewer.like')"
@click="emit('like')"
>
<!-- :key 重挂触发心跳放大(沿用 Task 10 popNonce);「减少动画」时禁用。 -->
<svg :key="likePop" class="action-icon like-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<span class="like-count">{{ likeCountText }}</span>
<span class="action-label">{{ t('viewer.like') }}</span>
</button>
<!-- §5.1:Like 下方次级提示(12px),说明可连续点赞。 -->
<p class="like-hint">{{ t('viewer.tapMore') }}</p>
</div>
<div class="action-slot">
<button
type="button"
class="action-btn"
:aria-label="t('viewer.share')"
@click="emit('share')"
>
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 3v12" />
<path d="m7 8 5-5 5 5" />
<path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
</svg>
<span class="action-label">{{ t('viewer.share') }}</span>
</button>
</div>
<!-- 下载关闭时整项移除(不留可绕过直链);剩余 slot flex 均分自动重排。 -->
<div v-if="canDownload" class="action-slot download-slot">
<div class="download-group">
<button
type="button"
class="action-btn download-main"
:aria-label="t('viewer.download')"
@click="onDownloadClick"
@pointerdown="startPress"
@pointerup="cancelPress"
@pointercancel="cancelPress"
@pointerleave="cancelPress"
>
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
<path d="M12 15V3" />
</svg>
<span class="action-label">{{ t('viewer.download') }}</span>
</button>
<button
v-if="canDownloadOriginal"
type="button"
class="download-caret"
:aria-label="t('download.options')"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div v-if="menuOpen && canDownloadOriginal" class="download-menu" role="menu" :aria-label="t('download.options')">
<button type="button" role="menuitem" @click="pickVariant('browse')">
{{ t('viewer.downloadBrowse') }}
</button>
<button type="button" role="menuitem" @click="pickVariant('original')">
{{ t('viewer.downloadOriginal') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 深色底部操作栏(§5.1:约 88-104px + 安全区,安全区 padding 由父级 footer 承担)。
* token 经 .photo-viewer 链 var() 回退,独立挂载也成立。 */
.action-bar {
display: flex;
align-items: stretch;
min-height: 88px;
}
.action-slot {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: var(--viewer-touch-target, 44px);
min-height: var(--viewer-touch-target, 44px);
border: none;
background: transparent;
color: #ffffff;
padding: 4px 12px;
cursor: pointer;
}
.action-btn:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
border-radius: 8px;
}
.action-icon {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Like:coral 实心心形 + 累计数(tabular-nums 稳定数字宽度)。 */
.like-icon {
fill: var(--viewer-like-accent, #ff667d);
stroke: var(--viewer-like-accent, #ff667d);
animation: heart-pop 400ms ease-out;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
/* 「减少动画」:不心跳(数字仍即时跳动,§6.3)。 */
:global(.viewer-reduced-motion) .like-icon {
animation: none;
}
.like-count {
font-variant-numeric: tabular-nums;
font-size: 1.0625rem;
font-weight: 700;
line-height: 1.2;
}
.like-hint {
margin: 2px 0 0;
max-width: 180px;
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
line-height: 1.3;
text-align: center;
}
.action-label {
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.92);
line-height: 1.3;
}
/* —— 下载组:主按钮 + ▾;菜单向上弹出 —— */
.download-group {
position: relative;
display: inline-flex;
align-items: center;
}
.download-caret {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: stretch;
min-width: 32px;
min-height: var(--viewer-touch-target, 44px);
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
}
.download-caret:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
border-radius: 6px;
}
.download-caret svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.download-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 5;
display: grid;
min-width: 10em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: rgba(22, 28, 36, 0.96);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 4px;
}
.download-menu button {
min-height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 6px;
background: transparent;
color: #ffffff;
padding: 0 14px;
text-align: left;
font-size: 0.9375rem;
cursor: pointer;
}
.download-menu button:hover {
background: rgba(255, 255, 255, 0.08);
}
.download-menu button:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
</style>
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { reducedMotion } from '../../ui/prefs';
// UI-02 §5.3 缩略图条(V04 新组件):显示当前附近最多 7 项(手机约可见 5 项),
// 当前项主色 2px 描边;点击切换 photoId(不新增浏览器历史——由父级导航入口
// 统一走既有 replace 语义)。只消费已加载序列的临近窗口,不为填满条带加载
// 整个相册;缩略图一律 thumb 变体 URL(不预载 browse 大图)。
export interface ThumbnailItem {
photoId: string;
/** thumb 变体签名 URL;缺失时渲染占位块(保持条带高度稳定)。 */
thumbUrl?: string;
width?: number;
height?: number;
}
const props = defineProps<{
photos: ThumbnailItem[];
currentPhotoId: string | null;
}>();
const emit = defineEmits<{ select: [photoId: string] }>();
const { t } = useI18n();
/** 窗口半径:当前 ±3 → 最多 7 项;首尾自然收缩(不循环、不预取)。 */
const WINDOW_RADIUS = 3;
const WINDOW_MAX = WINDOW_RADIUS * 2 + 1;
const currentIndex = computed(() => props.photos.findIndex((item) => item.photoId === props.currentPhotoId));
const windowItems = computed(() => {
const total = props.photos.length;
if (total === 0) return [];
const anchor = currentIndex.value === -1 ? 0 : currentIndex.value;
const start = Math.max(0, Math.min(anchor - WINDOW_RADIUS, total - WINDOW_MAX));
const end = Math.min(total, start + WINDOW_MAX);
const result: { item: ThumbnailItem; index: number }[] = [];
for (let i = start; i < end; i += 1) result.push({ item: props.photos[i], index: i });
return result;
});
// —— 滚动编排:选中项滚入可见范围(遵守减少动画);条带溢出提示 ——
const trackEl = ref<HTMLElement | null>(null);
const canScrollLeft = ref(false);
const canScrollRight = ref(false);
/** 减少动画判定:应用内偏好或系统 prefers-reduced-motion 任一命中即静止滚动。 */
function prefersReducedMotion(): boolean {
if (reducedMotion.value) return true;
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
return false;
}
function scrollCurrentIntoView(): void {
const track = trackEl.value;
if (track === null) return;
const current = track.querySelector<HTMLElement>('.thumb.current');
if (current === null) return;
if (typeof current.scrollIntoView !== 'function') return; // jsdom 无实现
current.scrollIntoView({
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
block: 'nearest',
inline: 'center',
});
}
function updateOverflow(): void {
const el = trackEl.value;
if (el === null) {
canScrollLeft.value = false;
canScrollRight.value = false;
return;
}
canScrollLeft.value = el.scrollLeft > 1;
canScrollRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
}
watch(
[trackEl, () => props.currentPhotoId, () => props.photos] as const,
() => {
scrollCurrentIntoView();
updateOverflow();
},
{ immediate: true, flush: 'post' },
);
</script>
<template>
<nav class="thumb-strip" :aria-label="t('viewer.thumbnails')">
<div ref="trackEl" class="thumb-track" data-native-scroll @scroll.passive="updateOverflow">
<button
v-for="{ item, index } in windowItems"
:key="item.photoId"
type="button"
class="thumb"
:class="{ current: item.photoId === currentPhotoId }"
:aria-current="item.photoId === currentPhotoId ? 'true' : undefined"
:aria-label="t('viewer.thumbItem', { n: index + 1 })"
@click="emit('select', item.photoId)"
>
<img v-if="item.thumbUrl !== undefined" class="thumb-img" :src="item.thumbUrl" alt="" loading="lazy" draggable="false">
<span v-else class="thumb-placeholder" aria-hidden="true" />
</button>
</div>
<span v-if="canScrollLeft" class="thumb-fade left" aria-hidden="true" />
<span v-if="canScrollRight" class="thumb-fade right" aria-hidden="true" />
</nav>
</template>
<style scoped>
/* 深色查看器内嵌条带:token 经 .photo-viewer 链 var() 回退,独立挂载也成立。 */
.thumb-strip {
position: relative;
height: 56px;
flex: none;
}
.thumb-track {
display: flex;
gap: 6px;
align-items: center;
height: 100%;
overflow-x: auto;
scrollbar-width: none;
padding: 0 12px;
touch-action: pan-x;
}
.thumb-track::-webkit-scrollbar {
display: none;
}
/* §5.3:宽约 56-64px、高约 44-52px;非当前项保留透明 2px 边框防切换抖动。 */
.thumb {
flex: none;
display: block;
width: 60px;
height: 48px;
padding: 0;
border: 2px solid transparent;
border-radius: var(--viewer-radius-img, 4px);
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
cursor: pointer;
}
.thumb.current {
border-color: var(--viewer-primary, #1a73e8);
}
.thumb:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 1px;
}
.thumb-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-placeholder {
display: block;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.12);
}
/* 条带溢出提示:仅确有未滚出内容的一侧渐隐(不拦截指针)。 */
.thumb-fade {
position: absolute;
top: 0;
bottom: 0;
width: 28px;
pointer-events: none;
}
.thumb-fade.right {
right: 0;
background: linear-gradient(to right, transparent, var(--viewer-dark-bg, #0e1218) 85%);
}
.thumb-fade.left {
left: 0;
background: linear-gradient(to left, transparent, var(--viewer-dark-bg, #0e1218) 85%);
}
</style>
+11
View File
@@ -79,6 +79,17 @@ export default {
noPhotos: 'Photos coming soon',
newPhotos: 'New photos',
reduceMotion: 'Reduce motion',
// —— UI-02 (V04 dark immersive viewer) additions ——
photoViewer: 'Photo viewer',
photoPreview: 'Photo preview',
loadedCount: '{count} loaded',
tapMore: 'Tap to send more love',
download: 'Download',
downloadBrowse: 'Browse',
downloadOriginal: 'Original',
more: 'More options',
thumbnails: 'Thumbnail navigation',
thumbItem: 'Photo {n}',
},
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
errors: {
+11
View File
@@ -79,6 +79,17 @@ export default {
noPhotos: '照片即将上线',
newPhotos: '有新照片',
reduceMotion: '减少动画',
// —— UI-02(V04 深色大图查看器)新增 ——
photoViewer: '照片查看器',
photoPreview: '照片预览',
loadedCount: '已加载 {count} 张',
tapMore: '连续点击,为照片点赞',
download: '下载',
downloadBrowse: '浏览图',
downloadOriginal: '原图',
more: '更多选项',
thumbnails: '缩略图导航',
thumbItem: '第 {n} 张',
},
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
errors: {
+28
View File
@@ -0,0 +1,28 @@
// apps/viewer/src/support/clipboard.ts —— 剪贴板写入兜底(UI-02 V02 起
// AlbumHeader 相册分享与 V04 大图 Share 共用):优先 navigator.clipboard
// (安全上下文),退回隐藏 textarea + execCommand('copy')(旧 WebView/
// 非安全上下文);均失败返回 false,由调用方展示可选中文本回退(§5.4)。
export async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 走传统复制兜底
}
try {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const copied = document.execCommand('copy');
area.remove();
return copied;
} catch {
return false;
}
}
+37
View File
@@ -0,0 +1,37 @@
// apps/viewer/src/viewer/position.ts —— UI-02 §5.2 大图顶栏位置计数(纯数据层)。
//
// 数据诚实红线:不把已加载张数冒充相册总数、不为装饰计数新增 COUNT 查询。
// 当前公开列表端点为游标分页(CursorPageMeta 只有 nextCursor/hasMore,无
// total),因此「12 / 248」总分形态仅当接口将来提供准确 total 时出现——
// 输入保留 total 字段,组件侧传 null 走真实回退:
// · 深链 solo 条目(自然位置未知)或尚未定位 → 「照片预览」,不伪造位置;
// · 有准确 total → 「i / total」;
// · 未到末页且无 total → 「已加载 N 张」;
// · 全部加载完成(!hasMore)且非 solo → 当前筛选序列「i / n」。
// 文案拼接(i18n)在组件层完成;本模块只做形态判定,便于 vitest 直接覆盖。
export interface PositionInput {
/** 当前照片在已加载序列中的下标;-1 = 尚未定位(渲染守卫后理论不出现,防御保留)。 */
index: number;
/** 已加载张数。 */
loaded: number;
/** 当前筛选范围的准确总数(API 提供);无 → null(不得以 loaded 充当)。 */
total: number | null;
/** 序列是否已到末页(= !sequence.hasMore)。 */
atEnd: boolean;
/** 深链 solo 条目:追加在序列尾部兜底,自然位置未知。 */
solo: boolean;
}
export type PositionDisplay =
| { kind: 'preview' }
| { kind: 'total'; current: number; total: number }
| { kind: 'loaded'; count: number }
| { kind: 'index'; current: number; total: number };
export function positionDisplay(input: PositionInput): PositionDisplay {
if (input.index < 0 || input.solo) return { kind: 'preview' };
if (input.total !== null) return { kind: 'total', current: input.index + 1, total: input.total };
if (!input.atEnd) return { kind: 'loaded', count: input.loaded };
return { kind: 'index', current: input.index + 1, total: input.loaded };
}