2026-09-18 10:23:27 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-09-18 11:04:05 +08:00
|
|
|
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
2026-09-18 10:23:27 +08:00
|
|
|
|
import { useI18n } from 'vue-i18n';
|
2026-09-18 11:04:05 +08:00
|
|
|
|
import type { PublicPhoto } from '../api/client';
|
|
|
|
|
|
import type { PhotoSequence } from '../feed/sequence';
|
2026-09-18 19:52:27 +08:00
|
|
|
|
import type { LikesController } from '../likes/useLikes';
|
2026-09-21 21:34:56 +08:00
|
|
|
|
import { layoutWaterfall, responsiveColumns, skeletonSources } from '../feed/layout';
|
2026-09-18 11:04:05 +08:00
|
|
|
|
|
2026-09-21 21:34:56 +08:00
|
|
|
|
// 观众瀑布流(Task 7;Task 8 起数据层上提;UI-02 Task 3 视觉重排):响应式列数
|
|
|
|
|
|
// 绝对定位(短列优先,条目高按 browse 宽高比例预留)+ IntersectionObserver 懒加载
|
|
|
|
|
|
// + 哨兵无限滚动 + 新照片白色提示 pill。数据生命周期(分页/去重/轮询/深链 solo)
|
|
|
|
|
|
// 在 ActivityPage 创建的 PhotoSequence composable(feed/sequence.ts)——feed 与
|
|
|
|
|
|
// 大图查看器共享同一序列,切换大图时保持浏览顺序、越过末尾自动扩展。
|
2026-09-18 10:23:27 +08:00
|
|
|
|
//
|
2026-09-21 21:34:56 +08:00
|
|
|
|
// 关键决策(沿用 Task 7 / UI-02 §4.3):
|
|
|
|
|
|
// · 响应式列数:responsiveColumns(容器宽)——<600 双列 gap4 / 600-959 三列 gap6 /
|
|
|
|
|
|
// ≥960 四列 gap8;ResizeObserver 跟踪容器宽变化后整体重排(确定性全量重算);
|
|
|
|
|
|
// · 卡片无白色厚包裹/无常驻文件名:图片边到边 + 4px 圆角,右下角点赞角标
|
|
|
|
|
|
// (半透明暗底,明亮照片上可读);点赞角标 @click.stop 独立交互不冒泡开大图;
|
2026-09-18 11:04:05 +08:00
|
|
|
|
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
|
|
|
|
|
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
2026-09-21 21:34:56 +08:00
|
|
|
|
// 退化为立即加载,功能不缺失;
|
2026-09-18 11:04:05 +08:00
|
|
|
|
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动);
|
|
|
|
|
|
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8);
|
|
|
|
|
|
// · 首屏骨架:以常见竖/横/方比例(skeletonSources)按真实瀑布流放置预留轮廓,
|
|
|
|
|
|
// 非均匀网格;宽高异常同样走 4:3 安全比例(无 NaN/无限高度)。
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
2026-09-18 11:04:05 +08:00
|
|
|
|
sequence: PhotoSequence;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
/** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */
|
|
|
|
|
|
likes?: LikesController;
|
2026-09-18 11:04:05 +08:00
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
open: [photoId: string];
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}>();
|
|
|
|
|
|
|
2026-09-21 21:34:56 +08:00
|
|
|
|
const { t, locale } = useI18n();
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
2026-09-18 11:04:05 +08:00
|
|
|
|
const photos = computed(() => props.sequence.photos);
|
|
|
|
|
|
const initialLoading = computed(() => props.sequence.initialLoading);
|
|
|
|
|
|
const initialErrorKey = computed(() => props.sequence.initialErrorKey);
|
|
|
|
|
|
const loadingMore = computed(() => props.sequence.loadingMore);
|
|
|
|
|
|
const moreFailed = computed(() => props.sequence.moreFailed);
|
|
|
|
|
|
const hasMore = computed(() => props.sequence.hasMore);
|
|
|
|
|
|
const hasNew = computed(() => props.sequence.hasNew);
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
|
|
|
|
|
const shownIds = reactive(new Set<string>());
|
|
|
|
|
|
const retriedIds = reactive(new Set<string>());
|
|
|
|
|
|
const failedIds = reactive(new Set<string>());
|
|
|
|
|
|
|
2026-09-21 21:34:56 +08:00
|
|
|
|
// —— 布局:容器宽 → 响应式列配置 → 纯函数放置(ResizeObserver 跟踪宽度变化) ——
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
const trackEl = ref<HTMLElement | null>(null);
|
|
|
|
|
|
const sentinelEl = ref<HTMLElement | null>(null);
|
|
|
|
|
|
const containerWidth = ref(0);
|
|
|
|
|
|
|
2026-09-21 21:34:56 +08:00
|
|
|
|
const columns = computed(() => responsiveColumns(containerWidth.value));
|
|
|
|
|
|
const layout = computed(() =>
|
|
|
|
|
|
layoutWaterfall(photos.value, containerWidth.value, columns.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
const skeletonLayout = computed(() =>
|
|
|
|
|
|
layoutWaterfall(skeletonSources(), containerWidth.value, columns.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const countFormatter = computed(() => new Intl.NumberFormat(locale.value));
|
|
|
|
|
|
|
|
|
|
|
|
/** 点赞角标计数(真实值:base + 乐观 pending,见 useLikes)。 */
|
|
|
|
|
|
function likeCountText(photo: PublicPhoto): string {
|
|
|
|
|
|
if (props.likes === undefined) return '';
|
|
|
|
|
|
return countFormatter.value.format(props.likes.displayFor(photo));
|
|
|
|
|
|
}
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
function measureContainer(): void {
|
|
|
|
|
|
if (trackEl.value !== null) containerWidth.value = trackEl.value.clientWidth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
trackEl,
|
|
|
|
|
|
(el) => {
|
|
|
|
|
|
resizeObserver?.disconnect();
|
|
|
|
|
|
resizeObserver = null;
|
|
|
|
|
|
if (el === null) return;
|
|
|
|
|
|
measureContainer();
|
|
|
|
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
|
|
|
|
resizeObserver = new ResizeObserver(measureContainer);
|
|
|
|
|
|
resizeObserver.observe(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ flush: 'post' },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// —— 懒加载/哨兵 IntersectionObserver ——
|
|
|
|
|
|
|
|
|
|
|
|
const cellElements = new Map<Element, string>();
|
|
|
|
|
|
let lazyObserver: IntersectionObserver | null = null;
|
|
|
|
|
|
let sentinelObserver: IntersectionObserver | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
function ensureLazyObserver(): IntersectionObserver | null {
|
|
|
|
|
|
if (lazyObserver !== null) return lazyObserver;
|
|
|
|
|
|
if (typeof IntersectionObserver === 'undefined') return null;
|
|
|
|
|
|
lazyObserver = new IntersectionObserver(
|
|
|
|
|
|
(entries) => {
|
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
|
if (!entry.isIntersecting) continue;
|
|
|
|
|
|
const photoId = cellElements.get(entry.target);
|
|
|
|
|
|
if (photoId !== undefined) shownIds.add(photoId);
|
|
|
|
|
|
lazyObserver?.unobserve(entry.target);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{ rootMargin: '400px 0px' },
|
|
|
|
|
|
);
|
|
|
|
|
|
return lazyObserver;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** v-for 函数 ref:登记 el → photoId 并纳入懒观察;IO 不可用退化立即显示。
|
|
|
|
|
|
* 按下标取 photos(卸载时 ref 收到 null 先行返回——函数 ref 在整表替换后
|
|
|
|
|
|
* 会对旧 vnode 重算,此时闭包里的数组访问必须可空)。 */
|
|
|
|
|
|
function registerCell(el: unknown, index: number): void {
|
|
|
|
|
|
if (!(el instanceof HTMLElement)) return;
|
|
|
|
|
|
const photo = photos.value[index];
|
|
|
|
|
|
if (photo === undefined) return;
|
|
|
|
|
|
cellElements.set(el, photo.photoId);
|
|
|
|
|
|
if (shownIds.has(photo.photoId)) return;
|
|
|
|
|
|
const observer = ensureLazyObserver();
|
|
|
|
|
|
if (observer === null) {
|
|
|
|
|
|
shownIds.add(photo.photoId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
observer.observe(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 移除已脱离文档的单元格映射(整表替换后 DOM 销毁的 el 不再可达)。 */
|
|
|
|
|
|
function pruneCellElements(): void {
|
|
|
|
|
|
for (const [el] of cellElements) {
|
|
|
|
|
|
if (!el.isConnected) cellElements.delete(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 11:04:05 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
photos,
|
|
|
|
|
|
() => pruneCellElements(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-18 10:23:27 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
sentinelEl,
|
|
|
|
|
|
(el) => {
|
|
|
|
|
|
sentinelObserver?.disconnect();
|
|
|
|
|
|
sentinelObserver = null;
|
|
|
|
|
|
if (el === null) return;
|
2026-09-18 11:04:05 +08:00
|
|
|
|
// IO 不可用(jsdom/旧环境)时不观察:无限滚动退化为已加载内容(不崩溃)。
|
|
|
|
|
|
if (typeof IntersectionObserver === 'undefined') return;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
sentinelObserver = new IntersectionObserver(
|
|
|
|
|
|
(entries) => {
|
2026-09-18 11:04:05 +08:00
|
|
|
|
if (entries.some((entry) => entry.isIntersecting)) void props.sequence.loadMore();
|
2026-09-18 10:23:27 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ rootMargin: '600px 0px' },
|
|
|
|
|
|
);
|
|
|
|
|
|
sentinelObserver.observe(el);
|
|
|
|
|
|
},
|
|
|
|
|
|
{ flush: 'post' },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-18 11:04:05 +08:00
|
|
|
|
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败/已下线则收起图片。 */
|
2026-09-18 10:23:27 +08:00
|
|
|
|
async function onImgError(photo: PublicPhoto): Promise<void> {
|
2026-09-18 11:04:05 +08:00
|
|
|
|
if (failedIds.has(photo.photoId)) return;
|
|
|
|
|
|
if (retriedIds.has(photo.photoId)) {
|
|
|
|
|
|
failedIds.add(photo.photoId);
|
2026-09-18 10:23:27 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-18 11:04:05 +08:00
|
|
|
|
retriedIds.add(photo.photoId);
|
|
|
|
|
|
const result = await props.sequence.refreshPhotoUrls(photo.photoId);
|
|
|
|
|
|
if (result !== 'replaced') failedIds.add(photo.photoId);
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 19:52:27 +08:00
|
|
|
|
/** 单元格点赞(Task 10):乐观 +1,不冒泡打开大图。 */
|
|
|
|
|
|
function likeCell(photo: PublicPhoto): void {
|
|
|
|
|
|
props.likes?.like(photo.photoId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 10:23:27 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
lazyObserver?.disconnect();
|
|
|
|
|
|
sentinelObserver?.disconnect();
|
|
|
|
|
|
resizeObserver?.disconnect();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="photo-feed">
|
2026-09-21 21:34:56 +08:00
|
|
|
|
<!-- 新照片提示 pill(UI-02 §4.3):接口只有 hasNew 布尔——不显示伪造数量;
|
|
|
|
|
|
点击加载合并,不强制回顶。sticky 悬浮于照片区顶部。 -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-if="hasNew"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="new-photo-bar"
|
|
|
|
|
|
@click="props.sequence.refreshFromHint()"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
|
|
|
|
<path d="M12 19V5" />
|
|
|
|
|
|
<path d="m5 12 7-7 7 7" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<span>{{ t('viewer.newPhotos') }}</span>
|
2026-09-18 10:23:27 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-09-21 21:34:56 +08:00
|
|
|
|
<!-- 首屏骨架:按真实瀑布流比例占位(非均匀网格)。trackEl 与真实瀑布流
|
|
|
|
|
|
共用(v-if 两分支同时只存在一个,ref watch 处理换锚);读屏文案走
|
|
|
|
|
|
视觉隐藏的 status。 -->
|
|
|
|
|
|
<p v-if="initialLoading" class="sr-only" role="status">{{ t('album.loading') }}</p>
|
|
|
|
|
|
<div v-if="initialLoading" ref="trackEl" class="waterfall skeleton" aria-hidden="true">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="placement in skeletonLayout.placements"
|
|
|
|
|
|
:key="placement.index"
|
|
|
|
|
|
class="cell-skeleton"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
left: `${placement.left}px`,
|
|
|
|
|
|
top: `${placement.top}px`,
|
|
|
|
|
|
width: `${skeletonLayout.columnWidth}px`,
|
|
|
|
|
|
height: `${placement.height}px`,
|
|
|
|
|
|
}"
|
|
|
|
|
|
/>
|
2026-09-18 10:23:27 +08:00
|
|
|
|
</div>
|
2026-09-21 21:34:56 +08:00
|
|
|
|
<p v-else-if="initialErrorKey !== null" class="error" role="alert">
|
|
|
|
|
|
{{ t(initialErrorKey) }}
|
|
|
|
|
|
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
|
|
|
|
|
|
</p>
|
2026-09-18 10:23:27 +08:00
|
|
|
|
|
|
|
|
|
|
<div v-else-if="photos.length === 0" class="feed">
|
|
|
|
|
|
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div ref="trackEl" class="waterfall" :style="{ height: `${layout.height}px` }">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="placement in layout.placements"
|
|
|
|
|
|
:key="photos[placement.index].photoId"
|
|
|
|
|
|
:ref="(el) => registerCell(el, placement.index)"
|
|
|
|
|
|
class="cell"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
left: `${placement.left}px`,
|
|
|
|
|
|
top: `${placement.top}px`,
|
|
|
|
|
|
width: `${layout.columnWidth}px`,
|
|
|
|
|
|
height: `${placement.height}px`,
|
|
|
|
|
|
}"
|
2026-09-18 11:04:05 +08:00
|
|
|
|
@click="emit('open', photos[placement.index].photoId)"
|
2026-09-18 10:23:27 +08:00
|
|
|
|
>
|
|
|
|
|
|
<img
|
|
|
|
|
|
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
|
|
|
|
|
|
class="cell-img"
|
|
|
|
|
|
:src="photos[placement.index].thumb.url"
|
|
|
|
|
|
:alt="`${t('route.photoLabel')} ${placement.index + 1}`"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
decoding="async"
|
|
|
|
|
|
@error="onImgError(photos[placement.index])"
|
|
|
|
|
|
>
|
2026-09-21 21:34:56 +08:00
|
|
|
|
<!-- 点赞角标:独立交互区(@click.stop),不随图片点击打开大图。 -->
|
2026-09-18 19:52:27 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-if="likes !== undefined"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="cell-like"
|
|
|
|
|
|
:aria-label="t('viewer.like')"
|
|
|
|
|
|
@click.stop="likeCell(photos[placement.index])"
|
|
|
|
|
|
>
|
2026-09-21 21:34:56 +08:00
|
|
|
|
<svg
|
|
|
|
|
|
:key="likes.popNonce(photos[placement.index].photoId)"
|
|
|
|
|
|
class="heart"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
focusable="false"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path d="M12 21.35 10.55 20.03C5.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="count">{{ likeCountText(photos[placement.index]) }}</span>
|
2026-09-18 19:52:27 +08:00
|
|
|
|
</button>
|
2026-09-18 10:23:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
|
|
|
|
|
|
<div v-else-if="moreFailed" class="more-failed">
|
2026-09-18 11:04:05 +08:00
|
|
|
|
<button type="button" @click="props.sequence.loadMore">{{ t('album.retry') }}</button>
|
2026-09-18 10:23:27 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="hasMore" ref="sentinelEl" class="sentinel" aria-hidden="true" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-09-21 21:34:56 +08:00
|
|
|
|
/* UI-02 Task 3(§4.3):token 经祖先 .viewer-shell 提供;独立挂载(无 shell)
|
|
|
|
|
|
* 时回退同值。照片区边到边,仅 4px 外沿内边距(--viewer-pad-photo)。 */
|
2026-09-18 10:23:27 +08:00
|
|
|
|
.photo-feed {
|
|
|
|
|
|
position: relative;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
padding: var(--viewer-pad-photo, 4px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 加载状态仅骨架可见时供读屏(视觉由骨架承载)。 */
|
|
|
|
|
|
.sr-only {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 1px;
|
|
|
|
|
|
height: 1px;
|
|
|
|
|
|
margin: -1px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
clip: rect(0 0 0 0);
|
|
|
|
|
|
clip-path: inset(50%);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
white-space: nowrap;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
/* —— 新照片提示 pill:白底蓝字蓝icon,悬浮照片区顶部 —— */
|
2026-09-18 10:23:27 +08:00
|
|
|
|
.new-photo-bar {
|
|
|
|
|
|
position: sticky;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
top: 10px;
|
|
|
|
|
|
z-index: 2;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
width: fit-content;
|
|
|
|
|
|
margin: 0 auto 12px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: var(--viewer-radius-pill, 20px);
|
|
|
|
|
|
background: var(--viewer-surface, #fff);
|
|
|
|
|
|
box-shadow: 0 2px 10px rgba(23, 32, 51, 0.16);
|
|
|
|
|
|
color: var(--viewer-primary, #1a73e8);
|
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
|
font-weight: 600;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
.new-photo-bar svg {
|
|
|
|
|
|
width: 14px;
|
|
|
|
|
|
height: 14px;
|
|
|
|
|
|
fill: none;
|
|
|
|
|
|
stroke: currentColor;
|
|
|
|
|
|
stroke-width: 2.2;
|
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
|
stroke-linejoin: round;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
.new-photo-bar:focus-visible {
|
|
|
|
|
|
outline: 2px solid var(--viewer-primary, #1a73e8);
|
|
|
|
|
|
outline-offset: 2px;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
|
2026-09-18 10:23:27 +08:00
|
|
|
|
.waterfall {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
/* —— 照片卡片:无白色厚卡片/无文件名;图片边到边 + 4px 圆角 —— */
|
2026-09-18 10:23:27 +08:00
|
|
|
|
.cell {
|
|
|
|
|
|
position: absolute;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
border-radius: var(--viewer-radius-img, 4px);
|
2026-09-18 10:23:27 +08:00
|
|
|
|
overflow: hidden;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
background: var(--viewer-divider, #e5eaf1);
|
2026-09-18 11:04:05 +08:00
|
|
|
|
cursor: pointer;
|
2026-09-18 10:23:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
.cell-img {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
/* —— 点赞角标:右下角半透明暗底(明亮照片上可读),白心 + 白色计数 —— */
|
2026-09-18 19:52:27 +08:00
|
|
|
|
.cell-like {
|
|
|
|
|
|
position: absolute;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
right: 6px;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
bottom: 6px;
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
border: none;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
border-radius: var(--viewer-radius-pill, 20px);
|
|
|
|
|
|
background: linear-gradient(to right, rgba(14, 18, 24, 0.42), rgba(14, 18, 24, 0.62));
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
padding: 3px 8px;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
font-size: 0.75rem;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
line-height: 1;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cell-like .heart {
|
2026-09-21 21:34:56 +08:00
|
|
|
|
width: 16px;
|
|
|
|
|
|
height: 16px;
|
|
|
|
|
|
fill: #fff;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
animation: heart-pop 400ms ease-out;
|
|
|
|
|
|
}
|
|
|
|
|
|
.cell-like .count {
|
2026-09-21 21:34:56 +08:00
|
|
|
|
color: #fff;
|
2026-09-18 19:52:27 +08:00
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes heart-pop {
|
|
|
|
|
|
0% {
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
40% {
|
|
|
|
|
|
transform: scale(1.6);
|
|
|
|
|
|
}
|
|
|
|
|
|
100% {
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.viewer-reduced-motion .cell-like .heart {
|
|
|
|
|
|
animation: none;
|
|
|
|
|
|
}
|
2026-09-21 21:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
/* —— 首屏骨架:真实瀑布流轮廓(比例来自 skeletonSources),轻呼吸动画 —— */
|
|
|
|
|
|
.cell-skeleton {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
border-radius: var(--viewer-radius-img, 4px);
|
|
|
|
|
|
background: var(--viewer-divider, #e5eaf1);
|
|
|
|
|
|
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes skeleton-pulse {
|
|
|
|
|
|
0%,
|
|
|
|
|
|
100% {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
50% {
|
|
|
|
|
|
opacity: 0.55;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.viewer-reduced-motion .cell-skeleton {
|
|
|
|
|
|
animation: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* —— 空态/错误/翻页尾部 —— */
|
|
|
|
|
|
.feed {
|
|
|
|
|
|
padding: 48px 16px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.empty-notice {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--viewer-text-secondary, #647084);
|
|
|
|
|
|
}
|
|
|
|
|
|
.error {
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
}
|
|
|
|
|
|
.error button,
|
|
|
|
|
|
.more-failed button {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
border: 1px solid var(--viewer-divider, #e5eaf1);
|
|
|
|
|
|
border-radius: var(--viewer-radius-pill, 20px);
|
|
|
|
|
|
background: var(--viewer-surface, #fff);
|
|
|
|
|
|
color: var(--viewer-primary, #1a73e8);
|
|
|
|
|
|
padding: 8px 20px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.more-failed {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.status {
|
|
|
|
|
|
color: var(--viewer-text-secondary, #647084);
|
|
|
|
|
|
}
|
|
|
|
|
|
.more-status {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
|
}
|
2026-09-18 10:23:27 +08:00
|
|
|
|
.end-note {
|
|
|
|
|
|
text-align: center;
|
2026-09-21 21:34:56 +08:00
|
|
|
|
color: var(--viewer-text-secondary, #647084);
|
2026-09-18 10:23:27 +08:00
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
padding: 12px 0;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.sentinel {
|
|
|
|
|
|
height: 1px;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|