Files
ReisaAdmin/reijm-read/src/views/Manga.vue
2025-09-30 12:54:29 +08:00

895 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- Manga.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
// 漫画专辑数据
interface AlbumData {
album_id: string
name: string
image_urls: string[]
nums: number[]
authors?: string[]
tags?: string[]
}
// 漫画图片列表(从 jm.vue 中获取)
const mangaImages = ref<string[]>([])
const mangaNums = ref<number[]>([])
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
// 阅读器状态
const showMenu = ref(false)
const isFullscreen = ref(false)
const showDrawer = ref(false)
const currentImageIndex = ref(0) // 当前显示的图片索引
const imageStates = ref<Array<{ scale: number; translateX: number; translateY: number }>>([])
const loading = ref(true)
const canvasImages = ref<string[]>([]) // 解码后的图片
const abortLoading = ref(false) // 取消加载标志
// 计算是否应该隐藏头部(用于样式调整)
const shouldHideHeader = computed(() => {
return route.meta?.hideHeader === true
})
// 计算当前页码信息
const currentPageInfo = computed(() => {
return `${currentImageIndex.value + 1} / ${mangaImages.value.length}`
})
// 获取专辑数据
const fetchAlbum = async (id: string) => {
console.log('尝试获取专辑数据ID:', id)
if (!id) {
console.log('ID为空无法获取专辑数据')
return
}
try {
loading.value = true
console.log('发送请求到:', `/api/manga/read?mangaId=${id}`)
const response = await axios.get(`/api/manga/read?mangaId=${id}`, {
maxRedirects: 5,
validateStatus: (status) => status < 500,
})
console.log('收到响应:', response)
let data: AlbumData
if (response.status === 307 || response.status === 302) {
const newUrl = new URL(response.headers.location, response.config.url).href
const redirectResponse = await axios.get(newUrl)
data = redirectResponse.data
} else {
data = response.data
}
// 设置专辑信息
albumInfo.value = {
album_id: data.album_id,
name: data.name,
authors: data.authors || [],
tags: data.tags || []
}
// 设置图片 URL 和解码参数
mangaImages.value = data.image_urls || []
mangaNums.value = data.nums || []
// 初始化图片状态
imageStates.value = (data.image_urls || []).map(() => ({
scale: 1,
translateX: 0,
translateY: 0
}))
// 初始化画布图片数组
canvasImages.value = new Array((data.image_urls || []).length)
// 开始加载图片
if (data.image_urls && data.nums) {
loadImagesInBatches(data.image_urls, data.nums)
} else {
loading.value = false
}
} catch (error) {
console.error('加载专辑失败', error)
loading.value = false
}
}
// 分批加载图片每次加载4张
const loadImagesInBatches = async (urls: string[], nums: number[]) => {
const batchSize = 4 // 每批加载4张图片
let currentIndex = 0
abortLoading.value = false // 开始新加载任务前重置标志
while (currentIndex < urls.length && !abortLoading.value) {
const batchUrls = urls.slice(currentIndex, currentIndex + batchSize)
const batchNums = nums.slice(currentIndex, currentIndex + batchSize)
const loadPromises = batchUrls.map((url, index) => {
return new Promise<void>((resolve) => {
if (abortLoading.value) return resolve()
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = url
img.onload = () => {
if (abortLoading.value) return resolve()
if (batchNums[index] !== 0) {
const decodedCanvas = decodeImage(img, batchNums[index])
const base64 = decodedCanvas.toDataURL()
canvasImages.value[currentIndex + index] = base64
} else {
canvasImages.value[currentIndex + index] = url
}
resolve()
}
img.onerror = () => {
if (abortLoading.value) return resolve()
console.error(`图片加载失败: ${url}`)
// 加载失败时使用原始 URL
canvasImages.value[currentIndex + index] = url
resolve()
}
})
})
await Promise.all(loadPromises)
currentIndex += batchSize
}
loading.value = false
}
// JS 版 decodeImage等效 Java 方法)
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
if (num === 0) {
const canvas = document.createElement('canvas')
canvas.width = imgSrc.width
canvas.height = imgSrc.height
const ctx = canvas.getContext('2d')
ctx?.drawImage(imgSrc, 0, 0)
return canvas
}
const w = imgSrc.width
const h = imgSrc.height
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
const over = h % num
for (let i = 0; i < num; i++) {
let move = Math.floor(h / num)
let ySrc = h - move * (i + 1) - over
let yDst = move * i
if (i === 0) {
move += over
} else {
yDst += over
}
const srcRect = { x: 0, y: ySrc, width: w, height: move }
const dstRect = { x: 0, y: yDst, width: w, height: move }
const tempCanvas = document.createElement('canvas')
tempCanvas.width = w
tempCanvas.height = move
const tempCtx = tempCanvas.getContext('2d')!
tempCtx.drawImage(
imgSrc,
srcRect.x,
srcRect.y,
srcRect.width,
srcRect.height,
0,
0,
srcRect.width,
srcRect.height
)
ctx.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
dstRect.x,
dstRect.y,
dstRect.width,
dstRect.height
)
}
return canvas
}
// 切换抽屉菜单显示
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
// 切换顶部菜单显示
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
// 进入全屏模式
const enterFullscreen = () => {
const element = document.documentElement
if (element.requestFullscreen) {
element.requestFullscreen()
isFullscreen.value = true
}
}
// 退出全屏模式
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 重置所有图片的缩放
const resetAllImages = () => {
imageStates.value = imageStates.value.map(() => ({
scale: 1,
translateX: 0,
translateY: 0
}))
}
// 处理双指缩放(只响应真正的触摸事件)
let initialDistance = 0
let initialScale = 1
const getDistance = (touch1: Touch, touch2: Touch) => {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
const handleTouchStart = (index: number, event: TouchEvent) => {
if (event.touches.length === 2) {
// 双指触摸开始
initialDistance = getDistance(event.touches[0], event.touches[1])
initialScale = imageStates.value[index].scale
} else if (event.touches.length === 1) {
// 单指触摸,可能是滚动,不阻止默认行为
return
}
}
const handleTouchMove = (index: number, event: TouchEvent) => {
if (event.touches.length === 2) {
// 双指缩放
event.preventDefault() // 只在双指操作时阻止默认行为
const currentDistance = getDistance(event.touches[0], event.touches[1])
const scale = initialScale * (currentDistance / initialDistance)
// 限制缩放范围
const clampedScale = Math.min(Math.max(scale, 1), 3)
imageStates.value[index].scale = clampedScale
}
}
// 处理滚轮缩放(仅鼠标滚轮)
const handleWheel = (index: number, event: WheelEvent) => {
// 确保是鼠标滚轮事件而不是触摸板手势
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) {
// 可能是水平滚动,不处理缩放
return
}
event.preventDefault()
const currentState = imageStates.value[index]
if (event.deltaY < 0) {
// 向上滚动放大
if (currentState.scale < 3) {
currentState.scale *= 1.1
}
} else {
// 向下滚动缩小
if (currentState.scale > 1) {
currentState.scale /= 1.1
if (currentState.scale <= 1) {
currentState.scale = 1
currentState.translateX = 0
currentState.translateY = 0
}
}
}
}
// 跳转到指定图片
const scrollToImage = (index: number) => {
if (index < 0 || index >= mangaImages.value.length) return
const element = document.getElementById(`image-${index}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
currentImageIndex.value = index
// 关闭抽屉菜单
showDrawer.value = false
}
}
// 跳转到下一张图片
const nextImage = () => {
if (currentImageIndex.value < mangaImages.value.length - 1) {
scrollToImage(currentImageIndex.value + 1)
}
}
// 跳转到上一张图片
const prevImage = () => {
if (currentImageIndex.value > 0) {
scrollToImage(currentImageIndex.value - 1)
}
}
// 节流函数
const throttle = (func: Function, limit: number) => {
let inThrottle: boolean
return function() {
const args = arguments
const context = this
if (!inThrottle) {
func.apply(context, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
// 监听滚动事件,更新当前图片索引
const handleScroll = () => {
const containers = document.querySelectorAll('.image-container')
let currentIndex = 0
// 使用更精确的计算方式确定当前视口中的图片
for (let i = 0; i < containers.length; i++) {
const rect = containers[i].getBoundingClientRect()
// 当图片的任意部分在视口中时就认为是当前图片
if (rect.bottom > 0 && rect.top < window.innerHeight) {
// 优先选择图片中心点在视口中央的图片
if (Math.abs(rect.top + rect.height/2 - window.innerHeight/2) <
Math.abs(containers[currentIndex].getBoundingClientRect().top +
containers[currentIndex].getBoundingClientRect().height/2 - window.innerHeight/2)) {
currentIndex = i
}
}
}
// 只有当索引真正改变时才更新
if (currentImageIndex.value !== currentIndex) {
currentImageIndex.value = currentIndex
}
}
// 使用节流优化滚动处理
const throttledHandleScroll = throttle(handleScroll, 100)
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowLeft':
prevImage()
event.preventDefault()
break
case 'ArrowRight':
nextImage()
event.preventDefault()
break
case 'Escape':
if (isFullscreen.value) {
exitFullscreen()
}
showDrawer.value = false
break
}
}
// 监听路由参数变化
watch(
() => route.params.id,
(newId) => {
console.log('路由参数变化:', newId)
if (newId) {
abortLoading.value = true // 取消之前的加载
fetchAlbum(newId as string)
}
},
{ immediate: true }
)
// 组件挂载时添加事件监听器
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
// 直接监听 manga-reader 元素的滚动事件,而不是 manga-content
const mangaReader = document.querySelector('.manga-reader')
if (mangaReader) {
mangaReader.addEventListener('scroll', throttledHandleScroll)
}
// 初始化当前图片索引
handleScroll()
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
const mangaReader = document.querySelector('.manga-content')
if (mangaReader) {
mangaReader.removeEventListener('scroll', throttledHandleScroll)
}
abortLoading.value = true // 取消加载
})
</script>
<template>
<div class="manga-reader"
@click="toggleMenu"
:class="{ 'no-header': shouldHideHeader }">
<!-- &lt;!&ndash; 始终可见的页码显示 &ndash;&gt;-->
<!-- <div class="page-indicator">-->
<!-- {{ currentPageInfo }}-->
<!-- </div>-->
<!-- 顶部菜单栏 -->
<div
class="menu-bar"
:class="{ visible: showMenu }"
@click.stop
>
<div class="menu-content">
<span class="page-info">{{ currentPageInfo }}</span>
<button @click="prevImage" :disabled="currentImageIndex === 0 || loading">上一张</button>
<button @click="nextImage" :disabled="currentImageIndex === mangaImages.length - 1 || loading">下一张</button>
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()">
{{ isFullscreen ? '退出全屏' : '全屏' }}
</button>
<button @click="toggleDrawer">菜单</button>
</div>
</div>
<!-- 左侧抽屉菜单 -->
<div
class="drawer-overlay"
:class="{ 'drawer-open': showDrawer }"
@click="toggleDrawer"
>
<div
class="drawer"
:class="{ 'drawer-open': showDrawer }"
@click.stop
>
<div class="drawer-content">
<div class="drawer-header">
<h3>ReiJM</h3>
<button @click="toggleDrawer" class="close-btn">×</button>
</div>
<div class="drawer-body">
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
</button>
<div class="page-list">
<h4>页面列表</h4>
<div
v-for="(image, index) in mangaImages"
:key="index"
class="page-item"
:class="{ 'active': index === currentImageIndex }"
@click="scrollToImage(index)"
>
{{ index + 1 }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 漫画内容 -->
<div class="manga-content">
<!-- 专辑信息 -->
<div v-if="albumInfo" class="comic-header">
<h1 class="comic-title">{{ albumInfo.name }}</h1>
<div class="comic-meta">
<div v-if="albumInfo.authors && albumInfo.authors.length" class="meta-item">
<span class="label">作者</span>
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
</div>
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
<span class="label">标签</span>
<span class="value tags">
<span v-for="(tag, idx) in albumInfo.tags" :key="idx" class="tag">{{ tag }}</span>
</span>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">加载中...</div>
<!-- 漫画图片 -->
<div
v-for="(image, index) in canvasImages"
:key="index"
class="image-container"
:id="`image-${index}`"
>
<img
v-if="image"
:src="image"
:alt="`漫画第 ${index + 1} 页`"
class="manga-image"
:style="{
transform: `scale(${imageStates[index].scale}) translate(${imageStates[index].translateX}px, ${imageStates[index].translateY}px)`
}"
@wheel="handleWheel(index, $event)"
@touchstart="handleTouchStart(index, $event)"
@touchmove="handleTouchMove(index, $event)"
/>
<div v-else class="image-placeholder">图片加载中...</div>
</div>
</div>
<!-- 点击屏幕显示菜单的提示区域 -->
<div
class="screen-click-area"
@click="toggleMenu"
></div>
</div>
</template>
<style scoped>
.manga-reader {
position: relative;
width: 100vw;
height: 100vh;
background-color: #000;
overflow-y: auto;
overflow-x: hidden;
user-select: none;
padding-top: 0; /* 移除顶部内边距 */
padding-bottom: 60px; /* 添加底部内边距 */
}
.manga-reader.no-header {
padding-bottom: 0; /* 无头部时也无底部内边距 */
}
/* 添加页码指示器样式 */
.page-indicator {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
z-index: 90;
pointer-events: none; /* 不拦截点击事件 */
}
.menu-bar {
position: fixed;
bottom: 0; /* 改为底部定位 */
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
z-index: 100;
transform: translateY(100%); /* 改为向上偏移 */
transition: transform 0.3s ease;
}
.menu-bar.visible {
transform: translateY(0); /* 回到正常位置 */
}
.menu-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
.menu-content button {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
margin-left: 10px;
}
.menu-content button:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.3);
}
.menu-content button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 16px;
white-space: nowrap;
}
/* 左侧抽屉菜单样式 */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 199;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.drawer-overlay.drawer-open {
opacity: 1;
visibility: visible;
}
.drawer {
position: fixed;
top: 0;
left: 0;
width: 300px;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
color: white;
z-index: 200;
transform: translateX(-100%);
transition: transform 0.3s ease;
overflow-y: auto;
}
.drawer.drawer-open {
transform: translateX(0);
}
.drawer-content {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.drawer-header h3 {
margin: 0;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
}
.drawer-body {
flex: 1;
overflow-y: auto;
}
.drawer-item {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 10px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: none;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.drawer-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.page-list h4 {
margin: 20px 0 10px 0;
color: #ccc;
}
.page-item {
padding: 8px;
margin: 5px 0;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
cursor: pointer;
}
.page-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.page-item.active {
background-color: rgba(255, 255, 255, 0.2);
font-weight: bold;
}
.manga-content {
padding: 20px 0 0 0; /* 只保留顶部内边距 */
}
.image-container {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 0; /* 移除图片间的间距 */
padding: 0; /* 移除内边距 */
}
.manga-image {
max-width: 100%;
height: auto;
cursor: zoom-in;
transform-origin: center center;
transition: transform 0.2s ease;
user-select: none;
-webkit-user-drag: none;
display: block; /* 避免 inline 元素带来的空白间隙 */
}
.manga-image:active {
cursor: grabbing;
}
.image-placeholder {
color: #999;
font-size: 16px;
padding: 20px;
}
/* 顶部信息 */
.comic-header {
margin-bottom: 24px;
z-index: 2;
position: relative;
padding: 0 20px;
}
.comic-title {
font-size: 28px;
font-weight: bold;
margin-bottom: 16px;
line-height: 1.2;
color: white;
}
.comic-meta {
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-item {
display: flex;
align-items: center;
}
.label {
font-weight: bold;
color: #aaa;
width: 50px;
flex-shrink: 0;
}
.value {
color: #ddd;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: rgba(255, 255, 255, 0.1);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
color: #ccc;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
font-size: 18px;
}
/* 屏幕点击区域 */
.screen-click-area {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
}
/* 移动端适配 */
@media (max-width: 768px) {
.menu-content {
flex-direction: column;
gap: 10px;
}
.menu-content > * {
width: 100%;
text-align: center;
}
.manga-image {
max-width: 100%;
}
.drawer {
width: 280px;
}
.page-info {
font-size: 14px;
}
.comic-header {
padding: 0 10px;
}
.comic-title {
font-size: 24px;
}
.page-indicator {
top: 5px;
font-size: 12px;
padding: 3px 10px;
}
}
</style>