该代码实现了一个基于Vue 3的电子书阅读器组件,主要功能包括: 翻页效果:使用PageFlip库实现书籍翻页动画效果 图片加载:支持批量预加载图片并显示加载进度 目录功能:提供多级目录导航,可点击跳

<template>
  <div class="flip-wrapper">
    <!-- 加载状态显示 -->
    <div v-if="isLoading || showReloadPrompt" class="loading-overlay">
      <div class="loading-content">
        <div v-if="isLoading" class="loading-spinner"></div>
        <p v-if="isLoading" class="loading-text">
          加载中 {{ loadedCount }}/{{ totalImages }}
        </p>

        <!-- 缓存问题提示 -->
        <div v-if="showReloadPrompt" class="cache-problem-prompt">
          <div class="warning-icon">⚠️</div>
          <h3>内容加载异常</h3>
          <p>可能是缓存问题导致内容无法显示</p>
          <button class="reload-button" @click="handleReload">重新加载</button>
        </div>

        <div v-if="isLoading" class="loading-progress">
          <div
            class="progress-bar"
            :style="{ width: progressPercentage + '%' }"
          ></div>
        </div>
      </div>
    </div>

    <!-- 翻页容器 -->
    <div
      ref="flipContainer"
      class="flip-container"
      :style="{ display: isLoading || showReloadPrompt ? 'none' : 'block' }"
    ></div>
  </div>
</template>

<script setup>
import {
  ref,
  onMounted,
  watch,
  nextTick,
  onBeforeUnmount,
  computed,
} from "vue";
import { PageFlip } from "page-flip";

const props = defineProps({
  imageUrls: {
    type: Array,
    required: true,
    validator: (value) => value.every((item) => typeof item === "string"),
  },
  width: {
    type: Number,
    default: 650,
  },
  height: {
    type: Number,
    default: 1000,
  },
  page: {
    type: Number,
    default: 1,
  },
  batchSize: {
    type: Number,
    default: 50,
  },
});

const emit = defineEmits(["update:page", "page-change", "load-more", "refresh-urls"]);

// 状态管理
const flipContainer = ref(null);
const isLoading = ref(true);
const loadedCount = ref(0);
const totalImages = ref(props.imageUrls.length);
let pageFlip = null;
let isInitializing = false;
const imageCache = new Map();
const loadedPages = ref(0);
const isFullyLoaded = ref(false);
const isComponentMounted = ref(false);
const activeImageLoaders = new Set();
const showReloadPrompt = ref(false);
const hasCacheProblem = ref(false);
const lastInitTime = ref(0);
const pendingPageJump = ref(null); // 待处理的页面跳转请求

// 计算进度百分比
const progressPercentage = computed(() => {
  if (totalImages.value === 0) return 0;
  return Math.round((loadedCount.value / totalImages.value) * 100);
});

// 创建可取消的图片加载Promise
const createCancellableImageLoader = (url) => {
  let abortController = new AbortController();

  const promise = new Promise((resolve) => {
    if (!isComponentMounted.value) {
      resolve(false);
      return;
    }

    const img = new Image();
    const onLoad = () => {
      if (!abortController.signal.aborted) {
        imageCache.set(url, img);
        loadedCount.value++;
        resolve(true);
      }
      cleanup();
    };

    const onError = () => {
      if (!abortController.signal.aborted) {
        loadedCount.value++;
        resolve(false);
      }
      cleanup();
    };

    const cleanup = () => {
      img.onload = null;
      img.onerror = null;
      activeImageLoaders.delete(promise);
    };

    img.onload = onLoad;
    img.onerror = onError;
    img.src = url;

    abortController.signal.addEventListener("abort", () => {
      img.src = "";
      cleanup();
      resolve(false);
    });
  });

  activeImageLoaders.add(promise);
  return {
    promise,
    cancel: () => {
      abortController.abort();
      activeImageLoaders.delete(promise);
    },
  };
};

// 优化后的预加载图片函数
const preloadImages = async (urls, startIndex = 0, endIndex = urls.length) => {
  if (!isComponentMounted.value) return [];

  const batchUrls = urls.slice(startIndex, endIndex);

  // 优先加载第一张图片
  if (startIndex === 0 && batchUrls.length > 0) {
    const firstImgUrl = batchUrls[0];
    if (!imageCache.has(firstImgUrl)) {
      const { promise } = createCancellableImageLoader(firstImgUrl);
      await promise;
    } else {
      loadedCount.value++;
    }
  }

  // 批量加载图片(每批4张)
  const subBatchSize = 4;
  for (let i = 0; i < batchUrls.length; i += subBatchSize) {
    if (!isComponentMounted.value) break;

    const subBatchUrls = batchUrls.slice(i, i + subBatchSize);
    const loaders = subBatchUrls.map((url) => {
      if (imageCache.has(url)) {
        loadedCount.value++;
        return Promise.resolve();
      }
      const { promise } = createCancellableImageLoader(url);
      return promise;
    });

    await Promise.all(loaders);
  }

  if (isComponentMounted.value) {
    loadedPages.value = Math.max(loadedPages.value, endIndex);
  }
  return batchUrls;
};

// 安全销毁函数
const safelyDestroyPageFlip = () => {
  activeImageLoaders.forEach((loader) => {
    if (loader.cancel) loader.cancel();
  });
  activeImageLoaders.clear();

  if (pageFlip) {
    try {
      pageFlip.destroy();
    } catch (error) {
      console.warn("销毁 PageFlip 时出错:", error);
    }
    pageFlip = null;
  }

  if (flipContainer.value) {
    const canvases = flipContainer.value.querySelectorAll("canvas");
    canvases.forEach((canvas) => canvas.remove());
  }
};

// 检查是否需要加载更多
const checkLoadMore = (currentPage) => {
  if (isFullyLoaded.value || !isComponentMounted.value) return;

  if (
    currentPage >= loadedPages.value - 10 &&
    loadedPages.value < props.imageUrls.length
  ) {
    emit("load-more", loadedPages.value);
  }
};

// 加载指定范围内的图片
const loadPageRange = async (targetPage) => {
  if (!isComponentMounted.value) return;
  
  const start = Math.max(0, targetPage - 2); // 加载目标页前后5页
  const end = Math.min(props.imageUrls.length, targetPage + 2);
  
  // 如果目标页已经加载,直接返回
  if (end <= loadedPages.value) return;
  
  await preloadImages(props.imageUrls, loadedPages.value, end);
  
  if (pageFlip && isComponentMounted.value) {
    pageFlip.updateFromImages(props.imageUrls.slice(0, end));
  }
  
  // 继续加载剩余图片
  if (end < props.imageUrls.length) {
    setTimeout(() => {
      loadMoreImages(end);
    }, 500);
  } else {
    isFullyLoaded.value = true;
  }
};

const initPageFlip = async () => {
  if (
    isInitializing ||
    !flipContainer.value ||
    props.imageUrls.length === 0 ||
    !isComponentMounted.value
  )
    return;

  const now = Date.now();
  if (now - lastInitTime.value < 1000) return;
  lastInitTime.value = now;

  isInitializing = true;
  isLoading.value = true;
  isFullyLoaded.value = false;
  showReloadPrompt.value = false;

  try {
    safelyDestroyPageFlip();
    await nextTick();

    const initTimeout = setTimeout(() => {
      if (isLoading.value && isComponentMounted.value) {
        hasCacheProblem.value = true;
        showReloadPrompt.value = true;
        isLoading.value = false;
      }
    }, 10000);

    // 先加载第一批
    const firstBatchUrls = await preloadImages(
      props.imageUrls,
      0,
      Math.min(props.batchSize, props.imageUrls.length)
    );

    if (!isComponentMounted.value) {
      clearTimeout(initTimeout);
      return;
    }

    if (firstBatchUrls.length > 0 && !imageCache.has(firstBatchUrls[0])) {
      throw new Error("图片加载失败,可能是缓存问题");
    }

    pageFlip = new PageFlip(flipContainer.value, {
      width: props.width,
      height: props.height,
      showCover: true,
    });

    pageFlip.loadFromImages(firstBatchUrls);

    pageFlip.on("flip", (e) => {
      if (!isComponentMounted.value) return;
      const newPage = e.data + 1;
      emit("update:page", newPage);
      emit("page-change", newPage);
      checkLoadMore(newPage);
    });

    // 处理待跳转的页面
    if (pendingPageJump.value !== null) {
      const targetPage = pendingPageJump.value;
      pendingPageJump.value = null;
      await handlePageJump(targetPage);
    } else if (props.page > 1) {
      await handlePageJump(props.page);
    }

    isLoading.value = false;
    clearTimeout(initTimeout);

    // 异步加载剩余图片
    if (props.imageUrls.length > props.batchSize && isComponentMounted.value) {
      setTimeout(async () => {
        await loadMoreImages(props.batchSize);
      }, 1000);
    } else {
      isFullyLoaded.value = true;
    }
  } catch (error) {
    if (isComponentMounted.value) {
      console.error("PageFlip initialization error:", error);
      isLoading.value = false;
      hasCacheProblem.value = true;
      showReloadPrompt.value = true;
    }
  } finally {
    isInitializing = false;
  }
};

// 处理页面跳转
const handlePageJump = async (targetPage) => {
  if (!isComponentMounted.value || !pageFlip) {
    pendingPageJump.value = targetPage;
    return;
  }

  const currentIndex = pageFlip.getCurrentPageIndex() + 1;
  if (targetPage === currentIndex) return;

  // 如果目标页未加载,先加载目标页附近的页面
  if (targetPage > loadedPages.value) {
    isLoading.value = true;
    await loadPageRange(targetPage);
    isLoading.value = false;
  }

  // 执行跳转
  if (pageFlip && isComponentMounted.value) {
    pageFlip.flip(targetPage - 1);
  }
};

const handleReload = () => {
  imageCache.clear();
  loadedCount.value = 0;

  if (props.imageUrls && props.imageUrls.length > 0) {
    const updatedUrls = props.imageUrls.map((url) => {
      if (url.includes("?")) {
        return `${url}&t=${Date.now()}`;
      } else {
        return `${url}?t=${Date.now()}`;
      }
    });

    emit("refresh-urls", updatedUrls);
  }

  showReloadPrompt.value = false;
  isLoading.value = true;
  setTimeout(initPageFlip, 300);
};

// 加载更多图片
const loadMoreImages = async (startIndex) => {
  if (
    isFullyLoaded.value ||
    startIndex >= props.imageUrls.length ||
    !isComponentMounted.value
  )
    return;

  const endIndex = Math.min(
    startIndex + props.batchSize,
    props.imageUrls.length
  );
  await preloadImages(props.imageUrls, startIndex, endIndex);

  if (pageFlip && isComponentMounted.value) {
    pageFlip.updateFromImages(props.imageUrls.slice(0, endIndex));
  }

  if (endIndex >= props.imageUrls.length && isComponentMounted.value) {
    isFullyLoaded.value = true;
  }
};

onMounted(() => {
  isComponentMounted.value = true;
  setTimeout(initPageFlip, 100);
});

onBeforeUnmount(() => {
  isComponentMounted.value = false;
  safelyDestroyPageFlip();
  imageCache.clear();
});

watch(
  () => props.imageUrls,
  (newVal, oldVal) => {
    if (
      JSON.stringify(newVal) !== JSON.stringify(oldVal) &&
      isComponentMounted.value
    ) {
      totalImages.value = newVal.length;
      loadedPages.value = 0;
      isFullyLoaded.value = false;
      initPageFlip();
    }
  },
  { immediate: true, deep: true }
);

watch(
  () => props.page,
  (newPage, oldPage) => {
    if (newPage !== oldPage && isComponentMounted.value) {
      if (pageFlip) {
        handlePageJump(newPage);
      } else {
        pendingPageJump.value = newPage;
      }
    }
  }
);

defineExpose({
  prevPage: () => {
    if (pageFlip && isComponentMounted.value) pageFlip.flipPrev();
  },
  nextPage: () => {
    if (pageFlip && isComponentMounted.value) pageFlip.flipNext();
  },
  refresh: () => {
    if (isComponentMounted.value) initPageFlip();
  },
  loadMore: loadMoreImages,
});
</script>

<style>
.flip-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
}

.flip-container {
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.2);
  margin: 0 auto;
}

.flip-container .page {
  background-color: white;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #f9f9f9 0%, #ffffff 100%);
  z-index: 10;
  backdrop-filter: blur(5px);
}

.loading-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 40px;
  background: rgba(255, 255, 255, 0.98);
  border-radius: 20px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
  border: 1px solid rgba(255, 255, 255, 0.3);
  min-width: 220px;
  transform: translateY(-20px);
}

.loading-spinner {
  width: 60px;
  height: 60px;
  border: 4px solid rgba(170, 120, 59, 0.2);
  border-top: 4px solid #aa783b;
  border-radius: 50%;
  animation: spin 1s ease-in-out infinite;
  margin-bottom: 25px;
  position: relative;
  display: block;
}

.loading-spinner::after {
  content: "";
  position: absolute;
  top: -8px;
  left: -8px;
  right: -8px;
  bottom: -8px;
  border: 2px solid rgba(170, 120, 59, 0.1);
  border-radius: 50%;
  animation: pulse 2s ease-in-out infinite;
}

.loading-text {
  color: #666;
  font-size: 16px;
  font-weight: 500;
  margin-bottom: 20px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  text-align: center;
  width: 100%;
}

.loading-progress {
  width: 200px;
  height: 6px;
  background: rgba(170, 120, 59, 0.1);
  border-radius: 3px;
  overflow: hidden;
  margin: 0 auto;
}

.progress-bar {
  height: 100%;
  background: linear-gradient(90deg, #aa783b, #d4a356);
  border-radius: 3px;
  transition: width 0.3s ease;
  box-shadow: 0 0 10px rgba(170, 120, 59, 0.3);
}

.cache-problem-prompt {
  text-align: center;
  padding: 20px;
}

.cache-problem-prompt h3 {
  color: #d32f2f;
  margin-bottom: 10px;
}

.cache-problem-prompt p {
  color: #666;
  margin-bottom: 15px;
}

.reload-button {
  background-color: #aa783b;
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.reload-button:hover {
  background-color: #8e5e2d;
}

.warning-icon {
  font-size: 40px;
  margin-bottom: 15px;
  color: #d32f2f;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.5;
    transform: scale(1.05);
  }
}

@media (max-width: 768px) {
  .loading-content {
    padding: 30px 25px;
    min-width: 180px;
    margin: 0 20px;
  }

  .loading-spinner {
    width: 50px;
    height: 50px;
    margin-bottom: 20px;
  }

  .loading-text {
    font-size: 14px;
    margin-bottom: 18px;
  }

  .loading-progress {
    width: 160px;
  }
}

@media (max-width: 480px) {
  .loading-content {
    padding: 25px 20px;
    min-width: 160px;
    border-radius: 16px;
  }

  .loading-spinner {
    width: 45px;
    height: 45px;
    margin-bottom: 18px;
  }

  .loading-text {
    font-size: 13px;
    margin-bottom: 16px;
  }

  .loading-progress {
    width: 140px;
  }
}

@media (max-height: 600px) {
  .loading-content {
    padding: 25px;
    transform: translateY(0);
  }

  .loading-spinner {
    width: 45px;
    height: 45px;
    margin-bottom: 15px;
  }

  .loading-text {
    margin-bottom: 15px;
    font-size: 14px;
  }
}

@media print {
  .loading-overlay {
    display: none !important;
  }
}
</style>

<template>
  <div class="p-5" @click="closeToc">
    <div class="icon_list" :style="iconListStyle">
      <div class="mulu" @click.stop="toggleToc">
        <span>目录</span> <el-icon><Memo /></el-icon>
      </div>
      <div class="mulu" @click="handleIndex">
        <span>首页</span><el-icon><HomeFilled /></el-icon>
      </div>
    </div>

    <div
      class="toc-content"
      v-if="pageshow"
      :style="tocContentStyle"
      @click.stop
    >
      <h3 style="text-align: center; font-size: 20px">目录</h3>
      <ul class="toc-list">
        <template v-for="item in tocItems" :key="item.chapterId">
          <!-- 一级目录 -->
          <li
            v-if="item.chapterLevel === 1"
            @click.stop="goToPage(item.remark)"
            class="toc-item level-1"
            :class="{ active: activeChapterId === item.chapterId }"
          >
            {{ item.chapterTitle }}
          </li>

          <!-- 二级目录 -->
          <li
            v-else-if="item.chapterLevel === 2"
            @click.stop="goToPage(item.remark)"
            class="toc-item level-2"
            :class="{ active: activeChapterId === item.chapterId }"
          >
            {{ item.chapterTitle }}
          </li>
          <li
            v-else-if="item.chapterLevel === 3"
            @click.stop="goToPage(item.remark)"
            class="toc-item level-3"
            :class="{ active: activeChapterId === item.chapterId }"
          >
            {{ item.chapterTitle }}
          </li>
          <li
            v-else-if="item.chapterLevel === 4"
            @click.stop="goToPage(item.remark)"
            class="toc-item level-4"
            :class="{ active: activeChapterId === item.chapterId }"
          >
            {{ item.chapterTitle }}
          </li>
        </template>
      </ul>
    </div>

    <image-flip-viewer
      ref="viewerRef"
      v-model:page="currentPage"
      :image-urls="imageUrls"
      :width="650"
      :height="900"
      @click.stop="pageshow = false"
      @page-change="handlePageChange"
    />
  </div>
</template>

<script setup>
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  nextTick,
  watch,
} from "vue";
import ImageFlipViewer from "@/components/image-flip-viewer.vue";
import { getBookCapterList, getBookInfo } from "@/api/index.js";
import { useRouter, useRoute } from "vue-router";
import { Memo, HomeFilled } from "@element-plus/icons-vue";

const route = useRoute();
const router = useRouter();
const pageshow = ref(false);
const viewerRef = ref(null);
const currentPage = ref(1);
const tocItems = ref([]);
const imageUrls = ref([]);
const screenWidth = ref(window.innerWidth);
const activeChapterId = ref(null);
const bookWidth = 1300;

// 计算书籍左侧位置
const bookLeft = computed(() =>
  Math.max(0, (screenWidth.value - bookWidth) / 2)
);

// 获取书籍信息
const handlebookinfo = async (id) => {
  const res = await getBookInfo({ bookId: id });
  if (res?.info) {
    imageUrls.value = await generateImageUrls(`1-${res.info.remark1}`);

    nextTick(() => {
      if (viewerRef.value?.refresh) {
        viewerRef.value.refresh();
      }
    });
  }
};

// 生成图片URL
const generateImageUrls = async (pageRange) => {
  if (!pageRange) return [];

  try {
    const [start, end] = pageRange.split("-").map(Number);
    const urls = [];
    const baseApi = process.env.VUE_APP_BASE_API || "";

    for (let i = start; i <= end; i++) {
      const pageStr = i.toString();
      urls.push(
        `${baseApi}/yuanzhen/profile/upload/pdfImg/${route.query.id}/${pageStr}.png`替换为自己的的图片地址路劲
      );
    }

    return urls;
  } catch (error) {
    console.error("解析页码区间失败:", error);
    return [];
  }
};

// 监听当前页码变化
const handlePageChange = (newPage) => {
  currentPage.value = newPage;

  closeToc();
};
// 更新当前激活的章节 - 优化版:找到最接近的目录项
const updateActiveChapter = () => {
  if (!tocItems.value.length) return;

  // 1. 过滤出有有效页码的目录项并转换为数字
  const validItems = tocItems.value
    .map((item) => ({
      ...item,
      pageNum: parseInt(item.remark),
    }))
    .filter((item) => !isNaN(item.pageNum));

  if (validItems.length === 0) return;

  // 2. 按页码排序
  validItems.sort((a, b) => a.pageNum - b.pageNum);

  // 3. 找到最后一个页码小于等于当前页码的目录项
  let closestChapter = null;
  for (const item of validItems) {
    if (item.pageNum <= currentPage.value) {
      closestChapter = item;
    } else {
      break; // 由于已排序,可以提前退出
    }
  }

  // 4. 如果没有找到或当前页码小于所有目录页码,选择第一个
  if (!closestChapter) {
    closestChapter = validItems[0];
  }
  // 5. 如果当前页码超过所有目录页码,选择最后一个
  else if (currentPage.value > validItems[validItems.length - 1].pageNum) {
    closestChapter = validItems[validItems.length - 1];
  }

  // 6. 更新选中状态
  if (closestChapter) {
    activeChapterId.value = closestChapter.chapterId;

    // 7. 滚动到选中的目录项
    nextTick(() => {
      const activeElement = document.querySelector(".toc-item.active");
      if (activeElement) {
        activeElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
      }
    });
  }
};

// toc-content样式
const tocContentStyle = computed(() => ({
  left: `${bookLeft.value}px`,
  width: `${bookWidth / 2}px`,
  height: "900px",
  zIndex: 999,
  position: "absolute",
  top: "45px",
  overflow: "auto",
  backgroundColor: "rgba(255,255,255,0.9)",
}));

// icon_list样式
const iconListStyle = computed(() => ({
  left: `${bookLeft.value / 2}px`,
  position: "absolute",
  display: "grid",
  top: "25px",
  zIndex: 1000,
}));

const handleIndex = () => {
  router.push("/");
};

const toggleToc = async () => {
  if (!pageshow.value) {
    await BookCapterList(route.query.id);
  }
  pageshow.value = !pageshow.value;
};

const closeToc = () => {
  pageshow.value = false;
};

const BookCapterList = async (id) => {
  try {
    const response = await getBookCapterList({ bookId: id });
    if (response.info) {
      tocItems.value = response.info.map((item) => ({
        ...item,
        pageNumber: item.pageNumber || 1,
      }));
      updateActiveChapter();
    }
  } catch (error) {
    console.error("获取数据失败:", error);
  }
};

const goToPage = (pageNum) => {
  const pageNumber = parseInt(pageNum);
  if (!isNaN(pageNumber)) {
    currentPage.value = pageNumber;
  }

  const foundChapter = tocItems.value.find((item) => item.remark === pageNum);
  if (foundChapter) {
    activeChapterId.value = foundChapter.chapterId;
  }
  closeToc();
};

// 窗口大小变化监听
const handleResize = () => {
  screenWidth.value = window.innerWidth;
};

onMounted(async () => {
  window.addEventListener("resize", handleResize);
  await handlebookinfo(route.query.id);

  // 初始化时检查路由参数中的页码
  const pageNumber = parseInt(route.query.pageNumber);
  if (!isNaN(pageNumber)) {
    currentPage.value = pageNumber;
  }

  // 初始更新激活章节
  updateActiveChapter();
});

onBeforeUnmount(() => {
  window.removeEventListener("resize", handleResize);
});

// 监听currentPage变化,自动更新激活章节
watch(
  currentPage,
  () => {
    updateActiveChapter();
  },
  { immediate: true }
);

// 监听tocItems变化,自动更新激活章节
watch(
  tocItems,
  () => {
    updateActiveChapter();
  },
  { deep: true }
);
</script>

<style scoped>
.p-5 {
  margin-top: 45px;
  position: relative;
  min-width: 1300px;
  height: 900px;
}

.mulu {
  font-weight: 400;
  font-size: 16px;
  color: #333333;
  line-height: 18px;
  text-align: right;
  font-style: normal;
  display: flex;
  align-items: center;
  gap: 5px;
  margin-top: 20px;
  cursor: pointer;
  z-index: 9999;
}

.mulu:hover {
  color: #a90f0f;
}

.toc-content {
  border-right: 1px solid #e0e0e0;
  box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}

.toc-content h3 {
  margin-bottom: 15px;
  color: #333;
  font-size: 16px;
  padding-bottom: 8px;
  border-bottom: 1px solid #e0e0e0;
}

.toc-list {
  list-style: none;
  padding: 0 15px;
  margin: 0;
  height: calc(900px - 60px);
  overflow-y: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.toc-list::-webkit-scrollbar {
  display: none;
}

.toc-list li {
  padding: 8px 10px;
  margin-bottom: 5px;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
  text-align: left;
  width: 100%;
  overflow: hidden;
}

.toc-list li:hover {
  background-color: #f0f0f0;
  color: #a80d0e;
}

.toc-item.level-1 {
  font-weight: bold;
  padding-left: 15px;
}

.toc-item.level-2 {
  padding-left: 40px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 24px;
  height: 40px;
  font-size: 14px;
}
.toc-item.level-3 {
  padding-left: 50px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 24px;
  height: 40px;
  font-size: 14px;
}
.toc-item.level-4 {
  padding-left: 60px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 24px;
  height: 40px;
  font-size: 14px;
}

.toc-item.active {
  background-color: #f5f5f5;
  color: #a80d0e;
  font-weight: bold;
  position: relative;
  border-left: 3px solid #a80d0e;
}

.toc-item.active::after {
  position: absolute;
  right: 15px;
  color: #a80d0e;
  font-size: 14px;
  animation: pulse 1s infinite;
}

@keyframes pulse {
  0% {
    opacity: 0.6;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.6;
  }
}
</style>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值