|
|
@@ -1,18 +1,290 @@
|
|
|
<template>
|
|
|
<div class="collection-page">
|
|
|
- <TopBar />
|
|
|
+ <TopBar @search="onSearch" />
|
|
|
|
|
|
- <el-scrollbar class="scroll-container" scroll-y>
|
|
|
- <div class="scroll-content">
|
|
|
- <Item />
|
|
|
- </div>
|
|
|
- </el-scrollbar>
|
|
|
+ <div
|
|
|
+ ref="galleryRef"
|
|
|
+ class="gallery"
|
|
|
+ :class="{ 'is-empty': isSearchEmpty }"
|
|
|
+ @mouseenter="onMouseEnter"
|
|
|
+ @mouseleave="onMouseLeave"
|
|
|
+ >
|
|
|
+ <p v-if="isSearchEmpty" class="search-empty">暂无数据</p>
|
|
|
+ <template v-else>
|
|
|
+ <div
|
|
|
+ v-for="(column, colIndex) in columns"
|
|
|
+ :key="colIndex"
|
|
|
+ class="waterfall-item"
|
|
|
+ :style="columnStyle(colIndex)"
|
|
|
+ >
|
|
|
+ <Item
|
|
|
+ v-for="item in column"
|
|
|
+ :key="item.id"
|
|
|
+ class="waterfall"
|
|
|
+ :src="item.src"
|
|
|
+ :name="item.name"
|
|
|
+ :model-url="item.modelUrl"
|
|
|
+ :parent-index="item.parentIndex"
|
|
|
+ :index="item.index"
|
|
|
+ :frame-type="item.frameType"
|
|
|
+ :color="item.color"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Toolbars
|
|
|
+ :show-back-top="showBackTop"
|
|
|
+ @back-top="scrollToTop"
|
|
|
+ @classify-change="onClassifyChange"
|
|
|
+ />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
+import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
|
import TopBar from "@/components/TopBar/index.vue";
|
|
|
+import Toolbars from "./components/Toolbars/index.vue";
|
|
|
import Item from "./components/Item/index.vue";
|
|
|
+import collectionData from "@/utils/data.json";
|
|
|
+import { resolveImagePath } from "@/utils/image";
|
|
|
+
|
|
|
+/** 接近正方形的宽高比阈值 */
|
|
|
+const SQUARE_RATIO = 0.85;
|
|
|
+/** 瀑布流列数(参考故宫典藏新选一般尺寸) */
|
|
|
+const COLUMN_COUNT = 7;
|
|
|
+/** 自动滚动速度(px / 帧,约 60fps) */
|
|
|
+const SCROLL_SPEED = 1.5;
|
|
|
+
|
|
|
+const galleryRef = ref(null);
|
|
|
+/** 全量藏品数据(初始化后不变) */
|
|
|
+const catalogItems = ref([]);
|
|
|
+/** 空字符串代表全部 */
|
|
|
+const activeCategory = ref("");
|
|
|
+/** 搜索关键词 */
|
|
|
+const searchKeyword = ref("");
|
|
|
+const scrolling = ref(true);
|
|
|
+const scrollTop = ref(0);
|
|
|
+
|
|
|
+let rafId = 0;
|
|
|
+let lastFrameTime = 0;
|
|
|
+let isBackTopAnimating = false;
|
|
|
+
|
|
|
+/** 按分类、关键词筛选 */
|
|
|
+const filteredItems = computed(() => {
|
|
|
+ let list = catalogItems.value;
|
|
|
+
|
|
|
+ if (activeCategory.value) {
|
|
|
+ list = list.filter((item) => item.categoryLabel === activeCategory.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ const keyword = searchKeyword.value.trim();
|
|
|
+ if (keyword) {
|
|
|
+ list = list.filter((item) => item.name.includes(keyword));
|
|
|
+ }
|
|
|
+
|
|
|
+ return list;
|
|
|
+});
|
|
|
+
|
|
|
+const isSearchEmpty = computed(
|
|
|
+ () => searchKeyword.value.trim() && filteredItems.value.length === 0,
|
|
|
+);
|
|
|
+
|
|
|
+/** 为当前筛选结果分配边框颜色 */
|
|
|
+const displayItems = computed(() => {
|
|
|
+ const source = filteredItems.value;
|
|
|
+ const list = [];
|
|
|
+
|
|
|
+ for (let i = 0; i < source.length; i++) {
|
|
|
+ const item = source[i];
|
|
|
+ const forbidden = new Set();
|
|
|
+ if (i > 0) forbidden.add(list[i - 1].color);
|
|
|
+ if (i >= COLUMN_COUNT) forbidden.add(list[i - COLUMN_COUNT].color);
|
|
|
+ const candidates = [1, 2, 3].filter((c) => !forbidden.has(c));
|
|
|
+ const color =
|
|
|
+ candidates[i % candidates.length] ?? pickColor(list[i - 1]?.color);
|
|
|
+
|
|
|
+ list.push({
|
|
|
+ ...item,
|
|
|
+ id: `${item.categoryLabel}-${i}-${item.name}`,
|
|
|
+ color,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return list;
|
|
|
+});
|
|
|
+
|
|
|
+/** 按列分配瀑布流 */
|
|
|
+const columns = computed(() => {
|
|
|
+ const cols = Array.from({ length: COLUMN_COUNT }, () => []);
|
|
|
+ displayItems.value.forEach((item, index) => {
|
|
|
+ cols[index % COLUMN_COUNT].push(item);
|
|
|
+ });
|
|
|
+ return cols;
|
|
|
+});
|
|
|
+
|
|
|
+const showBackTop = computed(() => scrollTop.value > 0);
|
|
|
+
|
|
|
+function scrollToTop() {
|
|
|
+ const el = galleryRef.value;
|
|
|
+ if (!el || el.scrollTop <= 0) return;
|
|
|
+
|
|
|
+ scrolling.value = false;
|
|
|
+ isBackTopAnimating = true;
|
|
|
+ cancelAnimationFrame(rafId);
|
|
|
+
|
|
|
+ const start = el.scrollTop;
|
|
|
+ const duration = 500;
|
|
|
+ const startTime = performance.now();
|
|
|
+
|
|
|
+ function animate(now) {
|
|
|
+ const progress = Math.min((now - startTime) / duration, 1);
|
|
|
+ const eased = 1 - (1 - progress) ** 3;
|
|
|
+ el.scrollTop = start * (1 - eased);
|
|
|
+ scrollTop.value = el.scrollTop;
|
|
|
+
|
|
|
+ if (progress < 1) {
|
|
|
+ rafId = requestAnimationFrame(animate);
|
|
|
+ } else {
|
|
|
+ el.scrollTop = 0;
|
|
|
+ scrollTop.value = 0;
|
|
|
+ isBackTopAnimating = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ rafId = requestAnimationFrame(animate);
|
|
|
+}
|
|
|
+
|
|
|
+function columnStyle(colIndex) {
|
|
|
+ // 奇数列随滚动产生轻微视差(参考故宫 .gallery updateWaterfall)
|
|
|
+ if (colIndex % 2 === 0) return { marginTop: "0px" };
|
|
|
+ const offset = Math.round(scrollTop.value * 0.25 * -1);
|
|
|
+ return { marginTop: `${offset}px` };
|
|
|
+}
|
|
|
+
|
|
|
+function getFrameType(width, height) {
|
|
|
+ const ratio = Math.min(width, height) / Math.max(width, height);
|
|
|
+ return ratio >= SQUARE_RATIO ? "sm" : "lg";
|
|
|
+}
|
|
|
+
|
|
|
+function pickColor(prevColor) {
|
|
|
+ const colors = [1, 2, 3].filter((c) => c !== prevColor);
|
|
|
+ return colors[Math.floor(Math.random() * colors.length)];
|
|
|
+}
|
|
|
+
|
|
|
+function loadImageSize(src) {
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const img = new Image();
|
|
|
+ img.onload = () =>
|
|
|
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
|
+ img.onerror = () => resolve({ width: 1, height: 1 });
|
|
|
+ img.src = src;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+async function buildCatalog() {
|
|
|
+ const sourceItems = collectionData.flatMap((category, parentIndex) =>
|
|
|
+ (category.children || []).map((child, index) => ({
|
|
|
+ ...child,
|
|
|
+ parentIndex,
|
|
|
+ index,
|
|
|
+ categoryLabel: category.label,
|
|
|
+ src: resolveImagePath(child.imgPath),
|
|
|
+ })),
|
|
|
+ );
|
|
|
+
|
|
|
+ const uniqueSrcs = [...new Set(sourceItems.map((item) => item.src))];
|
|
|
+ const sizeMap = new Map();
|
|
|
+ await Promise.all(
|
|
|
+ uniqueSrcs.map(async (src) => {
|
|
|
+ sizeMap.set(src, await loadImageSize(src));
|
|
|
+ }),
|
|
|
+ );
|
|
|
+
|
|
|
+ catalogItems.value = sourceItems.map((item) => {
|
|
|
+ const { width, height } = sizeMap.get(item.src) ?? { width: 1, height: 1 };
|
|
|
+ return {
|
|
|
+ name: item.name,
|
|
|
+ src: item.src,
|
|
|
+ parentIndex: item.parentIndex,
|
|
|
+ index: item.index,
|
|
|
+ categoryLabel: item.categoryLabel,
|
|
|
+ frameType: getFrameType(width, height),
|
|
|
+ modelUrl: item.modelUrl || "",
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function resetGalleryScroll() {
|
|
|
+ const el = galleryRef.value;
|
|
|
+ if (el) {
|
|
|
+ el.scrollTop = 0;
|
|
|
+ scrollTop.value = 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function onClassifyChange(label) {
|
|
|
+ activeCategory.value = label;
|
|
|
+ resetGalleryScroll();
|
|
|
+}
|
|
|
+
|
|
|
+function onSearch(keyword) {
|
|
|
+ searchKeyword.value = keyword;
|
|
|
+ resetGalleryScroll();
|
|
|
+}
|
|
|
+
|
|
|
+function onMouseEnter() {
|
|
|
+ scrolling.value = false;
|
|
|
+}
|
|
|
+
|
|
|
+function onMouseLeave() {
|
|
|
+ if (isBackTopAnimating) return;
|
|
|
+ scrolling.value = true;
|
|
|
+ lastFrameTime = performance.now();
|
|
|
+ tick();
|
|
|
+}
|
|
|
+
|
|
|
+function tick() {
|
|
|
+ const el = galleryRef.value;
|
|
|
+ if (!el || !scrolling.value) return;
|
|
|
+
|
|
|
+ const now = performance.now();
|
|
|
+ const deltaTime = now - (lastFrameTime || now);
|
|
|
+ lastFrameTime = now;
|
|
|
+
|
|
|
+ if (el.scrollHeight > el.clientHeight + 1) {
|
|
|
+ const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
|
+ if (!atBottom) {
|
|
|
+ let step = SCROLL_SPEED;
|
|
|
+ if (deltaTime < 16.67) {
|
|
|
+ step = SCROLL_SPEED * (deltaTime / 16.67);
|
|
|
+ }
|
|
|
+ el.scrollTop += step;
|
|
|
+ scrollTop.value = el.scrollTop;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ rafId = requestAnimationFrame(tick);
|
|
|
+}
|
|
|
+
|
|
|
+function onScroll() {
|
|
|
+ const el = galleryRef.value;
|
|
|
+ if (!el) return;
|
|
|
+ scrollTop.value = el.scrollTop;
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(async () => {
|
|
|
+ await buildCatalog();
|
|
|
+ const el = galleryRef.value;
|
|
|
+ el?.addEventListener("scroll", onScroll, { passive: true });
|
|
|
+ lastFrameTime = performance.now();
|
|
|
+ rafId = requestAnimationFrame(tick);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ cancelAnimationFrame(rafId);
|
|
|
+ galleryRef.value?.removeEventListener("scroll", onScroll);
|
|
|
+});
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|