initial2
This commit is contained in:
@@ -44,7 +44,6 @@
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cesium": "^1.129.0",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"imagemin": "^9.0.0",
|
||||
@@ -62,7 +61,6 @@
|
||||
"terser": "^5.37.0",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-cesium": "^1.2.23",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-image-optimizer": "^1.1.8",
|
||||
"vite-plugin-imagemin": "^0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- HomeView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ref, onMounted, reactive, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -12,7 +12,144 @@ interface Album {
|
||||
authors?: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
// 持久化存储键名
|
||||
const PERSISTENCE_KEY = 'homeViewState'
|
||||
const SCROLL_POSITION_KEY = 'homeViewScrollPosition'
|
||||
// 定义持久化状态接口
|
||||
interface HomeViewState {
|
||||
searchKeyword: string
|
||||
showSearchResults: boolean
|
||||
searchResults: Album[]
|
||||
}
|
||||
|
||||
// IDB 配置
|
||||
const IDB_CONFIG = {
|
||||
dbName: 'MangaImageCache',
|
||||
storeName: 'imageStore',
|
||||
version: 1,
|
||||
cacheExpireDays: 7 // 缓存有效期:7天
|
||||
}
|
||||
|
||||
// 初始化 IDB 数据库
|
||||
const initIDB = (): Promise<IDBDatabase> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(IDB_CONFIG.dbName, IDB_CONFIG.version)
|
||||
|
||||
// 数据库升级/创建
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
// 创建存储对象(键路径:albumId,唯一标识)
|
||||
if (!db.objectStoreNames.contains(IDB_CONFIG.storeName)) {
|
||||
db.createObjectStore(IDB_CONFIG.storeName, { keyPath: 'albumId' })
|
||||
}
|
||||
}
|
||||
|
||||
// 打开成功
|
||||
request.onsuccess = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
// 打开失败
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBOpenDBRequest).error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 从 IDB 读取图片缓存
|
||||
const getImageFromIDB = async (albumId: string): Promise<string | null> => {
|
||||
try {
|
||||
const db = await initIDB()
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(IDB_CONFIG.storeName, 'readonly')
|
||||
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||
const request = store.get(albumId)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cacheData = request.result
|
||||
if (!cacheData) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查缓存是否过期
|
||||
const now = Date.now()
|
||||
const expireTime = cacheData.timestamp + IDB_CONFIG.cacheExpireDays * 24 * 60 * 60 * 1000
|
||||
if (now > expireTime) {
|
||||
// 过期:删除旧缓存
|
||||
const deleteRequest = store.delete(albumId)
|
||||
deleteRequest.onsuccess = () => resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存有效:返回图片数据
|
||||
resolve(cacheData.imageData)
|
||||
}
|
||||
|
||||
request.onerror = () => resolve(null)
|
||||
})
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 将图片缓存写入 IDB
|
||||
const saveImageToIDB = async (albumId: string, imageData: string): Promise<boolean> => {
|
||||
try {
|
||||
const db = await initIDB()
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(IDB_CONFIG.storeName, 'readwrite')
|
||||
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||
// 存储数据:包含唯一标识、图片数据、时间戳
|
||||
const request = store.put({
|
||||
albumId,
|
||||
imageData,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
request.onsuccess = () => resolve(true)
|
||||
request.onerror = () => resolve(false)
|
||||
})
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有过期缓存
|
||||
const cleanExpiredCache = async () => {
|
||||
try {
|
||||
const db = await initIDB()
|
||||
return new Promise((resolve) => {
|
||||
const transaction = db.transaction(IDB_CONFIG.storeName, 'readwrite')
|
||||
const store = transaction.objectStore(IDB_CONFIG.storeName)
|
||||
const request = store.openCursor()
|
||||
const now = Date.now()
|
||||
let deletedCount = 0
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
if (cursor) {
|
||||
const cacheData = cursor.value
|
||||
const expireTime = cacheData.timestamp + IDB_CONFIG.cacheExpireDays * 24 * 60 * 60 * 1000
|
||||
if (now > expireTime) {
|
||||
cursor.delete()
|
||||
deletedCount++
|
||||
}
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => resolve(0)
|
||||
})
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 页面核心逻辑
|
||||
const router = useRouter()
|
||||
const albums = ref<Album[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -26,16 +163,27 @@ const searchResults = ref<Album[]>([])
|
||||
const searching = ref(false)
|
||||
const showSearchResults = ref(false)
|
||||
|
||||
// 存储已解码的封面图片
|
||||
// 临时状态存储(页面生命周期内)
|
||||
const coverImages = reactive<Record<string, string>>({})
|
||||
// 记录已处理的专辑,避免重复处理
|
||||
const processedAlbums = new Set<string>()
|
||||
// 记录已解码完成的专辑,用于控制显示
|
||||
const decodedAlbums = reactive<Record<string, boolean>>({})
|
||||
const decodeQueue = ref<Album[]>([])
|
||||
const isProcessingQueue = ref(false)
|
||||
|
||||
// 图片加载重试配置
|
||||
const retryConfig = {
|
||||
maxRetryCount: 3,
|
||||
retryInterval: 2000
|
||||
}
|
||||
|
||||
// 监控搜索关键词变化(无日志)
|
||||
watch(searchKeyword, () => {
|
||||
// 仅状态响应,无日志输出
|
||||
})
|
||||
|
||||
// 获取推荐漫画
|
||||
const fetchRecommendedManga = async () => {
|
||||
if (loading.value || !hasMore.value) return
|
||||
if (loading.value || !hasMore.value || showSearchResults.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -47,26 +195,27 @@ const fetchRecommendedManga = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新专辑到列表
|
||||
albums.value = [...albums.value, ...newAlbums]
|
||||
|
||||
// 为新专辑解码封面
|
||||
newAlbums.forEach(album => {
|
||||
if (!processedAlbums.has(album.album_id)) {
|
||||
decodeAndCacheCover(album)
|
||||
processedAlbums.add(album.album_id)
|
||||
}
|
||||
})
|
||||
if (!showSearchResults.value) {
|
||||
newAlbums.forEach(album => {
|
||||
if (!processedAlbums.has(album.album_id)) {
|
||||
decodeQueue.value.push(album)
|
||||
processedAlbums.add(album.album_id)
|
||||
}
|
||||
})
|
||||
processDecodeQueue()
|
||||
}
|
||||
|
||||
page.value++
|
||||
} catch (error) {
|
||||
console.error('获取推荐漫画失败:', error)
|
||||
// 静默失败,不输出日志
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
// 解码图片(核心逻辑保留)
|
||||
const decodeImage = (imgSrc: HTMLImageElement, num: number): string => {
|
||||
if (num === 0) {
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -132,53 +281,99 @@ const decodeImage = (imgSrc: HTMLImageElement, num: number): string => {
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
// 解码并缓存封面图片
|
||||
const decodeAndCacheCover = (album: Album) => {
|
||||
// 如果没有图片URL,返回默认图片
|
||||
if (!album.image_urls || album.image_urls.length === 0) {
|
||||
decodedAlbums[album.album_id] = true
|
||||
// 顺序处理加载队列(搜索状态正常处理)
|
||||
const processDecodeQueue = async () => {
|
||||
if (isProcessingQueue.value || decodeQueue.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = album.image_urls[0]
|
||||
isProcessingQueue.value = true
|
||||
|
||||
img.onload = () => {
|
||||
// 使用第一张图片的解码参数
|
||||
const num = album.nums && album.nums.length > 0 ? album.nums[0] : 0
|
||||
if (num !== 0) {
|
||||
const decodedImage = decodeImage(img, num)
|
||||
coverImages[album.album_id] = decodedImage
|
||||
} else {
|
||||
coverImages[album.album_id] = img.src
|
||||
try {
|
||||
while (decodeQueue.value.length > 0) {
|
||||
const album = decodeQueue.value[0]
|
||||
await decodeAndCacheCover(album)
|
||||
decodeQueue.value.shift()
|
||||
}
|
||||
// 标记解码完成
|
||||
decodedAlbums[album.album_id] = true
|
||||
} finally {
|
||||
isProcessingQueue.value = false
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// 加载失败时使用原始URL
|
||||
coverImages[album.album_id] = album.image_urls[0]
|
||||
// 标记解码完成(即使失败也标记完成)
|
||||
decodedAlbums[album.album_id] = true
|
||||
}
|
||||
// 解码并缓存封面(整合 IDB 缓存)
|
||||
const decodeAndCacheCover = (album: Album, currentRetry = 0): Promise<void> => {
|
||||
return new Promise(async (resolve) => {
|
||||
// 先从 IDB 读取缓存
|
||||
const cachedImage = await getImageFromIDB(album.album_id)
|
||||
if (cachedImage) {
|
||||
// 缓存命中:直接使用
|
||||
coverImages[album.album_id] = cachedImage
|
||||
decodedAlbums[album.album_id] = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存未命中:从网络加载
|
||||
if (!album.image_urls || album.image_urls.length === 0) {
|
||||
decodedAlbums[album.album_id] = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const imgUrl = album.image_urls[0]
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
// 超时处理
|
||||
const timeoutId = setTimeout(() => {
|
||||
img.src = ''
|
||||
handleLoadFail('timeout')
|
||||
}, 5000)
|
||||
|
||||
// 加载成功:解码 + 写入 IDB
|
||||
img.onload = () => {
|
||||
clearTimeout(timeoutId)
|
||||
const num = album.nums && album.nums.length > 0 ? album.nums[0] : 0
|
||||
let imageData = ''
|
||||
|
||||
if (num !== 0) {
|
||||
imageData = decodeImage(img, num)
|
||||
} else {
|
||||
imageData = img.src
|
||||
}
|
||||
|
||||
// 写入 IDB 缓存(不阻塞主线程)
|
||||
saveImageToIDB(album.album_id, imageData)
|
||||
// 更新临时存储
|
||||
coverImages[album.album_id] = imageData
|
||||
decodedAlbums[album.album_id] = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 加载失败重试
|
||||
const handleLoadFail = (errorType: 'error' | 'timeout') => {
|
||||
clearTimeout(timeoutId)
|
||||
if (currentRetry < retryConfig.maxRetryCount) {
|
||||
const nextRetry = currentRetry + 1
|
||||
setTimeout(() => {
|
||||
decodeAndCacheCover(album, nextRetry).then(resolve)
|
||||
}, retryConfig.retryInterval)
|
||||
} else {
|
||||
// 重试失败:使用原始 URL 兜底(不缓存)
|
||||
coverImages[album.album_id] = album.image_urls[0] || ''
|
||||
decodedAlbums[album.album_id] = true
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => handleLoadFail('error')
|
||||
img.src = imgUrl
|
||||
})
|
||||
}
|
||||
|
||||
// 获取封面图片URL
|
||||
const getCoverImageUrl = (album: Album): string => {
|
||||
// 如果已经解码过,直接返回解码后的图片
|
||||
if (coverImages[album.album_id]) {
|
||||
return coverImages[album.album_id]
|
||||
}
|
||||
|
||||
// 如果有原始URL,返回原始URL
|
||||
if (album.image_urls && album.image_urls.length > 0) {
|
||||
return album.image_urls[0]
|
||||
}
|
||||
|
||||
// 否则返回空
|
||||
return ''
|
||||
return coverImages[album.album_id] || album.image_urls?.[0] || ''
|
||||
}
|
||||
|
||||
// 检查专辑封面是否已解码完成
|
||||
@@ -191,11 +386,13 @@ const goToManga = (albumId: string) => {
|
||||
router.push(`/manga/${albumId}`)
|
||||
}
|
||||
|
||||
// 搜索功能
|
||||
// 修改搜索漫画函数,添加状态保存
|
||||
const searchManga = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (!keyword) {
|
||||
searchResults.value = []
|
||||
showSearchResults.value = false
|
||||
saveState() // 保存状态
|
||||
return
|
||||
}
|
||||
|
||||
@@ -203,40 +400,57 @@ const searchManga = async () => {
|
||||
showSearchResults.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/manga/search`, {
|
||||
params: {
|
||||
keyword: searchKeyword.value,
|
||||
page: 1,
|
||||
type: 0
|
||||
}
|
||||
params: { keyword, page: 1, type: 0 }
|
||||
})
|
||||
searchResults.value = response.data
|
||||
|
||||
// 为搜索结果解码封面
|
||||
// 清空队列,处理搜索结果
|
||||
decodeQueue.value = []
|
||||
searchResults.value.forEach(album => {
|
||||
decodedAlbums[album.album_id] = false
|
||||
if (!processedAlbums.has(album.album_id)) {
|
||||
decodeAndCacheCover(album)
|
||||
decodeQueue.value.push(album)
|
||||
processedAlbums.add(album.album_id)
|
||||
}
|
||||
})
|
||||
|
||||
await processDecodeQueue()
|
||||
saveState() // 保存状态
|
||||
} catch (error) {
|
||||
console.error('搜索漫画失败:', error)
|
||||
// 静默失败
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理回车键搜索
|
||||
const handleSearchKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
searchManga()
|
||||
}
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
// 修改清空搜索函数
|
||||
const clearSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
searchResults.value = []
|
||||
showSearchResults.value = false
|
||||
decodeQueue.value = albums.value.filter(album => !decodedAlbums[album.album_id])
|
||||
processDecodeQueue()
|
||||
saveState() // 保存状态
|
||||
}
|
||||
|
||||
// 搜索键盘事件
|
||||
const handleSearchKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const keyword = searchKeyword.value.trim()
|
||||
if (/^\d+$/.test(keyword)) {
|
||||
router.push(`/manga/${keyword}`)
|
||||
clearSearch()
|
||||
} else {
|
||||
searchManga()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发搜索
|
||||
const triggerSearch = () => {
|
||||
searchManga()
|
||||
}
|
||||
|
||||
// 设置无限滚动观察器
|
||||
@@ -246,9 +460,7 @@ const setupInfiniteScroll = () => {
|
||||
if (target.isIntersecting && !loading.value && hasMore.value && !showSearchResults.value) {
|
||||
fetchRecommendedManga()
|
||||
}
|
||||
}, {
|
||||
rootMargin: '100px' // 提前100px触发加载
|
||||
})
|
||||
}, { rootMargin: '100px' })
|
||||
|
||||
const loadMoreTrigger = document.getElementById('load-more-trigger')
|
||||
if (loadMoreTrigger && observer.value) {
|
||||
@@ -256,15 +468,103 @@ const setupInfiniteScroll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
// 修改组件挂载初始化
|
||||
onMounted(async () => {
|
||||
// 尝试恢复状态
|
||||
const savedState = restoreState()
|
||||
if (savedState) {
|
||||
searchKeyword.value = savedState.searchKeyword
|
||||
showSearchResults.value = savedState.showSearchResults
|
||||
searchResults.value = savedState.searchResults
|
||||
|
||||
// 如果有搜索结果,处理搜索结果的图片加载
|
||||
if (showSearchResults.value && searchResults.value.length > 0) {
|
||||
decodeQueue.value = []
|
||||
searchResults.value.forEach(album => {
|
||||
decodedAlbums[album.album_id] = false
|
||||
if (!processedAlbums.has(album.album_id)) {
|
||||
decodeQueue.value.push(album)
|
||||
processedAlbums.add(album.album_id)
|
||||
}
|
||||
})
|
||||
processDecodeQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期缓存(后台执行,不阻塞)
|
||||
await cleanExpiredCache()
|
||||
|
||||
fetchRecommendedManga().then(() => {
|
||||
// 等待DOM更新后设置无限滚动
|
||||
setTimeout(setupInfiniteScroll, 0)
|
||||
setupInfiniteScroll()
|
||||
processDecodeQueue()
|
||||
// 恢复滚动位置
|
||||
if (!savedState || !savedState.showSearchResults) {
|
||||
restoreScrollPosition()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
// 空函数占位(兼容旧逻辑)
|
||||
const restoreState = (): HomeViewState | null => {
|
||||
try {
|
||||
const savedState = localStorage.getItem(PERSISTENCE_KEY)
|
||||
if (savedState) {
|
||||
return JSON.parse(savedState)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复状态失败:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
// 保存状态到 localStorage
|
||||
const saveState = () => {
|
||||
try {
|
||||
const state: HomeViewState = {
|
||||
searchKeyword: searchKeyword.value,
|
||||
showSearchResults: showSearchResults.value,
|
||||
searchResults: searchResults.value
|
||||
}
|
||||
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state))
|
||||
} catch (e) {
|
||||
console.error('保存状态失败:', e)
|
||||
}
|
||||
}
|
||||
// 保存滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
try {
|
||||
const scrollPosition = {
|
||||
x: window.scrollX || window.pageXOffset,
|
||||
y: window.scrollY || window.pageYOffset
|
||||
}
|
||||
localStorage.setItem(SCROLL_POSITION_KEY, JSON.stringify(scrollPosition))
|
||||
} catch (e) {
|
||||
console.error('保存滚动位置失败:', e)
|
||||
}
|
||||
}
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = () => {
|
||||
try {
|
||||
const savedPosition = localStorage.getItem(SCROLL_POSITION_KEY)
|
||||
if (savedPosition) {
|
||||
const { x, y } = JSON.parse(savedPosition)
|
||||
// 使用 nextTick 确保 DOM 更新后再滚动
|
||||
nextTick(() => {
|
||||
window.scrollTo(x, y)
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('恢复滚动位置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除保存的状态
|
||||
const clearSavedState = () => {
|
||||
localStorage.removeItem(PERSISTENCE_KEY)
|
||||
localStorage.removeItem(SCROLL_POSITION_KEY)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<div class="header">
|
||||
@@ -279,7 +579,6 @@ onMounted(() => {
|
||||
@click="goToManga(album.album_id)"
|
||||
>
|
||||
<div class="manga-cover">
|
||||
<!-- 只有解码完成后才显示图片 -->
|
||||
<img
|
||||
v-if="isAlbumDecoded(album) && getCoverImageUrl(album)"
|
||||
:src="getCoverImageUrl(album)"
|
||||
@@ -320,7 +619,6 @@ onMounted(() => {
|
||||
<div v-else-if="!hasMore" class="no-more">没有更多了</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<div class="search-box">
|
||||
<div class="search-input-container">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- Manga.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import router from "@/router";
|
||||
@@ -8,7 +8,7 @@ import router from "@/router";
|
||||
const route = useRoute()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
// 漫画专辑数据
|
||||
// 漫画专辑数据接口
|
||||
interface AlbumData {
|
||||
album_id: string
|
||||
name: string
|
||||
@@ -16,9 +16,10 @@ interface AlbumData {
|
||||
nums: number[]
|
||||
authors?: string[]
|
||||
tags?: string[]
|
||||
readIndex?: number
|
||||
}
|
||||
|
||||
// 漫画图片列表(从 jm.vue 中获取)
|
||||
// 漫画图片列表
|
||||
const mangaImages = ref<string[]>([])
|
||||
const mangaNums = ref<number[]>([])
|
||||
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
|
||||
@@ -28,25 +29,154 @@ const showMenu = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
const showDrawer = ref(false)
|
||||
let currentImageIndex = ref(0) // 当前显示的图片索引
|
||||
const imageStates = ref<Array<{ scale: number; translateX: number; translateY: number }>>([])
|
||||
const imageStates = ref<Array<{
|
||||
scale: number;
|
||||
translateX: number;
|
||||
translateY: number;
|
||||
startX: number; // 拖拽起始X
|
||||
startY: number; // 拖拽起始Y
|
||||
isDragging: boolean; // 是否正在拖拽
|
||||
}>>([])
|
||||
const loading = ref(true)
|
||||
const canvasImages = ref<string[]>([]) // 解码后的图片
|
||||
const abortLoading = ref(false) // 取消加载标志
|
||||
|
||||
// 计算是否应该隐藏头部(用于样式调整)
|
||||
// 图片加载状态跟踪
|
||||
const imageLoadStates = ref<Array<'loading' | 'loaded' | 'error'>>([])
|
||||
// 重试配置 - 每次重试间隔时间(毫秒)
|
||||
const retryInterval = 3000
|
||||
|
||||
// 计算是否应该隐藏头部
|
||||
const shouldHideHeader = computed(() => {
|
||||
return route.meta?.hideHeader === true
|
||||
})
|
||||
// 在现有代码中添加新的函数
|
||||
|
||||
// 生成未加载占位图片
|
||||
const generatePlaceholderImage = (width: number = 300, height: number = 400): string => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return ''
|
||||
|
||||
// 背景色
|
||||
ctx.fillStyle = '#f5f5f5'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// 网格线条
|
||||
ctx.strokeStyle = '#e0e0e0'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = (i + 1) * (height / 6)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(width * 0.1, y)
|
||||
ctx.lineTo(width * 0.9, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 未加载文字
|
||||
ctx.fillStyle = '#999'
|
||||
ctx.font = '14px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText('加载中...', width / 2, height / 2)
|
||||
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
|
||||
// IndexedDB 缓存实现
|
||||
const IDB_STORE_NAME = 'manga_image_cache'
|
||||
const IDB_VERSION = 1
|
||||
|
||||
// 初始化IndexedDB
|
||||
const initIDB = (): Promise<IDBDatabase> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('MangaReaderCache', IDB_VERSION)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
||||
db.createObjectStore(IDB_STORE_NAME, { keyPath: 'url' })
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBOpenDBRequest).result)
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
console.error('IndexedDB初始化失败:', (event.target as IDBOpenDBRequest).error)
|
||||
reject((event.target as IDBOpenDBRequest).error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 从缓存获取图片
|
||||
const getImageFromCache = (url: string): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
initIDB().then(db => {
|
||||
const transaction = db.transaction(IDB_STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(IDB_STORE_NAME)
|
||||
const request = store.get(url)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const data = request.result
|
||||
// 检查缓存是否过期(7天有效期)
|
||||
if (data && data.timestamp && (Date.now() - data.timestamp) < 7 * 24 * 60 * 60 * 1000) {
|
||||
resolve(data.dataUrl)
|
||||
} else {
|
||||
// 过期则删除缓存
|
||||
if (data) {
|
||||
const deleteTx = db.transaction(IDB_STORE_NAME, 'readwrite')
|
||||
deleteTx.objectStore(IDB_STORE_NAME).delete(url)
|
||||
}
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('从缓存获取图片失败:', request.error)
|
||||
resolve(null)
|
||||
}
|
||||
}).catch(() => {
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 保存图片到缓存
|
||||
const saveImageToCache = (url: string, dataUrl: string) => {
|
||||
initIDB().then(db => {
|
||||
const transaction = db.transaction(IDB_STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(IDB_STORE_NAME)
|
||||
store.put({
|
||||
url,
|
||||
dataUrl,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log(`图片已缓存: ${url}`)
|
||||
}
|
||||
|
||||
transaction.onerror = () => {
|
||||
console.error('图片缓存失败:', transaction.error)
|
||||
}
|
||||
}).catch(() => { /* 忽略缓存失败 */ })
|
||||
}
|
||||
|
||||
// 上报阅读进度
|
||||
const reportReadManga = async (mangaId: string, index: number) => {
|
||||
try {
|
||||
await axios.post('/api/manga/read', {}, {
|
||||
headers: {
|
||||
Token: token || '',
|
||||
mangaId: mangaId,
|
||||
index: index.toString()
|
||||
index: index + 1
|
||||
}
|
||||
});
|
||||
console.log('上报成功' + index.toString())
|
||||
} catch (error) {
|
||||
console.error('上报阅读进度失败:', error);
|
||||
}
|
||||
@@ -57,6 +187,61 @@ const currentPageInfo = computed(() => {
|
||||
return `第 ${currentImageIndex.value + 1} / ${mangaImages.value.length} 页`
|
||||
})
|
||||
|
||||
// 图片加载函数(支持缓存和无限重试)
|
||||
const loadImageWithRetry = (url: string, num: number, index: number): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
// 取消加载时直接返回
|
||||
if (abortLoading.value) return resolve()
|
||||
|
||||
// 先尝试从缓存加载
|
||||
getImageFromCache(url).then(cachedDataUrl => {
|
||||
if (cachedDataUrl) {
|
||||
console.log(`使用缓存图片: 索引${index} - ${url}`)
|
||||
canvasImages.value[index] = cachedDataUrl
|
||||
imageLoadStates.value[index] = 'loaded'
|
||||
return resolve()
|
||||
}
|
||||
|
||||
// 缓存未命中,从网络加载
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
|
||||
// 加载成功处理
|
||||
img.onload = () => {
|
||||
if (abortLoading.value) return resolve()
|
||||
|
||||
let dataUrl: string
|
||||
// 按原逻辑解码图片
|
||||
if (num !== 0) {
|
||||
const decodedCanvas = decodeImage(img, num)
|
||||
dataUrl = decodedCanvas.toDataURL()
|
||||
} else {
|
||||
dataUrl = url
|
||||
}
|
||||
|
||||
canvasImages.value[index] = dataUrl
|
||||
imageLoadStates.value[index] = 'loaded'
|
||||
// 保存到缓存
|
||||
saveImageToCache(url, dataUrl)
|
||||
console.log(`图片加载成功: 索引${index} - ${url}`)
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 加载失败处理 - 无限重试
|
||||
img.onerror = () => {
|
||||
if (abortLoading.value) return resolve()
|
||||
console.error(`图片加载失败,将在${retryInterval}ms后重试: 索引${index} - ${url}`)
|
||||
imageLoadStates.value[index] = 'loading'
|
||||
// 延迟后递归重试
|
||||
setTimeout(() => {
|
||||
loadImageWithRetry(url, num, index).then(resolve)
|
||||
}, retryInterval)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 获取专辑数据
|
||||
const fetchAlbum = async (id: string) => {
|
||||
console.log('尝试获取专辑数据,ID:', id)
|
||||
@@ -77,7 +262,6 @@ const fetchAlbum = async (id: string) => {
|
||||
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)
|
||||
@@ -93,85 +277,114 @@ const fetchAlbum = async (id: string) => {
|
||||
authors: data.authors || [],
|
||||
tags: data.tags || []
|
||||
}
|
||||
|
||||
if (data.album_id) {
|
||||
checkIsLoved(data.album_id)
|
||||
}
|
||||
// 设置图片 URL 和解码参数
|
||||
mangaImages.value = data.image_urls || []
|
||||
mangaNums.value = data.nums || []
|
||||
|
||||
// 初始化图片状态
|
||||
// 初始化图片状态、画布数组、加载状态
|
||||
imageStates.value = (data.image_urls || []).map(() => ({
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0
|
||||
translateY: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isDragging: false
|
||||
}))
|
||||
|
||||
// 初始化画布图片数组
|
||||
canvasImages.value = new Array((data.image_urls || []).length)
|
||||
imageLoadStates.value = (data.image_urls || []).map(() => 'loading')
|
||||
|
||||
// 开始加载图片
|
||||
// 设置当前阅读进度并跳转
|
||||
const targetIndex = data.readIndex || 0
|
||||
currentImageIndex.value = targetIndex
|
||||
await nextTick()
|
||||
// 修复跳转逻辑,直接跳转到目标索引
|
||||
scrollToImage(targetIndex)
|
||||
console.log('已跳转到上次阅读位置,索引:', targetIndex)
|
||||
|
||||
// 关键修改:删除优先加载,直接全量加载所有图片(带缓存支持)
|
||||
if (data.image_urls && data.nums) {
|
||||
loadImagesInBatches(data.image_urls, data.nums)
|
||||
await loadAllImages(data.image_urls, data.nums)
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
scrollToImage(data.readIndex)
|
||||
currentImageIndex.value = data.readIndex
|
||||
console.log(currentImageIndex.value)
|
||||
} 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 // 开始新加载任务前重置标志
|
||||
// 全量加载所有图片(删除优先加载逻辑,按顺序加载所有)
|
||||
const loadAllImages = async (urls: string[], nums: number[]) => {
|
||||
abortLoading.value = false
|
||||
const batchSize = 3 // 保留小批量加载避免请求过于集中
|
||||
|
||||
while (currentIndex < urls.length && !abortLoading.value) {
|
||||
const batchUrls = urls.slice(currentIndex, currentIndex + batchSize)
|
||||
const batchNums = nums.slice(currentIndex, currentIndex + batchSize)
|
||||
// 按顺序分批次加载所有图片(从第0页开始)
|
||||
for (let i = 0; i < urls.length; i += batchSize) {
|
||||
if (abortLoading.value) break // 取消加载时退出
|
||||
|
||||
const loadPromises = batchUrls.map((url, index) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (abortLoading.value) return resolve()
|
||||
const end = Math.min(i + batchSize, urls.length)
|
||||
const batchPromises = []
|
||||
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
// 加载当前批次的图片(包含前3页)
|
||||
for (let j = i; j < end; j++) {
|
||||
batchPromises.push(loadImageWithRetry(urls[j], nums[j], j))
|
||||
}
|
||||
|
||||
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
|
||||
await Promise.all(batchPromises)
|
||||
console.log(`已加载批次: ${i}~${end-1}`)
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
console.log('所有图片加载完成')
|
||||
}
|
||||
|
||||
// JS 版 decodeImage(等效 Java 方法)
|
||||
// 收藏相关功能
|
||||
const isLoved = ref(false)
|
||||
const checkIsLoved = async (mangaId: string) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/manga/isLove?mangaId=${mangaId}`, {
|
||||
headers: {
|
||||
Token: token || ''
|
||||
}
|
||||
})
|
||||
isLoved.value = response.data === '已收藏'
|
||||
} catch (error) {
|
||||
console.error('检查收藏状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleLove = async () => {
|
||||
if (!albumInfo.value) return
|
||||
|
||||
try {
|
||||
if (isLoved.value) {
|
||||
await axios.delete(`/api/manga/love?mangaId=${albumInfo.value.album_id}`, {
|
||||
headers: {
|
||||
Token: token || ''
|
||||
}
|
||||
})
|
||||
isLoved.value = false
|
||||
} else {
|
||||
await axios.post(`/api/manga/love`, {}, {
|
||||
params: {
|
||||
mangaId: albumInfo.value.album_id,
|
||||
mangaName: albumInfo.value.name
|
||||
},
|
||||
headers: {
|
||||
Token: token || ''
|
||||
}
|
||||
})
|
||||
isLoved.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏操作失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片解码函数
|
||||
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
|
||||
if (num === 0) {
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -247,7 +460,7 @@ const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
// 进入全屏模式
|
||||
// 全屏模式切换
|
||||
const enterFullscreen = () => {
|
||||
const element = document.documentElement
|
||||
if (element.requestFullscreen) {
|
||||
@@ -256,7 +469,6 @@ const enterFullscreen = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 退出全屏模式
|
||||
const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
@@ -264,16 +476,19 @@ const exitFullscreen = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有图片的缩放
|
||||
// 重置所有图片的缩放和位置
|
||||
const resetAllImages = () => {
|
||||
imageStates.value = imageStates.value.map(() => ({
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0
|
||||
translateY: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isDragging: false
|
||||
}))
|
||||
}
|
||||
|
||||
// 处理双指缩放(只响应真正的触摸事件)
|
||||
// 触摸事件处理(缩放+拖拽)
|
||||
let initialDistance = 0
|
||||
let initialScale = 1
|
||||
|
||||
@@ -284,41 +499,47 @@ const getDistance = (touch1: Touch, touch2: Touch) => {
|
||||
}
|
||||
|
||||
const handleTouchStart = (index: number, event: TouchEvent) => {
|
||||
const state = imageStates.value[index]
|
||||
if (event.touches.length === 2) {
|
||||
// 双指触摸开始
|
||||
// 双指缩放
|
||||
initialDistance = getDistance(event.touches[0], event.touches[1])
|
||||
initialScale = imageStates.value[index].scale
|
||||
initialScale = state.scale
|
||||
state.isDragging = false
|
||||
} else if (event.touches.length === 1) {
|
||||
// 单指触摸,可能是滚动,不阻止默认行为
|
||||
return
|
||||
// 单指拖拽准备
|
||||
state.startX = event.touches[0].clientX - state.translateX
|
||||
state.startY = event.touches[0].clientY - state.translateY
|
||||
state.isDragging = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (index: number, event: TouchEvent) => {
|
||||
const state = imageStates.value[index]
|
||||
if (event.touches.length === 2) {
|
||||
// 双指缩放
|
||||
event.preventDefault() // 只在双指操作时阻止默认行为
|
||||
// 双指缩放处理
|
||||
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
|
||||
state.scale = Math.min(Math.max(scale, 1), 3) // 限制缩放范围1-3倍
|
||||
state.isDragging = false
|
||||
} else if (event.touches.length === 1 && state.isDragging && state.scale > 1) {
|
||||
// 单指拖拽处理(仅在缩放后允许)
|
||||
event.preventDefault()
|
||||
state.translateX = event.touches[0].clientX - state.startX
|
||||
state.translateY = event.touches[0].clientY - state.startY
|
||||
}
|
||||
}
|
||||
|
||||
// 处理滚轮缩放(以鼠标位置为中心点缩放)
|
||||
const handleTouchEnd = (index: number) => {
|
||||
imageStates.value[index].isDragging = false
|
||||
}
|
||||
|
||||
// 鼠标事件处理(滚轮缩放+拖拽)
|
||||
const handleWheel = (index: number, event: WheelEvent) => {
|
||||
// 确保是鼠标滚轮事件而不是触摸板手势
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) {
|
||||
// 可能是水平滚动,不处理缩放
|
||||
return
|
||||
}
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) return
|
||||
|
||||
event.preventDefault()
|
||||
const currentState = imageStates.value[index]
|
||||
|
||||
// 获取图片元素
|
||||
const state = imageStates.value[index]
|
||||
const imgElement = document.getElementById(`image-${index}`)?.querySelector('.manga-image')
|
||||
if (!imgElement) return
|
||||
|
||||
@@ -327,71 +548,106 @@ const handleWheel = (index: number, event: WheelEvent) => {
|
||||
const mouseX = event.clientX - rect.left
|
||||
const mouseY = event.clientY - rect.top
|
||||
|
||||
// 计算当前鼠标位置相对于图片中心的偏移(减小移动幅度)
|
||||
// 计算当前鼠标位置相对于图片中心的偏移
|
||||
const centerX = rect.width / 2.7
|
||||
const centerY = rect.height / 2.5
|
||||
const offsetX = (mouseX - centerX) * 0.4 // 减少移动幅度
|
||||
const offsetY = (mouseY - centerY) * 0.4 // 减少移动幅度
|
||||
const offsetX = (mouseX - centerX) * 0.4
|
||||
const offsetY = (mouseY - centerY) * 0.4
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
// 向上滚动放大
|
||||
if (currentState.scale < 3) {
|
||||
const newScale = currentState.scale * 1.1
|
||||
const scaleRatio = newScale / currentState.scale
|
||||
// 放大
|
||||
if (state.scale < 3) {
|
||||
const newScale = state.scale * 1.1
|
||||
const scaleRatio = newScale / state.scale
|
||||
|
||||
// 以鼠标位置为中心进行缩放(减小位移)
|
||||
const newTranslateX = currentState.translateX - offsetX * (scaleRatio - 1)
|
||||
const newTranslateY = currentState.translateY - offsetY * (scaleRatio - 1)
|
||||
|
||||
currentState.scale = newScale
|
||||
currentState.translateX = newTranslateX
|
||||
currentState.translateY = newTranslateY
|
||||
state.scale = newScale
|
||||
state.translateX = state.translateX - offsetX * (scaleRatio - 1)
|
||||
state.translateY = state.translateY - offsetY * (scaleRatio - 1)
|
||||
}
|
||||
} else {
|
||||
// 向下滚动缩小
|
||||
if (currentState.scale > 1) {
|
||||
const newScale = currentState.scale / 1.1
|
||||
const scaleRatio = newScale / currentState.scale
|
||||
// 缩小
|
||||
if (state.scale > 1) {
|
||||
const newScale = state.scale / 1.1
|
||||
const scaleRatio = newScale / state.scale
|
||||
|
||||
// 以鼠标位置为中心进行缩放(减小位移)
|
||||
const newTranslateX = currentState.translateX - offsetX * (scaleRatio - 1)
|
||||
const newTranslateY = currentState.translateY - offsetY * (scaleRatio - 1)
|
||||
state.scale = newScale
|
||||
state.translateX = state.translateX - offsetX * (scaleRatio - 1)
|
||||
state.translateY = state.translateY - offsetY * (scaleRatio - 1)
|
||||
|
||||
currentState.scale = newScale
|
||||
currentState.translateX = newTranslateX
|
||||
currentState.translateY = newTranslateY
|
||||
|
||||
// 如果缩放到原始大小,重置位置
|
||||
if (currentState.scale <= 1) {
|
||||
currentState.scale = 1
|
||||
currentState.translateX = 0
|
||||
currentState.translateY = 0
|
||||
// 缩放到原始大小则重置位置
|
||||
if (state.scale <= 1) {
|
||||
state.scale = 1
|
||||
state.translateX = 0
|
||||
state.translateY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定图片
|
||||
// 鼠标拖拽处理
|
||||
const handleMouseDown = (index: number, event: MouseEvent) => {
|
||||
const state = imageStates.value[index]
|
||||
if (state.scale > 1 && event.button === 0) { // 仅左键且缩放后允许拖拽
|
||||
state.startX = event.clientX - state.translateX
|
||||
state.startY = event.clientY - state.startY
|
||||
state.isDragging = true
|
||||
|
||||
// 添加临时鼠标移动和释放事件
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (state.isDragging) {
|
||||
state.translateX = e.clientX - state.startX
|
||||
state.translateY = e.clientY - state.startY
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
state.isDragging = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
// 翻页与滚动 - 修复跳转位置
|
||||
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 maxRetries = 15
|
||||
const retryDelay = 150
|
||||
let retryCount = 0
|
||||
|
||||
// 跳转到下一张图片
|
||||
const tryScroll = () => {
|
||||
const element = document.getElementById(`image-${index}`);
|
||||
if (element) {
|
||||
// 强制计算元素位置
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
// 手动调整滚动位置(解决偏移问题)
|
||||
const rect = element.getBoundingClientRect()
|
||||
const offset = rect.top + window.scrollY - (window.innerHeight / 2)
|
||||
window.scrollTo({ top: offset, behavior: 'smooth' })
|
||||
|
||||
currentImageIndex.value = index
|
||||
showDrawer.value = false
|
||||
} else if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryScroll, retryDelay)
|
||||
} else {
|
||||
console.warn(`超过最大重试次数,无法滚动到图片 ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
tryScroll()
|
||||
}
|
||||
// 翻页功能
|
||||
const nextImage = () => {
|
||||
if (currentImageIndex.value < mangaImages.value.length - 1) {
|
||||
scrollToImage(currentImageIndex.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到上一张图片
|
||||
const prevImage = () => {
|
||||
if (currentImageIndex.value > 0) {
|
||||
scrollToImage(currentImageIndex.value - 1)
|
||||
@@ -412,17 +668,14 @@ const throttle = (func: Function, limit: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件,更新当前图片索引
|
||||
// 滚动事件处理
|
||||
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)) {
|
||||
@@ -431,7 +684,6 @@ const handleScroll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 只有当索引真正改变时才更新
|
||||
if (currentImageIndex.value !== currentIndex) {
|
||||
currentImageIndex.value = currentIndex
|
||||
}
|
||||
@@ -441,7 +693,7 @@ const back = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 使用节流优化滚动处理
|
||||
// 节流处理滚动事件
|
||||
const throttledHandleScroll = throttle(handleScroll, 100)
|
||||
|
||||
// 键盘事件处理
|
||||
@@ -470,21 +722,21 @@ watch(
|
||||
(newId) => {
|
||||
console.log('路由参数变化:', newId)
|
||||
if (newId) {
|
||||
abortLoading.value = true // 取消之前的加载
|
||||
abortLoading.value = true
|
||||
fetchAlbum(newId as string)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
let refreshInterval: number | null = null;
|
||||
|
||||
// 新增:刷新token的函数
|
||||
// Token刷新
|
||||
let refreshInterval: number | null = null;
|
||||
const refreshToken = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await axios.post('/api/user/ref', {
|
||||
await axios.post('/api/user/ref', {
|
||||
data: token,
|
||||
timestamp: Date.now()
|
||||
}, {
|
||||
@@ -497,39 +749,39 @@ const refreshToken = async () => {
|
||||
console.error('Refresh token failed:', error);
|
||||
}
|
||||
};
|
||||
// 组件挂载时添加事件监听器
|
||||
|
||||
// 组件挂载
|
||||
onMounted(() => {
|
||||
refreshInterval = window.setInterval(refreshToken, 30000);
|
||||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
// 直接监听 manga-reader 元素的滚动事件,而不是 manga-content
|
||||
|
||||
const mangaReader = document.querySelector('.manga-reader')
|
||||
if (mangaReader) {
|
||||
mangaReader.addEventListener('scroll', throttledHandleScroll)
|
||||
}
|
||||
|
||||
// 初始化当前图片索引
|
||||
handleScroll()
|
||||
})
|
||||
// 替换现有的 watch 监听器
|
||||
|
||||
// 监听阅读进度变化并上报
|
||||
watch([currentImageIndex, loading, mangaImages], ([newIndex, isLoading, images]) => {
|
||||
// 只有在非加载状态且索引有效时才发送请求
|
||||
if (albumInfo.value && !isLoading && newIndex >= 0 &&
|
||||
newIndex < images.length) {
|
||||
if (albumInfo.value && !isLoading && newIndex >= 0 && newIndex < images.length) {
|
||||
reportReadManga(albumInfo.value.album_id, newIndex);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
// 组件卸载
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
}
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
const mangaReader = document.querySelector('.manga-content')
|
||||
|
||||
const mangaReader = document.querySelector('.manga-reader')
|
||||
if (mangaReader) {
|
||||
mangaReader.removeEventListener('scroll', throttledHandleScroll)
|
||||
}
|
||||
abortLoading.value = true // 取消加载
|
||||
abortLoading.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -538,11 +790,6 @@ onUnmounted(() => {
|
||||
@click="toggleMenu"
|
||||
:class="{ 'no-header': shouldHideHeader }">
|
||||
|
||||
<!-- <!– 始终可见的页码显示 –>-->
|
||||
<!-- <div class="page-indicator">-->
|
||||
<!-- {{ currentPageInfo }}-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- 顶部菜单栏 -->
|
||||
<div
|
||||
class="menu-bar"
|
||||
@@ -583,13 +830,19 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="drawer-header">
|
||||
<button @click="back" class="back-btn">ReiJM</button>
|
||||
<div class="back-btn-wrapper">
|
||||
<button @click="back" class="back-btn" title="返回主页">ReiJM</button>
|
||||
<div class="back-tooltip">返回主页</div>
|
||||
</div>
|
||||
<button @click="toggleDrawer" class="close-btn">×</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
|
||||
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
|
||||
</button>
|
||||
<button @click="toggleLove" class="drawer-item" :class="{ 'loved': isLoved }">
|
||||
{{ isLoved ? '❤️ 取消收藏' : '🤍 收藏漫画' }}
|
||||
</button>
|
||||
<div class="page-list">
|
||||
<h4>页面列表</h4>
|
||||
<div
|
||||
@@ -617,6 +870,10 @@ onUnmounted(() => {
|
||||
<span class="label">作者:</span>
|
||||
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
|
||||
</div>
|
||||
<div v-if="albumInfo.album_id" class="meta-item">
|
||||
<span class="label">ID:</span>
|
||||
<span class="value">{{ albumInfo.album_id }}</span>
|
||||
</div>
|
||||
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
|
||||
<span class="label">标签:</span>
|
||||
<span class="value tags">
|
||||
@@ -636,19 +893,27 @@ onUnmounted(() => {
|
||||
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>
|
||||
<template v-if="imageLoadStates[index] === 'loaded' && image">
|
||||
<img
|
||||
: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)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 未加载的占位图片 -->
|
||||
<img
|
||||
:src="generatePlaceholderImage()"
|
||||
:alt="`未加载 - 漫画第 ${index + 1} 页`"
|
||||
class="manga-image placeholder"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -672,7 +937,11 @@ onUnmounted(() => {
|
||||
padding-top: 0; /* 移除顶部内边距 */
|
||||
padding-bottom: 60px; /* 添加底部内边距 */
|
||||
}
|
||||
|
||||
/* 占位图样式 */
|
||||
.manga-image.placeholder {
|
||||
opacity: 0.7;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.manga-reader.no-header {
|
||||
padding-bottom: 0; /* 无头部时也无底部内边距 */
|
||||
}
|
||||
@@ -795,11 +1064,74 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-content {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-btn-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.back-tooltip {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 201;
|
||||
}
|
||||
|
||||
.back-btn-wrapper:hover .back-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.drawer-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -993,4 +1325,21 @@ onUnmounted(() => {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
/* 添加收藏按钮样式 */
|
||||
.menu-content button.loved {
|
||||
background-color: rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-content button.loved:hover:not(:disabled) {
|
||||
background-color: rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.drawer-item.loved {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.drawer-item.loved:hover {
|
||||
background-color: rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,73 +1,143 @@
|
||||
<!-- User.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, watchEffect } from 'vue'
|
||||
import GithubLoginButton from './GithubLoginButton.vue'
|
||||
import {useRoute} from "vue-router";
|
||||
import router from "@/router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
const route = useRoute()
|
||||
|
||||
// 类型定义
|
||||
interface UserInfo {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
avatar_url: string
|
||||
githubId: string
|
||||
mangas_like?: Record<string, string>
|
||||
album2page?: Record<string, number>
|
||||
}
|
||||
|
||||
interface FavoriteItem {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: string
|
||||
index: number
|
||||
name?: string
|
||||
tags?: string[]
|
||||
totalPages?: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
// 状态管理
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const isLoggedIn = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isDarkMode = ref(false)
|
||||
|
||||
// 从URL参数中获取token
|
||||
// 收藏和历史数据
|
||||
const favorites = ref<FavoriteItem[]>([])
|
||||
const historyItems = ref<HistoryItem[]>([])
|
||||
|
||||
// 从URL获取token
|
||||
const getUrlParameter = (name: string): string | null => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
//移除值
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
token: undefined,
|
||||
}
|
||||
}); return urlParams.get(name)
|
||||
router.replace({ query: { ...route.query, token: undefined } })
|
||||
return urlParams.get(name)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let token = getUrlParameter('token')
|
||||
// 如果URL中没有token,则从localStorage中获取
|
||||
if (!token || token === 'error') {
|
||||
token = localStorage.getItem('token')
|
||||
}
|
||||
if (!token || token === 'error') {
|
||||
if (token === 'error') {
|
||||
error.value = '登录失败'
|
||||
// 跳转漫画详情
|
||||
const goToManga = (mangaId: string) => {
|
||||
router.push(`/manga/${mangaId}`)
|
||||
}
|
||||
|
||||
// 获取漫画详情
|
||||
const fetchMangaDetails = async (item: HistoryItem) => {
|
||||
try {
|
||||
item.loading = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch(`/api/manga/mangaE?mangaId=${item.id}`, {
|
||||
headers: { Token: token || '' }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const manga = await response.json()
|
||||
item.name = manga.name
|
||||
item.tags = manga.tags
|
||||
item.totalPages = manga.image_urls?.length || 0
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取漫画详情出错:', err)
|
||||
} finally {
|
||||
item.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
// 检查深色模式偏好
|
||||
isDarkMode.value = localStorage.getItem('darkMode') === 'true' ||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
let token = getUrlParameter('token') || localStorage.getItem('token')
|
||||
|
||||
if (!token || token === 'error') {
|
||||
error.value = token === 'error' ? '登录失败' : null
|
||||
loading.value = false
|
||||
isLoggedIn.value = false
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('token', token)
|
||||
//写到localStorage
|
||||
|
||||
try {
|
||||
// 调用API获取用户信息(需要后端提供此接口)
|
||||
const response = await fetch(`/api/user/data`, { headers: { Token: `${token}` }})
|
||||
const response = await fetch(`/api/user/data`, { headers: { Token: token }})
|
||||
if (response.ok) {
|
||||
userInfo.value = await response.json()
|
||||
const userData = await response.json()
|
||||
|
||||
if (userData.id?.includes('Error')) {
|
||||
throw new Error('用户认证失效,请重新登录')
|
||||
}
|
||||
|
||||
userInfo.value = userData
|
||||
isLoggedIn.value = true
|
||||
|
||||
// 处理收藏
|
||||
if (userData.mangas_like && typeof userData.mangas_like === 'object') {
|
||||
favorites.value = Object.entries(userData.mangas_like)
|
||||
.map(([id, title]) => ({ id, title: title as string }))
|
||||
}
|
||||
|
||||
// 处理历史记录
|
||||
if (userData.album2page && typeof userData.album2page === 'object') {
|
||||
historyItems.value = Object.entries(userData.album2page)
|
||||
.map(([id, index]) => ({ id, index, loading: false }))
|
||||
|
||||
historyItems.value.forEach(item => fetchMangaDetails(item))
|
||||
}
|
||||
} else {
|
||||
throw new Error('获取用户信息失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '未知错误'
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('token')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 监听深色模式变化
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', isDarkMode.value)
|
||||
localStorage.setItem('darkMode', isDarkMode.value.toString())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-page">
|
||||
<br><br>
|
||||
<div v-if="loading" class="loading">
|
||||
正在加载用户信息...
|
||||
</div>
|
||||
@@ -84,23 +154,69 @@ onMounted(async () => {
|
||||
|
||||
<div v-else-if="userInfo" class="user-container">
|
||||
<div class="user-content">
|
||||
<!-- 左侧:用户基本信息 (3/10宽度) -->
|
||||
<div class="user-info">
|
||||
<!-- 左侧:用户信息 -->
|
||||
<div class="user-info glass-effect">
|
||||
<img
|
||||
:src="userInfo.avatar_url"
|
||||
:alt="userInfo.username"
|
||||
class="avatar"
|
||||
:src="userInfo.avatar_url"
|
||||
:alt="userInfo.username"
|
||||
class="avatar"
|
||||
/>
|
||||
<h2>{{ userInfo.username }}</h2>
|
||||
<p v-if="userInfo.email" class="email">邮箱: {{ userInfo.email }}</p>
|
||||
</div>
|
||||
<div class="actions-container">
|
||||
<!-- 右侧:收藏和历史记录 -->
|
||||
<div class="user-actions">
|
||||
<!-- 收藏区域 -->
|
||||
<div class="favorites-section glass-effect">
|
||||
<h3>我的收藏</h3>
|
||||
<div class="scroll-container">
|
||||
<div v-if="favorites.length === 0" class="no-content">
|
||||
暂无收藏内容
|
||||
</div>
|
||||
|
||||
<!-- 右侧:预留空间 (7/10宽度) -->
|
||||
<div class="user-actions">
|
||||
<div class="placeholder">
|
||||
<!-- 这里可以添加用户操作功能,如编辑资料、登出等 -->
|
||||
<h3>用户操作区</h3>
|
||||
<p>此处可以添加用户相关的功能操作</p>
|
||||
<ul class="favorites-list" v-else>
|
||||
<li
|
||||
v-for="item in favorites"
|
||||
:key="item.id"
|
||||
class="favorite-item"
|
||||
@click="goToManga(item.id)"
|
||||
>
|
||||
<span>{{ item.id }}. {{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史阅读区域 -->
|
||||
<div class="history-section glass-effect">
|
||||
<h3>历史阅读</h3>
|
||||
<div class="scroll-container">
|
||||
<div v-if="historyItems.length === 0" class="no-content">
|
||||
暂无阅读记录
|
||||
</div>
|
||||
|
||||
<ul class="history-list" v-else>
|
||||
<li
|
||||
v-for="item in historyItems"
|
||||
:key="item.id"
|
||||
class="history-item"
|
||||
@click="goToManga(item.id)"
|
||||
>
|
||||
<div v-if="item.loading" class="loading-indicator">加载中...</div>
|
||||
<div v-else>
|
||||
<div class="item-title">{{ item.name || `漫画 ${item.id}` }}</div>
|
||||
<div class="item-tags">
|
||||
<span v-for="(tag, idx) in item.tags" :key="idx" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="reading-progress">
|
||||
阅读进度: {{ item.index }} / {{ item.totalPages || '未知' }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,122 +225,219 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 基础样式保持不变 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s ease;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
/* 新增:为固定导航栏预留顶部空间 */
|
||||
padding-top: 60px; /* 导航栏高度约56px,预留60px避免内容被遮挡 */
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.user-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* 关键修改:计算高度时减去导航栏和页面内边距 */
|
||||
min-height: calc(100vh - 60px); /* 减去顶部预留的60px */
|
||||
padding: 15px; /* 减少内边距,压缩整体高度 */
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 20px;
|
||||
/* 液态玻璃效果保持不变 */
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dark .glass-effect {
|
||||
background: rgba(30, 41, 59, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 主题切换按钮保持不变 */
|
||||
.theme-toggle {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.dark .theme-toggle {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
/* 加载、错误和登录区域调整 */
|
||||
.loading, .error, .login-section {
|
||||
text-align: center;
|
||||
margin: 20px auto; /* 减少上下边距 */
|
||||
padding: 15px; /* 减少内边距 */
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.login-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-section h2 {
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-section p {
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 用户容器布局 - 关键高度调整 */
|
||||
.user-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
/* 调整高度计算,减去导航栏和内边距 */
|
||||
height: calc(100vh - 100px); /* 60px导航栏 + 40px内边距 */
|
||||
}
|
||||
|
||||
.user-content {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
gap: 15px; /* 减少间距 */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 左侧用户信息区域 */
|
||||
/* 左侧用户信息区域调整 */
|
||||
.user-info {
|
||||
flex: 3;
|
||||
}
|
||||
|
||||
/* 右侧预留区域 */
|
||||
.user-actions {
|
||||
flex: 7;
|
||||
padding: 20px 15px; /* 减少内边距 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.placeholder h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 头像样式 */
|
||||
.avatar {
|
||||
width: 120px;
|
||||
width: 120px; /* 缩小头像尺寸 */
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 15px; /* 减少间距 */
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.user-info h2 {
|
||||
margin: 10px 0;
|
||||
color: #333;
|
||||
.email {
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
font-size: 0.95em; /* 略小字体 */
|
||||
}
|
||||
|
||||
.user-info .email,
|
||||
.user-info .github-id {
|
||||
margin: 10px 0;
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
/* 右侧内容区域调整 */
|
||||
.user-actions {
|
||||
flex: 7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px; /* 减少区域间距 */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 移动端响应式布局 */
|
||||
@media (max-width: 768px) {
|
||||
.user-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
/* 收藏和历史区域 */
|
||||
.favorites-section, .history-section {
|
||||
flex: 1;
|
||||
padding: 15px; /* 减少内边距 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.user-info,
|
||||
.user-actions {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
/* 滚动容器 */
|
||||
.scroll-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
margin-top: 10px; /* 减少间距 */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
/* 滚动条美化保持不变 */
|
||||
.scroll-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.user-page {
|
||||
padding: 10px;
|
||||
}
|
||||
.scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
.scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dark .scroll-container::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dark .scroll-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 列表样式调整 */
|
||||
.favorite-item, .history-item {
|
||||
padding: 10px 12px; /* 减少内边距 */
|
||||
margin-bottom: 8px; /* 减少间距 */
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dark .favorite-item, .dark .history-item {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 历史项目样式微调 */
|
||||
.item-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px; /* 减少间距 */
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.item-tags {
|
||||
gap: 4px;
|
||||
margin-bottom: 6px; /* 减少间距 */
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.75em; /* 缩小标签字体 */
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.reading-progress {
|
||||
font-size: 0.85em; /* 缩小进度文字 */
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 空状态和加载状态 */
|
||||
.no-content, .loading-indicator {
|
||||
padding: 15px; /* 减少内边距 */
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,6 @@ export default defineConfig({
|
||||
cssMinify: true,
|
||||
},
|
||||
plugins: [
|
||||
cesium(),
|
||||
vue(),
|
||||
viteCompression({
|
||||
verbose: true,
|
||||
@@ -101,6 +100,33 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
allowedHosts: ["w.godserver.cn",'godserver.cn','www.godserver.cn','rbq.college','mai.godserver.cn'],
|
||||
proxy: {
|
||||
"/aapi": {
|
||||
target: "https://reijm.godserver.cn/api/",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache"
|
||||
// 移除 Accept 头部,避免覆盖客户端发送的 Accept: text/event-stream
|
||||
},
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
// 确保不覆盖客户端的 Accept 头部
|
||||
if (req.headers.accept) {
|
||||
proxyReq.setHeader('Accept', req.headers.accept);
|
||||
}
|
||||
});
|
||||
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
// 确保流式响应的头部设置正确
|
||||
proxyRes.headers['Content-Type'] = 'text/event-stream';
|
||||
proxyRes.headers['Cache-Control'] = 'no-cache';
|
||||
proxyRes.headers['Connection'] = 'keep-alive';
|
||||
proxyRes.headers['X-Accel-Buffering'] = 'no';
|
||||
});
|
||||
}
|
||||
},
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8981/api",
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user