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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user