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