jinx 2 месяцев назад
Родитель
Сommit
2eb04e98e8

+ 2 - 0
package.json

@@ -22,10 +22,12 @@
     "element-plus": "^2.14.2",
     "epubjs": "^0.3.93",
     "html5-qrcode": "^2.3.8",
+    "pdfjs-dist": "5.7.284",
     "swiper": "^12.2.0",
     "vue": "^3.5.38",
     "vue-pdf-embed": "^2.1.4",
     "vue-router": "^4.6.4",
+    "vue-virtual-scroller": "^3.0.4",
     "vuex": "^4.1.0"
   },
   "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"

Разница между файлами не показана из-за своего большого размера
+ 1735 - 0
pnpm-lock.yaml


+ 228 - 0
src/components/FileReader/PdfVirtualPage.vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="pdf-page-shell" :style="{ minHeight: `${placeholderHeight}px` }">
+    <canvas
+      v-show="rendered"
+      ref="canvasRef"
+      class="pdf-page-canvas"
+    ></canvas>
+    <div
+      v-show="!rendered"
+      class="pdf-page-placeholder"
+      :style="{ height: `${placeholderHeight}px` }"
+    ></div>
+  </div>
+</template>
+
+<script setup>
+import { nextTick, onBeforeUnmount, ref, watch } from "vue";
+
+const props = defineProps({
+  active: {
+    type: Boolean,
+    default: false,
+  },
+  doc: {
+    type: Object,
+    default: null,
+  },
+  pageNumber: {
+    type: Number,
+    required: true,
+  },
+  pageWidth: {
+    type: Number,
+    default: 0,
+  },
+  placeholderHeight: {
+    type: Number,
+    default: 720,
+  },
+});
+
+const emit = defineEmits(["rendered", "error"]);
+
+const canvasRef = ref(null);
+const rendered = ref(false);
+
+let renderTask = null;
+let renderToken = 0;
+let rafId = 0;
+let renderedDoc = null;
+let renderedPageNumber = 0;
+let renderedWidth = 0;
+const clearSchedule = () => {
+  if (typeof window === "undefined") {
+    return;
+  }
+
+  if (rafId) {
+    window.cancelAnimationFrame(rafId);
+    rafId = 0;
+  }
+};
+
+const cancelRender = () => {
+  clearSchedule();
+  renderToken += 1;
+
+  if (renderTask) {
+    renderTask.cancel();
+    renderTask = null;
+  }
+};
+
+const clearCanvas = () => {
+  const canvas = canvasRef.value;
+  if (!canvas) {
+    return;
+  }
+
+  const context = canvas.getContext("2d");
+  context?.clearRect(0, 0, canvas.width, canvas.height);
+  canvas.width = 0;
+  canvas.height = 0;
+  rendered.value = false;
+  renderedDoc = null;
+  renderedPageNumber = 0;
+  renderedWidth = 0;
+};
+
+const renderPage = async () => {
+  if (!props.active || !props.doc || !props.pageWidth || !canvasRef.value) {
+    return;
+  }
+
+  const width = Math.round(props.pageWidth);
+  if (
+    rendered.value &&
+    renderedDoc === props.doc &&
+    renderedPageNumber === props.pageNumber &&
+    renderedWidth === width
+  ) {
+    emit("rendered", props.pageNumber);
+    return;
+  }
+
+  cancelRender();
+  const token = renderToken;
+  rendered.value = false;
+
+  try {
+    const page = await props.doc.getPage(props.pageNumber);
+    if (token !== renderToken || !canvasRef.value || !props.active) {
+      page.cleanup?.();
+      return;
+    }
+
+    const baseViewport = page.getViewport({ scale: 1 });
+    const scale = width / baseViewport.width;
+    const viewport = page.getViewport({ scale });
+    const outputScale = window.devicePixelRatio || 1;
+    const canvas = canvasRef.value;
+    const context = canvas.getContext("2d");
+
+    canvas.style.width = `${Math.floor(viewport.width)}px`;
+    canvas.style.height = `${Math.floor(viewport.height)}px`;
+    canvas.width = Math.floor(viewport.width * outputScale);
+    canvas.height = Math.floor(viewport.height * outputScale);
+
+    renderTask = page.render({
+      canvasContext: context,
+      transform:
+        outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null,
+      viewport,
+    });
+
+    await renderTask.promise;
+
+    if (token !== renderToken) {
+      page.cleanup?.();
+      return;
+    }
+
+    renderTask = null;
+    rendered.value = true;
+    renderedDoc = props.doc;
+    renderedPageNumber = props.pageNumber;
+    renderedWidth = width;
+    emit("rendered", props.pageNumber);
+    page.cleanup?.();
+  } catch (error) {
+    renderTask = null;
+    if (error?.name === "RenderingCancelledException") {
+      return;
+    }
+
+    emit("error", error);
+  }
+};
+
+const scheduleRender = () => {
+  if (!props.active || !props.doc || !props.pageWidth) {
+    return;
+  }
+
+  const width = Math.round(props.pageWidth);
+  if (
+    rendered.value &&
+    renderedDoc === props.doc &&
+    renderedPageNumber === props.pageNumber &&
+    renderedWidth === width
+  ) {
+    emit("rendered", props.pageNumber);
+    return;
+  }
+
+  clearSchedule();
+
+  if (typeof window !== "undefined") {
+    rafId = window.requestAnimationFrame(() => {
+      rafId = 0;
+      renderPage();
+    });
+  }
+};
+
+watch(
+  () => [props.active, props.doc, props.pageNumber, props.pageWidth],
+  async ([active, doc, pageNumber, width]) => {
+    if (!doc || !pageNumber || !width) {
+      cancelRender();
+      clearCanvas();
+      return;
+    }
+
+    if (!active) {
+      cancelRender();
+      return;
+    }
+
+    await nextTick();
+    scheduleRender();
+  },
+  { immediate: true },
+);
+
+onBeforeUnmount(() => {
+  cancelRender();
+  clearCanvas();
+});
+</script>
+
+<style lang="scss" scoped>
+.pdf-page-shell {
+  width: 100%;
+}
+
+.pdf-page-placeholder {
+  width: 100%;
+  border-radius: 8px;
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.pdf-page-canvas {
+  width: 100%;
+  display: block;
+  background: #fff;
+}
+</style>

+ 222 - 25
src/components/FileReader/pdf.vue

@@ -1,26 +1,67 @@
 <template>
-  <div class="pdf-view" v-if="props.filesList.length">
+  <div v-if="props.filesList.length" ref="containerRef" class="pdf-view">
     <div v-if="pdfError" class="pdf-empty">{{ pdfError }}</div>
-    <VuePdfEmbed
+    <DynamicScroller
       v-else
-      class="pdf-embed"
-      :source="pdfSource"
-      @loaded="handleLoaded"
-      @loading-failed="handleLoadFailed"
-      @rendering-failed="handleRenderFailed"
-    />
+      :key="pdfUrl"
+      class="pdf-scroller"
+      :items="pageItems"
+      :min-item-size="estimatedPageHeight"
+      :buffer="700"
+      :prerender="1"
+      :update-interval="80"
+      key-field="id"
+      v-slot="{ item, active }"
+    >
+      <DynamicScrollerItem
+        :item="item"
+        :active="active"
+        :size-dependencies="[pageWidth]"
+        class="pdf-page-item"
+      >
+        <PdfVirtualPage
+          :key="item.id"
+          :active="canRenderPage(item, active)"
+          :doc="pdfDocument"
+          :page-number="item.pageNumber"
+          :page-width="pageWidth"
+          :placeholder-height="estimatedPageHeight"
+          @rendered="handlePageRendered"
+          @error="handleRenderFailed"
+        />
+      </DynamicScrollerItem>
+    </DynamicScroller>
   </div>
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
-import VuePdfEmbed from "vue-pdf-embed";
+import {
+  computed,
+  markRaw,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  shallowRef,
+  watch,
+} from "vue";
+import { onBeforeRouteLeave } from "vue-router";
+import {
+  getDocument,
+  GlobalWorkerOptions,
+} from "pdfjs-dist/legacy/build/pdf.mjs";
+import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+import PdfWorker from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url";
 import { getThumb } from "@/utils/file";
-import { RecycleScroller } from "vue-virtual-scroller";
+import PdfVirtualPage from "@/components/FileReader/PdfVirtualPage.vue";
+
 import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
-import "vue-pdf-embed/dist/styles/annotationLayer.css";
-import "vue-pdf-embed/dist/styles/textLayer.css";
-const currentPage = ref(1);
+
+const RANGE_CHUNK_SIZE = 32 * 1024;
+const DEFAULT_PAGE_RATIO = 1.414;
+
+GlobalWorkerOptions.workerSrc = PdfWorker;
+
 const props = defineProps({
   url: {
     type: String,
@@ -32,26 +73,180 @@ const props = defineProps({
   },
 });
 
-
 const emit = defineEmits(["ready", "error"]);
+
+const containerRef = ref(null);
 const pdfError = ref("");
+const pageWidth = ref(0);
+const pageRatio = ref(DEFAULT_PAGE_RATIO);
+const firstRenderedPage = ref(false);
+const pdfDocument = shallowRef(null);
+let resizeObserver = null;
+let loadingTask = null;
+let loadToken = 0;
 
-const pdfSource = computed(() => getThumb(props.filesList[0]) || "");
+const pdfUrl = computed(() => getThumb(props.filesList[0]) || "");
+const pdfSource = computed(() => {
+  if (!pdfUrl.value) {
+    return null;
+  }
 
-const handleLoaded = () => {
-  pdfError.value = "";
-  emit("ready");
-};
+  return {
+    url: pdfUrl.value,
+    disableRange: false,
+    disableStream: true,
+    disableAutoFetch: true,
+    rangeChunkSize: RANGE_CHUNK_SIZE,
+  };
+});
 
 const handleLoadFailed = (error) => {
   pdfError.value = error?.message || "PDF 加载失败";
   emit("error", error);
 };
 
+const destroyLoadingTask = () => {
+  loadToken += 1;
+  pdfDocument.value = null;
+  firstRenderedPage.value = false;
+
+  if (loadingTask) {
+    const task = loadingTask;
+    loadingTask = null;
+    task.destroy().catch(() => {});
+  }
+};
+
+const loadPdfDocument = async (source) => {
+  destroyLoadingTask();
+  pdfDocument.value = null;
+  pdfError.value = "";
+  firstRenderedPage.value = false;
+
+  if (!source) {
+    return;
+  }
+
+  const currentToken = loadToken;
+  loadingTask = getDocument(source);
+
+  try {
+    const doc = await loadingTask.promise;
+    if (currentToken !== loadToken) {
+      doc.destroy?.();
+      return;
+    }
+
+    pdfDocument.value = markRaw(doc);
+  } catch (error) {
+    if (currentToken !== loadToken) {
+      return;
+    }
+
+    handleLoadFailed(error);
+  }
+};
+
+const pageItems = computed(() => {
+  const total = pdfDocument.value?.numPages || 0;
+  return Array.from({ length: total }, (_, index) => ({
+    id: index + 1,
+    pageNumber: index + 1,
+  }));
+});
+
+const estimatedPageHeight = computed(() => {
+  const width = pageWidth.value || 557;
+  return Math.max(720, Math.round(width * pageRatio.value) + 24);
+});
+
+const updatePageWidth = () => {
+  const nextWidth = containerRef.value?.clientWidth || 0;
+  if (nextWidth) {
+    pageWidth.value = nextWidth;
+  }
+};
+
+const handlePageRendered = (pageNumber) => {
+  if (firstRenderedPage.value) {
+    return;
+  }
+
+  firstRenderedPage.value = true;
+  pdfError.value = "";
+  emit("ready", pageNumber);
+};
+
+const canRenderPage = (item, active) => {
+  if (!active) {
+    return false;
+  }
+
+  return firstRenderedPage.value || item.pageNumber === 1;
+};
+
 const handleRenderFailed = (error) => {
   pdfError.value = error?.message || "PDF 渲染失败";
   emit("error", error);
 };
+
+watch(
+  pdfUrl,
+  () => {
+    pdfError.value = "";
+    firstRenderedPage.value = false;
+  },
+  { immediate: true },
+);
+
+watch(pdfSource, loadPdfDocument, { immediate: true });
+
+watch(
+  pdfDocument,
+  async (doc) => {
+    if (!doc) {
+      pageRatio.value = DEFAULT_PAGE_RATIO;
+      return;
+    }
+
+    try {
+      const firstPage = await doc.getPage(1);
+      const viewport = firstPage.getViewport({ scale: 1 });
+      if (viewport.width && viewport.height) {
+        pageRatio.value = viewport.height / viewport.width;
+      }
+    } catch {
+      pageRatio.value = DEFAULT_PAGE_RATIO;
+    }
+
+    await nextTick();
+    updatePageWidth();
+  },
+  { immediate: true },
+);
+
+onMounted(() => {
+  updatePageWidth();
+
+  if (typeof ResizeObserver !== "undefined" && containerRef.value) {
+    resizeObserver = new ResizeObserver(updatePageWidth);
+    resizeObserver.observe(containerRef.value);
+  } else if (typeof window !== "undefined") {
+    window.addEventListener("resize", updatePageWidth);
+  }
+});
+
+onBeforeRouteLeave(() => {
+  destroyLoadingTask();
+});
+
+onBeforeUnmount(() => {
+  destroyLoadingTask();
+  resizeObserver?.disconnect();
+  if (typeof window !== "undefined") {
+    window.removeEventListener("resize", updatePageWidth);
+  }
+});
 </script>
 
 <style lang="scss" scoped>
@@ -59,7 +254,7 @@ const handleRenderFailed = (error) => {
   width: 567px;
   height: 80vh;
   margin: 0 auto;
-  overflow: auto;
+  overflow: hidden;
   padding-right: 10px;
   position: absolute;
   top: 50%;
@@ -76,10 +271,12 @@ const handleRenderFailed = (error) => {
   color: #8a8a8a;
 }
 
-.pdf-embed {
-  width: 100%;
+.pdf-scroller {
+  height: 100%;
 }
-.scroller {
-  height: 500px;
+
+.pdf-page-item {
+  padding-bottom: 16px;
+  box-sizing: border-box;
 }
 </style>

+ 7 - 0
src/router/mobile.js

@@ -152,6 +152,13 @@ router.beforeEach(async (to, from) => {
   if (from.name) {
     store.commit("storeMobile/setStore", { previousRoutePath: from.fullPath });
   }
+
+  if (to.name === "resource-detail" && from.name !== "reading-detail") {
+    store.commit("storeMobile/setStore", {
+      resourceDetailBackPath: from.fullPath || "/home",
+    });
+  }
+
   return true;
 });
 

+ 2 - 0
src/store/modules/storeMobile.js

@@ -6,6 +6,7 @@ export default {
       fieldOptions: [],
       formDefInfo: null,
       previousRoutePath: null,
+      resourceDetailBackPath: "/home",
     };
   },
   getters: {
@@ -13,6 +14,7 @@ export default {
     fieldOptions: (state) => state.fieldOptions,
     formDefInfo: (state) => state.formDefInfo,
     previousRoutePath: (state) => state.previousRoutePath,
+    resourceDetailBackPath: (state) => state.resourceDetailBackPath,
   },
   mutations: {
     setStore(state, payload) {

+ 61 - 93
src/utils/request.js

@@ -1,25 +1,23 @@
 import axios from "axios";
 import { ElMessage } from "element-plus";
 import { getCurrentRouter } from "@/router/current";
+import store from "@/store";
 
-// 存储所有待取消的请求取消函数
 let pendingRequests = [];
 
-// 添加请求取消函数到队列
+const LOGIN_PATH = "/login";
+
 const addPendingRequest = (cancelTokenSource) => {
-  // 如果存在则先移除(避免重复)
   removePendingRequest(cancelTokenSource);
   pendingRequests.push(cancelTokenSource);
 };
 
-// 从队列中移除某个取消函数
 const removePendingRequest = (cancelTokenSource) => {
   pendingRequests = pendingRequests.filter(
     (source) => source !== cancelTokenSource,
   );
 };
 
-// 取消所有正在进行的请求
 const cancelAllPendingRequests = () => {
   pendingRequests.forEach((source) => {
     source.cancel("Token expired or unauthorized, request cancelled");
@@ -27,7 +25,49 @@ const cancelAllPendingRequests = () => {
   pendingRequests = [];
 };
 
-// 创建 axios 实例
+const clearLoginState = () => {
+  localStorage.removeItem("token");
+  localStorage.removeItem("user_data");
+  localStorage.removeItem("HAN_SHAN_SHI_FAN_GOODS_HOUTAI_USETINFO");
+
+  store.commit("setStore", { userInfo: null });
+  store.commit("storePc/setStore", { formDefInfo: null });
+  store.commit("storeMobile/setStore", { formDefInfo: null });
+  store.dispatch("resetBreadcrumbTrail");
+};
+
+const forceRedirectToLogin = () => {
+  if (window.__isRedirecting) {
+    return;
+  }
+
+  window.__isRedirecting = true;
+  clearLoginState();
+  ElMessage.warning("未登录");
+
+  Promise.resolve()
+    .then(async () => {
+      const router = getCurrentRouter();
+
+      if (!router) {
+        window.location.hash = `#${LOGIN_PATH}`;
+        return;
+      }
+
+      if (router.currentRoute.value.path !== LOGIN_PATH) {
+        await router.replace(LOGIN_PATH);
+      }
+    })
+    .catch(() => {
+      window.location.hash = `#${LOGIN_PATH}`;
+    })
+    .finally(() => {
+      setTimeout(() => {
+        window.__isRedirecting = false;
+      }, 1000);
+    });
+};
+
 const service = axios.create({
   baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
   headers: {
@@ -35,12 +75,11 @@ const service = axios.create({
   },
 });
 
-// 请求拦截器:为每个请求生成 CancelToken
 service.interceptors.request.use(
   (config) => {
-    // 创建 CancelToken 源
     const source = axios.CancelToken.source();
     config.cancelToken = source.token;
+    config.__cancelSource = source;
 
     const token = localStorage.getItem("token");
 
@@ -48,112 +87,41 @@ service.interceptors.request.use(
       config.headers.satoken = `${token}`;
     }
 
-    // 存储取消函数
     addPendingRequest(source);
     return config;
   },
   (error) => Promise.reject(error),
 );
 
-// 响应拦截器
 service.interceptors.response.use(
   (response) => {
-    // 请求完成后,从队列中移除该请求的取消函数
-    const source = response.config.cancelToken?.source;
-    if (source) {
-      removePendingRequest(source);
+    if (response.config.__cancelSource) {
+      removePendingRequest(response.config.__cancelSource);
     }
-    // 正常处理响应
-    if (response.data.code === 401) {
-      // 触发401时,取消所有其他请求
-      cancelAllPendingRequests();
-      // 跳转登录(需要判断是否已经跳转,避免重复跳转)
-      // 可以使用一个标志量防止多次执行
-      if (!window.__isRedirecting) {
-        window.__isRedirecting = true;
-        const router = getCurrentRouter();
-        localStorage.removeItem("token");
-        localStorage.removeItem("user_data");
-        localStorage.removeItem("HAN_SHAN_SHI_FAN_GOODS_HOUTAI_USETINFO");
-
-        ElMessage.warning("未登录");
-        if (router) {
-          router.push("/login");
-        } else {
-          window.location.hash = "#/login";
-        }
-        // 完成后重置标志(可能需要延时)
-        setTimeout(() => {
-          window.__isRedirecting = false;
-        }, 1000);
-      }
-      // 返回一个错误,让外层捕获,但也可以直接返回 Promise.reject
 
+    const responseData = response.data;
+
+    if (responseData?.code === 401 || responseData?.code === "401") {
+      cancelAllPendingRequests();
+      forceRedirectToLogin();
       return Promise.reject(new Error("未授权"));
     }
-    return response.data;
+
+    return responseData;
   },
   (error) => {
-    // 如果是主动取消的请求,不需要弹出错误提示
+    if (error.config?.__cancelSource) {
+      removePendingRequest(error.config.__cancelSource);
+    }
+
     if (axios.isCancel(error)) {
-      console.log("Request cancelled:", error.message);
-      // 静默处理,不抛出给业务层
       return Promise.reject(error);
     }
-    // 其他错误处理
+
     return Promise.reject(error);
   },
 );
 
-// const service = axios.create({
-//   baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
-//   headers: {
-//     "Content-Type": "application/json",
-//   },
-// });
-
-// service.interceptors.request.use(
-//   (config) => {
-//     const token = localStorage.getItem("token");
-
-//     if (token) {
-//       config.headers.satoken = `1${token}`;
-//     }
-
-//     return config;
-//   },
-//   (error) => Promise.reject(error),
-// );
-
-// service.interceptors.response.use(
-//   (response) => {
-//     const responseData = response.data;
-
-//     if (responseData?.code === 401) {
-//       localStorage.removeItem("token");
-//       localStorage.removeItem("user_data");
-//       if (!isLogout) {
-//         isLogout = true;
-//         ElMessage.warning("未登录");
-//       }
-//       router.push("/login").finally(() => {
-//         isLogout = false;
-//       });
-//     }
-
-//     return responseData;
-//   },
-//   (error) => {
-//     const message =
-//       error.response?.data?.message ||
-//       error.response?.data?.msg ||
-//       error.message ||
-//       "Request Error";
-
-//     return Promise.reject(new Error(message));
-//   },
-// );
-
 export const get = (url, params = {}, config = {}) =>
   service.get(url, { params, ...config });
 

+ 17 - 6
src/views/mobile/MreadingDetailView.vue

@@ -56,10 +56,22 @@
 </template>
 
 <script setup>
-import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
+import {
+  computed,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from "vue";
 import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
 import { useStore } from "vuex";
-import { addCollect, formDataDetail, userAddReadTime,userAddRead } from "@/api";
+import {
+  addCollect,
+  formDataDetail,
+  userAddReadTime,
+  userAddRead,
+} from "@/api";
 
 import PdfView from "@/views/mobile/components/FileReader/pdf.vue";
 import ImageView from "@/views/mobile/components/FileReader/image.vue";
@@ -314,7 +326,7 @@ const toggleFavorite = async () => {
 
 const handleBack = () => {
   if (previousRoutePath.value) {
-    router.go(-1);
+    router.back()
   } else {
     router.push("/home");
   }
@@ -322,7 +334,7 @@ const handleBack = () => {
 
 onMounted(() => {
   getDetails();
-  AddRead()
+  AddRead();
   syncReadTimerState();
   document.addEventListener("visibilitychange", handleVisibilityChange);
   window.addEventListener("focus", handleVisibilityChange);
@@ -376,8 +388,7 @@ onBeforeUnmount(() => {
   display: flex;
   align-items: center;
   gap: 18px;
-  background:
-    url("@/assets/mobile/images/search-bg.png") no-repeat center top;
+  background: url("@/assets/mobile/images/search-bg.png") no-repeat center top;
   background-size: 100% auto;
   background-position: center bottom;
 }

+ 5 - 8
src/views/mobile/MresourceDetailView.vue

@@ -104,8 +104,8 @@ defineOptions({
   name: "mobile-resource-detail",
 });
 const store = useStore();
-const previousRoutePath = computed(
-  () => store.getters["storeMobile/previousRoutePath"],
+const resourceDetailBackPath = computed(
+  () => store.getters["storeMobile/resourceDetailBackPath"] || "/home",
 );
 const route = useRoute();
 const router = useRouter();
@@ -131,11 +131,8 @@ const filterKey = [
 ];
 
 const handleBack = () => {
-  if (previousRoutePath.value) {
-    router.go(-1);
-  } else {
-    router.push("/home");
-  }
+  const targetPath = resourceDetailBackPath.value || "/home";
+  router.replace(targetPath === route.fullPath ? "/home" : targetPath);
 };
 
 const fileTypes = ref([
@@ -433,7 +430,7 @@ onMounted(() => {
   display: flex;
   align-items: flex-start;
   color: #75665d;
-  font-size: 22px;
+  font-size: 24px;
   line-height: 1.45;
 }
 

+ 223 - 26
src/views/mobile/components/FileReader/pdf.vue

@@ -1,24 +1,66 @@
 <template>
-  <div class="pdf-view" v-if="props.filesList.length">
+  <div v-if="props.filesList.length" ref="containerRef" class="pdf-view">
     <div v-if="pdfError" class="pdf-empty">{{ pdfError }}</div>
-    <VuePdfEmbed
+    <DynamicScroller
       v-else
-      class="pdf-embed"
-      :source="pdfSource"
-      @loaded="handleLoaded"
-      @loading-failed="handleLoadFailed"
-      @rendering-failed="handleRenderFailed"
-    />
+      :key="pdfUrl"
+      class="pdf-scroller"
+      :items="pageItems"
+      :min-item-size="estimatedPageHeight"
+      :buffer="560"
+      :prerender="1"
+      :update-interval="80"
+      key-field="id"
+      v-slot="{ item, active }"
+    >
+      <DynamicScrollerItem
+        :item="item"
+        :active="active"
+        :size-dependencies="[pageWidth]"
+        class="pdf-page-item"
+      >
+        <PdfVirtualPage
+          :key="item.id"
+          :active="canRenderPage(item, active)"
+          :doc="pdfDocument"
+          :page-number="item.pageNumber"
+          :page-width="pageWidth"
+          :placeholder-height="estimatedPageHeight"
+          @rendered="handlePageRendered"
+          @error="handleRenderFailed"
+        />
+      </DynamicScrollerItem>
+    </DynamicScroller>
   </div>
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
-import VuePdfEmbed from "vue-pdf-embed";
+import {
+  computed,
+  markRaw,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  shallowRef,
+  watch,
+} from "vue";
+import { onBeforeRouteLeave } from "vue-router";
+import {
+  getDocument,
+  GlobalWorkerOptions,
+} from "pdfjs-dist/legacy/build/pdf.mjs";
+import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+import PdfWorker from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url";
 import { getThumb } from "@/utils/file";
+import PdfVirtualPage from "@/components/FileReader/PdfVirtualPage.vue";
 
-import "vue-pdf-embed/dist/styles/annotationLayer.css";
-import "vue-pdf-embed/dist/styles/textLayer.css";
+import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
+
+const RANGE_CHUNK_SIZE = 32 * 1024;
+const DEFAULT_PAGE_RATIO = 1.414;
+
+GlobalWorkerOptions.workerSrc = PdfWorker;
 
 const props = defineProps({
   url: {
@@ -32,31 +74,186 @@ const props = defineProps({
 });
 
 const emit = defineEmits(["ready", "error"]);
+
+const containerRef = ref(null);
 const pdfError = ref("");
+const pageWidth = ref(0);
+const pageRatio = ref(DEFAULT_PAGE_RATIO);
+const firstRenderedPage = ref(false);
+const pdfDocument = shallowRef(null);
+let resizeObserver = null;
+let loadingTask = null;
+let loadToken = 0;
 
-const pdfSource = computed(() => getThumb(props.filesList[0]) || "");
+const pdfUrl = computed(() => getThumb(props.filesList[0]) || "");
+const pdfSource = computed(() => {
+  if (!pdfUrl.value) {
+    return null;
+  }
 
-const handleLoaded = () => {
-  pdfError.value = "";
-  emit("ready");
-};
+  return {
+    url: pdfUrl.value,
+    disableRange: false,
+    disableStream: true,
+    disableAutoFetch: true,
+    rangeChunkSize: RANGE_CHUNK_SIZE,
+  };
+});
 
 const handleLoadFailed = (error) => {
   pdfError.value = error?.message || "PDF 加载失败";
   emit("error", error);
 };
 
+const destroyLoadingTask = () => {
+  loadToken += 1;
+  pdfDocument.value = null;
+  firstRenderedPage.value = false;
+
+  if (loadingTask) {
+    const task = loadingTask;
+    loadingTask = null;
+    task.destroy().catch(() => {});
+  }
+};
+
+const loadPdfDocument = async (source) => {
+  destroyLoadingTask();
+  pdfDocument.value = null;
+  pdfError.value = "";
+  firstRenderedPage.value = false;
+
+  if (!source) {
+    return;
+  }
+
+  const currentToken = loadToken;
+  loadingTask = getDocument(source);
+
+  try {
+    const doc = await loadingTask.promise;
+    if (currentToken !== loadToken) {
+      doc.destroy?.();
+      return;
+    }
+
+    pdfDocument.value = markRaw(doc);
+  } catch (error) {
+    if (currentToken !== loadToken) {
+      return;
+    }
+
+    handleLoadFailed(error);
+  }
+};
+
+const pageItems = computed(() => {
+  const total = pdfDocument.value?.numPages || 0;
+  return Array.from({ length: total }, (_, index) => ({
+    id: index + 1,
+    pageNumber: index + 1,
+  }));
+});
+
+const estimatedPageHeight = computed(() => {
+  const width = pageWidth.value || 690;
+  return Math.max(560, Math.round(width * pageRatio.value) + 20);
+});
+
+const updatePageWidth = () => {
+  const nextWidth = containerRef.value?.clientWidth || 0;
+  if (nextWidth) {
+    pageWidth.value = nextWidth;
+  }
+};
+
+const handlePageRendered = (pageNumber) => {
+  if (firstRenderedPage.value) {
+    return;
+  }
+
+  firstRenderedPage.value = true;
+  pdfError.value = "";
+  emit("ready", pageNumber);
+};
+
+const canRenderPage = (item, active) => {
+  if (!active) {
+    return false;
+  }
+
+  return firstRenderedPage.value || item.pageNumber === 1;
+};
+
 const handleRenderFailed = (error) => {
   pdfError.value = error?.message || "PDF 渲染失败";
   emit("error", error);
 };
+
+watch(
+  pdfUrl,
+  () => {
+    pdfError.value = "";
+    firstRenderedPage.value = false;
+  },
+  { immediate: true },
+);
+
+watch(pdfSource, loadPdfDocument, { immediate: true });
+
+watch(
+  pdfDocument,
+  async (doc) => {
+    if (!doc) {
+      pageRatio.value = DEFAULT_PAGE_RATIO;
+      return;
+    }
+
+    try {
+      const firstPage = await doc.getPage(1);
+      const viewport = firstPage.getViewport({ scale: 1 });
+      if (viewport.width && viewport.height) {
+        pageRatio.value = viewport.height / viewport.width;
+      }
+    } catch {
+      pageRatio.value = DEFAULT_PAGE_RATIO;
+    }
+
+    await nextTick();
+    updatePageWidth();
+  },
+  { immediate: true },
+);
+
+onMounted(() => {
+  updatePageWidth();
+
+  if (typeof ResizeObserver !== "undefined" && containerRef.value) {
+    resizeObserver = new ResizeObserver(updatePageWidth);
+    resizeObserver.observe(containerRef.value);
+  } else if (typeof window !== "undefined") {
+    window.addEventListener("resize", updatePageWidth);
+  }
+});
+
+onBeforeRouteLeave(() => {
+  destroyLoadingTask();
+});
+
+onBeforeUnmount(() => {
+  destroyLoadingTask();
+  resizeObserver?.disconnect();
+  if (typeof window !== "undefined") {
+    window.removeEventListener("resize", updatePageWidth);
+  }
+});
 </script>
 
 <style lang="scss" scoped>
 .pdf-view {
   width: 100%;
-  min-height: calc(100vh - 132px);
-  overflow: auto;
+  height: calc(100vh - 132px);
+  overflow: hidden;
   padding: 20px 14px 32px;
   box-sizing: border-box;
 }
@@ -70,16 +267,16 @@ const handleRenderFailed = (error) => {
   color: #8a8a8a;
 }
 
-.pdf-embed {
-  width: 100%;
-  border-radius: 10px;
-  overflow: hidden;
-  background: rgba(255, 255, 255, 0.95);
-  box-shadow: 0 8px 24px rgba(76, 48, 34, 0.08);
+.pdf-scroller {
+  height: 100%;
+}
+
+.pdf-page-item {
+  padding-bottom: 14px;
+  box-sizing: border-box;
 }
 
 :deep(.vue-pdf-embed__page) {
-  margin-bottom: 14px;
   box-shadow: 0 8px 20px rgba(75, 50, 40, 0.1);
 }
 </style>