chenlei 3 тижнів тому
батько
коміт
3af3c8c000

+ 1 - 3
package.json

@@ -5,9 +5,7 @@
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "dev:pano": "pnpm --dir pano-viewer dev",
-    "build:pano": "pnpm --dir pano-viewer build",
-    "build": "pnpm build:pano && vite build",
+    "build": "vite build",
     "preview": "vite preview"
   },
   "dependencies": {

BIN
src/assets/fonts/FZWBJW.TTF


BIN
src/assets/images/1.png


BIN
src/assets/images/2.png


BIN
src/assets/images/back.png


BIN
src/assets/images/btn.png


BIN
src/assets/images/classify-bg.png


BIN
src/assets/images/icon.png


BIN
src/assets/images/img1.png


BIN
src/assets/images/img2.png


+ 4 - 0
src/assets/main.css

@@ -71,6 +71,10 @@ iframe {
   font-family: "SourceHanSerifSC-Regular";
   src: url("@/assets/fonts/SourceHanSerifCN-Medium.otf");
 }
+@font-face {
+  font-family: "FZQKBYSJW--GB1-0";
+  src: url("@/assets/fonts/FZWBJW.TTF");
+}
 
 .limit-line {
   display: -webkit-box;

+ 1 - 0
src/components/TopBar/index.scss

@@ -10,6 +10,7 @@
   justify-content: space-between;
   padding: 0 utils.vw-calc(32) utils.vh-calc(9) utils.vw-calc(47);
   height: utils.vh-calc(108);
+  font-family: "FZQKBYSJW--GB1-0";
   background: url("@/assets/images/head-bg.png") no-repeat center / cover;
   z-index: 998;
 

+ 18 - 3
src/components/TopBar/index.vue

@@ -10,14 +10,29 @@
     <div class="right">
       <div class="search">
         <p>文物名称</p>
-        <input type="text" placeholder="输入您想查看的文物名称" />
-        <div class="search-btn">搜索</div>
+        <input
+          v-model="keyword"
+          type="text"
+          placeholder="输入您想查看的文物名称"
+          @keyup.enter="handleSearch"
+        />
+        <div class="search-btn" @click="handleSearch">搜索</div>
       </div>
     </div>
   </div>
 </template>
 
-<script setup></script>
+<script setup>
+import { ref } from "vue";
+
+const emit = defineEmits(["search"]);
+
+const keyword = ref("");
+
+function handleSearch() {
+  emit("search", keyword.value.trim());
+}
+</script>
 
 <style lang="scss" scoped>
 @use "./index.scss";

+ 5 - 0
src/router/index.js

@@ -13,6 +13,11 @@ const router = createRouter({
       name: "collection",
       component: () => import("../views/Collection/index.vue"),
     },
+    {
+      path: "/model/:parentIndex/:index",
+      name: "model",
+      component: () => import("../views/Model/index.vue"),
+    },
   ],
 });
 

+ 19 - 0
src/utils/collection.js

@@ -0,0 +1,19 @@
+import collectionData from "./data.json";
+import { resolveImagePath } from "./image";
+
+export function getCollectionDetail(parentIndex, index) {
+  const category = collectionData[parentIndex];
+  if (!category) return null;
+
+  const item = category.children?.[index];
+  if (!item) return null;
+
+  return {
+    ...item,
+    parentIndex,
+    index,
+    categoryLabel: category.label,
+    categoryImg: resolveImagePath(category.imgPath),
+    src: resolveImagePath(item.imgPath),
+  };
+}

Різницю між файлами не показано, бо вона завелика
+ 2314 - 0
src/utils/data.json


+ 4 - 0
src/utils/image.js

@@ -0,0 +1,4 @@
+export function resolveImagePath(imgPath) {
+  if (!imgPath) return "";
+  return new URL(`../assets/${imgPath}`, import.meta.url).href;
+}

+ 145 - 10
src/views/Collection/components/Item/index.vue

@@ -1,30 +1,165 @@
 <template>
-  <div class="collection-item">
-    <img src="@/assets/images/1.png" draggable="false" />
+  <div
+    class="collection-item"
+    :class="{ sm: frameType === 'sm', disabled: !modelUrl }"
+    :style="{ backgroundImage: `url(${frameSrc})` }"
+    @click="handleClick"
+  >
+    <img v-lazy="src" draggable="false" alt="" />
+
+    <p class="name">{{ name }}</p>
   </div>
 </template>
 
+<script setup>
+import { computed } from "vue";
+import { useRouter } from "vue-router";
+
+const props = defineProps({
+  src: {
+    type: String,
+    required: true,
+  },
+  name: {
+    type: String,
+    default: "",
+  },
+  modelUrl: {
+    type: String,
+    default: "",
+  },
+  parentIndex: {
+    type: Number,
+    required: true,
+  },
+  index: {
+    type: Number,
+    required: true,
+  },
+  /** lg: 长方形边框;sm: 接近正方形边框 */
+  frameType: {
+    type: String,
+    default: "lg",
+    validator: (v) => ["lg", "sm"].includes(v),
+  },
+  /** 边框颜色:1 / 2 / 3 */
+  color: {
+    type: Number,
+    default: 1,
+    validator: (v) => [1, 2, 3].includes(v),
+  },
+});
+
+const router = useRouter();
+
+const frameModules = import.meta.glob("./images/*.{png,jpg}", {
+  eager: true,
+  import: "default",
+});
+
+const frameSrc = computed(
+  () => frameModules[`./images/${props.frameType}-${props.color}.png`],
+);
+
+function handleClick() {
+  if (!props.modelUrl) return;
+  router.push({
+    name: "model",
+    params: {
+      parentIndex: props.parentIndex,
+      index: props.index,
+    },
+  });
+}
+</script>
+
 <style lang="scss" scoped>
+@use "@/assets/utils.scss";
+
 .collection-item {
   position: relative;
   display: flex;
   align-items: center;
   justify-content: center;
   cursor: pointer;
-  padding: 30px;
-  width: 250px;
-  height: 373px;
-  background: url("./images/lg-1.png") repeat center / cover;
+  width: 100%;
+  overflow: hidden;
+  aspect-ratio: 250 / 373;
+  padding: utils.vw-calc(30);
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 100% 100%;
 
-  &.sm {
-    width: 250px;
-    height: 266px;
-    background-image: url("./images/sm-1.png");
+  &::before {
+    content: "";
+    position: absolute;
+    top: utils.vh-calc(23);
+    left: utils.vw-calc(2);
+    z-index: 1;
+    width: utils.vw-calc(84);
+    height: utils.vw-calc(84);
+    background: url("@/assets/images/icon.png") no-repeat center / cover;
+    opacity: 0;
+    transform: scale(0.85);
+    transition:
+      opacity 0.3s ease,
+      transform 0.3s cubic-bezier(0.34, 1.2, 0.64, 1);
+    pointer-events: none;
+  }
+
+  .name {
+    position: absolute;
+    top: utils.vh-calc(65);
+    left: utils.vw-calc(26);
+    z-index: 1;
+    font-size: utils.vw-calc(30);
+    color: #fff;
+    font-family: "FZQKBYSJW--GB1-0";
+    writing-mode: vertical-rl;
+    text-wrap: nowrap;
+    height: 70%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    opacity: 0;
+    transform: translateX(utils.vw-calc(-12));
+    transition:
+      opacity 0.35s ease 0.06s,
+      transform 0.35s ease 0.06s;
+    pointer-events: none;
   }
+
   img {
     width: auto;
+    height: auto;
     max-width: 100%;
+    max-height: 100%;
     object-fit: contain;
+    transform: translateX(0);
+    transition: transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
+  }
+
+  &:hover {
+    &::before {
+      opacity: 1;
+      transform: scale(1);
+    }
+
+    .name {
+      opacity: 1;
+      transform: translateX(0);
+    }
+
+    img {
+      transform: translateX(utils.vw-calc(20));
+    }
+  }
+
+  &.sm {
+    aspect-ratio: 252 / 266;
+  }
+
+  &.disabled {
+    cursor: default;
   }
 }
 </style>

BIN
src/views/Collection/components/Toolbars/images/act-card.png


BIN
src/views/Collection/components/Toolbars/images/border.png


BIN
src/views/Collection/components/Toolbars/images/card.png


BIN
src/views/Collection/components/Toolbars/images/icon.png


BIN
src/views/Collection/components/Toolbars/images/title.png


+ 284 - 0
src/views/Collection/components/Toolbars/index.vue

@@ -0,0 +1,284 @@
+<template>
+  <div ref="toolbarsRef" class="toolbars-root">
+    <div class="toolbars" :class="{ 'show-classify': showClassify }">
+      <div class="toolbar-filter" @click="toggleClassify">
+        <i class="icon-filter" />
+        <span>浏览条件</span>
+
+        <div class="toolbar-filter-model">{{ activeLabel || "全部" }}</div>
+      </div>
+
+      <i
+        v-show="showBackTop"
+        class="toolbar-backtop"
+        role="button"
+        aria-label="返回顶部"
+        @click="emit('back-top')"
+      />
+    </div>
+
+    <Transition name="classify-slide">
+      <div v-show="showClassify" class="classify">
+        <img class="classify-title" src="./images/title.png" alt="分类" />
+        <Swiper
+          class="classify-list"
+          :modules="swiperModules"
+          :slides-per-view="'auto'"
+          :space-between="spaceBetween"
+          :free-mode="{ enabled: true, momentum: true }"
+          @swiper="onSwiper"
+        >
+          <SwiperSlide
+            v-for="item in classifyList"
+            :key="item.label"
+            :class="{ active: activeLabel === item.label }"
+            @click="selectClassify(item.label)"
+          >
+            <div class="classify-item">
+              <span class="classify-item-label">{{ item.label }}</span>
+            </div>
+          </SwiperSlide>
+        </Swiper>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script setup>
+import { nextTick, ref, watch } from "vue";
+import { onClickOutside } from "@vueuse/core";
+import { Swiper, SwiperSlide } from "swiper/vue";
+import { FreeMode } from "swiper/modules";
+import collectionData from "@/utils/data.json";
+import { resolveImagePath } from "@/utils/image";
+import "swiper/css";
+
+defineProps({
+  showBackTop: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const emit = defineEmits(["back-top", "classify-change"]);
+
+const swiperModules = [FreeMode];
+const spaceBetween = Math.round((window.innerWidth * 70) / 1920);
+
+const showClassify = ref(false);
+/** 空字符串代表全部 */
+const activeLabel = ref("");
+const toolbarsRef = ref(null);
+const swiperInstance = ref(null);
+
+const classifyList = collectionData.map((item) => ({
+  label: item.label,
+  img: resolveImagePath(item.imgPath),
+}));
+
+function onSwiper(swiper) {
+  swiperInstance.value = swiper;
+}
+
+function toggleClassify() {
+  showClassify.value = !showClassify.value;
+}
+
+function selectClassify(label) {
+  const nextLabel = activeLabel.value === label ? "" : label;
+  activeLabel.value = nextLabel;
+  showClassify.value = false;
+  emit("classify-change", nextLabel);
+}
+
+watch(showClassify, async (visible) => {
+  if (!visible) return;
+  await nextTick();
+  swiperInstance.value?.update();
+});
+
+onClickOutside(toolbarsRef, () => {
+  showClassify.value = false;
+});
+</script>
+
+<style lang="scss" scoped>
+@use "@/assets/utils.scss";
+
+.toolbars-root {
+  position: relative;
+  z-index: 998;
+}
+
+.classify {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: utils.vh-calc(292);
+  padding: utils.vh-calc(45) 0 0 utils.vw-calc(90);
+  background: url("@/assets/images/classify-bg.png") no-repeat top center /
+    cover;
+
+  &::after {
+    content: "";
+    position: absolute;
+    left: utils.vw-calc(90);
+    right: 0;
+    bottom: utils.vh-calc(50);
+    height: utils.vh-calc(4);
+    background: url("./images/border.png") no-repeat center / cover;
+  }
+
+  &-title {
+    position: absolute;
+    top: utils.vh-calc(40);
+    left: utils.vw-calc(95);
+    width: utils.vh-calc(124);
+    height: utils.vh-calc(52);
+  }
+
+  &-list {
+    margin-left: utils.vw-calc(205);
+    padding-right: utils.vw-calc(90);
+    width: calc(100% - utils.vw-calc(205));
+
+    :deep(.swiper),
+    :deep(.swiper-wrapper) {
+      overflow: visible;
+    }
+
+    :deep(.swiper-slide) {
+      padding-bottom: utils.vh-calc(54);
+      width: utils.vh-calc(45);
+      overflow: visible;
+
+      &::after {
+        content: "";
+        position: absolute;
+        left: 50%;
+        bottom: 0;
+        transform: translateX(-50%);
+        width: utils.vh-calc(54);
+        height: utils.vh-calc(54);
+        background: url("./images/icon.png") no-repeat center / contain;
+      }
+      &.active .classify-item {
+        background-image: url("./images/act-card.png");
+
+        &-label {
+          color: #ffeac8;
+        }
+      }
+    }
+  }
+
+  &-item {
+    width: utils.vh-calc(45);
+    height: utils.vh-calc(170);
+    background: url("./images/card.png") no-repeat center / contain;
+    cursor: pointer;
+    overflow: visible;
+    transition: transform 0.2s ease;
+
+    &:hover {
+      transform: translateY(utils.vh-calc(-4));
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      border-radius: utils.vh-calc(8);
+    }
+
+    &-label {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 100%;
+      height: 100%;
+      padding: utils.vw-calc(8);
+      font-size: utils.vw-calc(20);
+      font-family: "FZQKBYSJW--GB1-0";
+      writing-mode: vertical-lr;
+      color: #5c431c;
+      text-align: center;
+    }
+  }
+}
+
+.classify-slide-enter-active,
+.classify-slide-leave-active {
+  transition:
+    transform 0.3s ease,
+    opacity 0.3s ease;
+}
+
+.classify-slide-enter-from,
+.classify-slide-leave-to {
+  transform: translateY(100%);
+  opacity: 0;
+}
+
+.toolbars {
+  position: fixed;
+  display: flex;
+  align-items: center;
+  gap: utils.vw-calc(10);
+  right: utils.vw-calc(63);
+  bottom: utils.vh-calc(30);
+  transition: bottom 0.3s ease;
+
+  &.show-classify {
+    bottom: utils.vh-calc(294);
+  }
+
+  .toolbar-backtop {
+    display: block;
+    flex-shrink: 0;
+    width: utils.vh-calc(58);
+    height: utils.vh-calc(58);
+    cursor: pointer;
+    background: url("@/assets/images/icon-top.png") no-repeat center / cover;
+  }
+
+  .toolbar-filter {
+    display: flex;
+    align-items: center;
+    padding: 0 utils.vw-calc(10) 0 utils.vw-calc(24);
+    height: utils.vh-calc(52);
+    border-radius: 25px;
+    border: 1px solid #c3aa95;
+    background: white;
+    font-size: utils.vw-calc(17);
+    font-family: "FZQKBYSJW--GB1-0";
+    cursor: pointer;
+    user-select: none;
+
+    &::before {
+      content: "";
+      display: block;
+      width: utils.vh-calc(32);
+      height: utils.vh-calc(19);
+      background: url("@/assets/images/icon-search.png") no-repeat center /
+        cover;
+    }
+
+    span {
+      padding: 0 utils.vw-calc(50) 0 utils.vw-calc(12);
+    }
+
+    &-model {
+      padding: 0 utils.vw-calc(10);
+      min-width: utils.vw-calc(112);
+      height: utils.vh-calc(38);
+      text-align: center;
+      border-radius: 25px;
+      color: white;
+      background: #993418;
+      line-height: utils.vh-calc(38);
+    }
+  }
+}
+</style>

+ 37 - 6
src/views/Collection/index.scss

@@ -1,4 +1,4 @@
-@use "../../assets/utils.scss";
+@use "@/assets/utils.scss";
 
 .collection-page {
   position: absolute;
@@ -6,14 +6,45 @@
   padding-top: utils.vh-calc(108 + 13);
   background: url("@/assets/images/bg.jpg") no-repeat center top / cover;
 
-  .scroll-container {
+  .gallery {
+    display: flex;
+    align-items: flex-start;
     width: 100%;
     height: 100%;
+    overflow-x: hidden;
+    overflow-y: auto;
+    padding: 0 utils.vw-calc(48);
+    gap: utils.vw-calc(11);
+    scrollbar-width: none;
+
+    &.is-empty {
+      align-items: center;
+      justify-content: center;
+    }
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
+
+  .search-empty {
+    font-size: utils.vw-calc(24);
+    font-family: "FZQKBYSJW--GB1-0";
+    color: #958474;
+    user-select: none;
   }
-  .scroll-content {
-    display: grid;
-    grid-template-columns: repeat(7, 1fr);
+
+  .waterfall-item {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
     gap: utils.vw-calc(11);
-    padding: 0 utils.vw-calc(48);
+    padding-bottom: utils.vw-calc(30);
+    transition: margin-top 0.3s ease;
+  }
+
+  .waterfall {
+    width: 100%;
   }
 }

+ 278 - 6
src/views/Collection/index.vue

@@ -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>

+ 100 - 0
src/views/Model/index.vue

@@ -0,0 +1,100 @@
+<template>
+  <div class="model-page">
+    <iframe
+      v-if="detail?.modelUrl"
+      :src="detail.modelUrl"
+      title="3D模型"
+      allowfullscreen
+    />
+
+    <div class="model-info">
+      <h3>{{ detail?.name }}</h3>
+      <p>级别:{{ detail?.level }}</p>
+      <p>年代:{{ detail?.categoryLabel }}</p>
+    </div>
+
+    <i class="icon-close" @click="$router.back()"></i>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useRoute } from "vue-router";
+import { getCollectionDetail } from "@/utils/collection";
+
+const route = useRoute();
+
+const detail = computed(() => {
+  const parentIndex = Number(route.params.parentIndex);
+  const index = Number(route.params.index);
+  if (Number.isNaN(parentIndex) || Number.isNaN(index)) return null;
+  return getCollectionDetail(parentIndex, index);
+});
+</script>
+
+<style lang="scss" scoped>
+@use "@/assets/utils.scss";
+
+.model-page {
+  position: fixed;
+  inset: 0;
+
+  &::before {
+    content: "";
+    position: absolute;
+    top: utils.vh-calc(167);
+    right: utils.vw-calc(63);
+    width: utils.vw-calc(36);
+    height: utils.vw-calc(267);
+    background: url("@/assets/images/img1.png") no-repeat center / cover;
+    z-index: 1;
+  }
+  &::after {
+    content: "";
+    position: absolute;
+    left: utils.vw-calc(46);
+    bottom: utils.vh-calc(53);
+    width: utils.vw-calc(422);
+    height: utils.vw-calc(73);
+    background: url("@/assets/images/img2.png") no-repeat center / cover;
+    z-index: 1;
+  }
+
+  .model-info {
+    position: absolute;
+    top: utils.vh-calc(120);
+    left: utils.vw-calc(160);
+    width: utils.vw-calc(400);
+    font-size: utils.vw-calc(19);
+    color: #535353;
+    z-index: 1;
+
+    h3 {
+      margin-bottom: utils.vw-calc(35);
+      color: #4b4b4b;
+      font-size: utils.vw-calc(34);
+      font-family: "SourceHanSerifSC-Bold";
+    }
+    p {
+      line-height: utils.vw-calc(38);
+    }
+  }
+
+  iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+
+  .icon-close {
+    position: absolute;
+    top: utils.vh-calc(54);
+    right: utils.vw-calc(54);
+    width: utils.vw-calc(55);
+    height: utils.vw-calc(55);
+    background: url("@/assets/images/back.png") no-repeat center / cover;
+    cursor: pointer;
+    z-index: 1;
+  }
+}
+</style>

+ 6 - 3
vite.config.js

@@ -36,9 +36,12 @@ export default defineConfig({
     preprocessorOptions: {
       scss: {
         api: "modern-compiler",
-        additionalData: `
-          @use "@/assets/elements.scss" as *;
-        `,
+        additionalData(source, filename) {
+          if (filename.replace(/\\/g, "/").endsWith("src/assets/elements.scss")) {
+            return source;
+          }
+          return `@use "@/assets/elements.scss" as *;\n${source}`;
+        },
       },
     },
   },