diff --git a/backend/README.md b/backend/README.md index 34401d1..4c090ef 100644 --- a/backend/README.md +++ b/backend/README.md @@ -89,6 +89,12 @@ backend/ - `GET /api/proxy/image` - 图片代理服务 - 参数: `url` (图片URL) +- `GET /api/proxy/cache/stats` - 获取图片缓存统计信息 +- `DELETE /api/proxy/cache` - 清理所有图片缓存 +- `DELETE /api/proxy/cache/expired` - 清理过期图片缓存 +- `GET /api/proxy/cache/config` - 获取缓存配置 +- `PUT /api/proxy/cache/config` - 更新缓存配置 +- `POST /api/proxy/cache/config/reset` - 重置缓存配置为默认值 ### 仓库管理相关 @@ -158,6 +164,7 @@ backend/ - **artist.js**: 作者服务,处理作者API调用 - **download.js**: 下载服务,处理文件下载 - **repository.js**: 仓库管理服务,处理文件管理和配置 +- **image-cache.js**: 图片缓存服务,管理图片代理缓存 ### 工具类 @@ -204,6 +211,13 @@ backend/ - 磁盘使用情况监控 - 作品删除管理 +### 7. 图片缓存管理 +- 图片代理缓存功能 +- 自动缓存过期清理 +- 缓存大小限制管理 +- 缓存统计信息查看 +- 手动缓存清理功能 + ## 🔒 安全特性 - 统一的错误处理 diff --git a/backend/config/cache-config.js b/backend/config/cache-config.js new file mode 100644 index 0000000..c8c978b --- /dev/null +++ b/backend/config/cache-config.js @@ -0,0 +1,200 @@ +const fs = require('fs').promises; +const path = require('path'); + +/** + * 缓存配置管理器 + * 负责管理图片缓存的配置选项 + */ +class CacheConfigManager { + constructor() { + // 检测是否在pkg打包环境中运行 + const isPkg = process.pkg !== undefined; + + if (isPkg) { + // 在打包环境中,使用可执行文件所在目录 + this.configPath = path.join(process.cwd(), 'data', 'cache-config.json'); + } else { + // 在开发环境中,使用相对路径 + this.configPath = path.join(__dirname, 'cache-config.json'); + } + + // 确保路径是绝对路径 + this.configPath = path.resolve(this.configPath); + + // 默认配置 + this.defaultConfig = { + // 缓存配置 + maxAge: 24 * 60 * 60 * 1000, // 24小时缓存 + maxSize: 100 * 1024 * 1024, // 100MB最大缓存大小 + cleanupInterval: 60 * 60 * 1000, // 1小时清理一次 + + // 缓存启用状态 + enabled: true, + + // 代理配置 + proxy: { + enabled: true, + timeout: 30000, // 30秒超时 + retryCount: 3, // 重试次数 + retryDelay: 1000, // 重试延迟(毫秒) + }, + + // 文件类型过滤 + allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'], + + // 最后更新时间 + lastUpdated: new Date().toISOString() + }; + + // 确保配置目录存在 + this.ensureConfigDir(); + } + + /** + * 确保配置目录存在 + */ + ensureConfigDir() { + try { + const configDir = path.dirname(this.configPath); + if (!require('fs').existsSync(configDir)) { + require('fs').mkdirSync(configDir, { recursive: true }); + console.log('缓存配置目录创建成功:', configDir); + } + } catch (error) { + console.error('创建缓存配置目录失败:', error); + } + } + + /** + * 初始化配置文件 + */ + async initialize() { + try { + // 检查配置文件是否存在 + await fs.access(this.configPath); + console.log('缓存配置文件已存在'); + } catch (error) { + // 配置文件不存在,创建默认配置 + console.log('创建默认缓存配置文件...'); + await this.createDefaultConfig(); + } + } + + /** + * 创建默认配置文件 + */ + async createDefaultConfig() { + try { + const configContent = JSON.stringify(this.defaultConfig, null, 2); + await fs.writeFile(this.configPath, configContent, 'utf8'); + console.log('默认缓存配置文件创建成功:', this.configPath); + } catch (error) { + console.error('创建默认缓存配置文件失败:', error); + throw error; + } + } + + /** + * 加载配置 + */ + async loadConfig() { + try { + const configContent = await fs.readFile(this.configPath, 'utf8'); + const config = JSON.parse(configContent); + + // 合并默认配置,确保所有字段都存在 + return { ...this.defaultConfig, ...config }; + } catch (error) { + console.error('加载缓存配置失败:', error); + return this.defaultConfig; + } + } + + /** + * 保存配置 + */ + async saveConfig(config) { + try { + // 更新最后修改时间 + config.lastUpdated = new Date().toISOString(); + + const configContent = JSON.stringify(config, null, 2); + await fs.writeFile(this.configPath, configContent, 'utf8'); + console.log('缓存配置保存成功'); + } catch (error) { + console.error('保存缓存配置失败:', error); + throw error; + } + } + + /** + * 更新配置 + */ + async updateConfig(updates) { + try { + const currentConfig = await this.loadConfig(); + const newConfig = { ...currentConfig, ...updates }; + await this.saveConfig(newConfig); + return newConfig; + } catch (error) { + console.error('更新缓存配置失败:', error); + throw error; + } + } + + /** + * 重置为默认配置 + */ + async resetToDefault() { + try { + await this.saveConfig(this.defaultConfig); + console.log('缓存配置已重置为默认值'); + return this.defaultConfig; + } catch (error) { + console.error('重置缓存配置失败:', error); + throw error; + } + } + + /** + * 验证配置 + */ + validateConfig(config) { + const errors = []; + + if (config.maxAge < 0) { + errors.push('maxAge 必须大于等于 0'); + } + + if (config.maxSize < 0) { + errors.push('maxSize 必须大于等于 0'); + } + + if (config.cleanupInterval < 0) { + errors.push('cleanupInterval 必须大于等于 0'); + } + + if (config.proxy.timeout < 0) { + errors.push('proxy.timeout 必须大于等于 0'); + } + + if (config.proxy.retryCount < 0) { + errors.push('proxy.retryCount 必须大于等于 0'); + } + + if (config.proxy.retryDelay < 0) { + errors.push('proxy.retryDelay 必须大于等于 0'); + } + + return errors; + } + + /** + * 获取配置路径 + */ + getConfigPath() { + return this.configPath; + } +} + +module.exports = CacheConfigManager; \ No newline at end of file diff --git a/backend/config/cache-config.json b/backend/config/cache-config.json new file mode 100644 index 0000000..9f266ab --- /dev/null +++ b/backend/config/cache-config.json @@ -0,0 +1,21 @@ +{ + "maxAge": 86400000, + "maxSize": 104857600, + "cleanupInterval": 3600000, + "enabled": true, + "proxy": { + "enabled": true, + "timeout": 30000, + "retryCount": 3, + "retryDelay": 1000 + }, + "allowedExtensions": [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".bmp" + ], + "lastUpdated": "2025-08-25T06:14:10.559Z" +} \ No newline at end of file diff --git a/backend/routes/proxy.js b/backend/routes/proxy.js index 4400479..df7c3da 100644 --- a/backend/routes/proxy.js +++ b/backend/routes/proxy.js @@ -1,6 +1,9 @@ const express = require('express'); const router = express.Router(); -const axios = require('axios'); +const ImageCacheService = require('../services/image-cache'); + +// 创建缓存服务实例 +const imageCache = new ImageCacheService(); /** * 图片代理 @@ -17,28 +20,22 @@ router.get('/image', async (req, res) => { }); } - const response = await axios({ - method: 'GET', - url: decodeURIComponent(url), - responseType: 'stream', - headers: { - 'Referer': 'https://www.pixiv.net/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - }, - timeout: 30000 - }); - + const decodedUrl = decodeURIComponent(url); + + // 使用缓存服务获取图片 + const imageData = await imageCache.getImage(decodedUrl); + // 设置响应头 res.set({ - 'Content-Type': response.headers['content-type'], - 'Cache-Control': 'public, max-age=3600', // 缓存1小时 + 'Content-Type': getContentType(decodedUrl), + 'Cache-Control': 'public, max-age=3600', // 浏览器缓存1小时 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type' }); - // 流式传输图片数据 - response.data.pipe(res); + // 发送图片数据 + res.send(imageData); } catch (error) { console.error('Image proxy error:', error.message); @@ -49,4 +46,141 @@ router.get('/image', async (req, res) => { } }); +/** + * 缓存管理 - 获取缓存统计信息 + * GET /api/proxy/cache/stats + */ +router.get('/cache/stats', async (req, res) => { + try { + const stats = await imageCache.getCacheStats(); + res.json({ + success: true, + data: stats + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 缓存管理 - 清理所有缓存 + * DELETE /api/proxy/cache + */ +router.delete('/cache', async (req, res) => { + try { + await imageCache.clearAllCache(); + res.json({ + success: true, + message: '所有缓存已清理' + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 缓存管理 - 清理过期缓存 + * DELETE /api/proxy/cache/expired + */ +router.delete('/cache/expired', async (req, res) => { + try { + await imageCache.cleanupExpiredCache(); + res.json({ + success: true, + message: '过期缓存已清理' + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 缓存管理 - 获取缓存配置 + * GET /api/proxy/cache/config + */ +router.get('/cache/config', async (req, res) => { + try { + const config = await imageCache.getConfig(); + res.json({ + success: true, + data: config + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 缓存管理 - 更新缓存配置 + * PUT /api/proxy/cache/config + */ +router.put('/cache/config', async (req, res) => { + try { + const updates = req.body; + const config = await imageCache.updateConfig(updates); + res.json({ + success: true, + data: config, + message: '缓存配置已更新' + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 缓存管理 - 重置缓存配置 + * POST /api/proxy/cache/config/reset + */ +router.post('/cache/config/reset', async (req, res) => { + try { + const config = await imageCache.resetConfig(); + res.json({ + success: true, + data: config, + message: '缓存配置已重置为默认值' + }); + } catch (error) { + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +/** + * 获取文件内容类型 + * @param {string} url 图片URL + * @returns {string} 内容类型 + */ +function getContentType(url) { + const ext = url.split('.').pop()?.toLowerCase(); + const contentTypeMap = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'svg': 'image/svg+xml' + }; + + return contentTypeMap[ext] || 'image/jpeg'; +} + module.exports = router; \ No newline at end of file diff --git a/backend/services/image-cache.js b/backend/services/image-cache.js new file mode 100644 index 0000000..c228b3c --- /dev/null +++ b/backend/services/image-cache.js @@ -0,0 +1,421 @@ +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); +const axios = require('axios'); +const CacheConfigManager = require('../config/cache-config'); + +/** + * 图片缓存服务 + * 负责管理图片代理的缓存功能 + */ +class ImageCacheService { + constructor() { + // 检测是否在pkg打包环境中运行 + const isPkg = process.pkg !== undefined; + + if (isPkg) { + // 在打包环境中,使用可执行文件所在目录 + this.cacheDir = path.join(process.cwd(), 'data', 'image-cache'); + } else { + // 在开发环境中,使用相对路径 + this.cacheDir = path.join(__dirname, '..', 'data', 'image-cache'); + } + + // 确保路径是绝对路径 + this.cacheDir = path.resolve(this.cacheDir); + + // 创建配置管理器 + this.configManager = new CacheConfigManager(); + + // 默认缓存配置 + this.config = { + maxAge: 24 * 60 * 60 * 1000, // 24小时缓存 + maxSize: 100 * 1024 * 1024, // 100MB最大缓存大小 + cleanupInterval: 60 * 60 * 1000, // 1小时清理一次 + enabled: true, + proxy: { + enabled: true, + timeout: 30000, + retryCount: 3, + retryDelay: 1000, + }, + allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'], + }; + + // 初始化配置 + this.initializeConfig(); + } + + /** + * 初始化配置 + */ + async initializeConfig() { + try { + await this.configManager.initialize(); + const config = await this.configManager.loadConfig(); + this.config = { ...this.config, ...config }; + + // 确保缓存目录存在 + await this.ensureCacheDir(); + + // 启动定期清理任务 + this.startCleanupTask(); + + console.log('图片缓存服务初始化完成'); + } catch (error) { + console.error('图片缓存服务初始化失败:', error); + } + } + + /** + * 确保缓存目录存在 + */ + async ensureCacheDir() { + try { + await fs.mkdir(this.cacheDir, { recursive: true }); + console.log('图片缓存目录创建成功:', this.cacheDir); + } catch (error) { + console.error('创建图片缓存目录失败:', error); + } + } + + /** + * 生成缓存文件名 + * @param {string} url 原始图片URL + * @returns {string} 缓存文件名 + */ + generateCacheKey(url) { + const hash = crypto.createHash('md5').update(url).digest('hex'); + const ext = this.getFileExtension(url); + return `${hash}${ext}`; + } + + /** + * 获取文件扩展名 + * @param {string} url 图片URL + * @returns {string} 文件扩展名 + */ + getFileExtension(url) { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const ext = path.extname(pathname); + return ext || '.jpg'; // 默认使用.jpg + } catch (error) { + return '.jpg'; + } + } + + /** + * 获取缓存文件路径 + * @param {string} url 原始图片URL + * @returns {string} 缓存文件路径 + */ + getCacheFilePath(url) { + const cacheKey = this.generateCacheKey(url); + return path.join(this.cacheDir, cacheKey); + } + + /** + * 检查缓存是否存在且有效 + * @param {string} url 原始图片URL + * @returns {Promise} 缓存是否有效 + */ + async isCacheValid(url) { + try { + const cachePath = this.getCacheFilePath(url); + const stats = await fs.stat(cachePath); + + // 检查文件是否过期 + const age = Date.now() - stats.mtime.getTime(); + return age < this.config.maxAge; + } catch (error) { + return false; + } + } + + /** + * 从缓存获取图片 + * @param {string} url 原始图片URL + * @returns {Promise} 图片数据,如果缓存不存在则返回null + */ + async getFromCache(url) { + try { + if (!(await this.isCacheValid(url))) { + return null; + } + + const cachePath = this.getCacheFilePath(url); + const data = await fs.readFile(cachePath); + + // 更新文件访问时间 + await fs.utimes(cachePath, new Date(), new Date()); + + return data; + } catch (error) { + console.error('读取缓存失败:', error); + return null; + } + } + + /** + * 将图片保存到缓存 + * @param {string} url 原始图片URL + * @param {Buffer} data 图片数据 + * @returns {Promise} + */ + async saveToCache(url, data) { + try { + const cachePath = this.getCacheFilePath(url); + await fs.writeFile(cachePath, data); + + // 检查缓存大小,如果超过限制则清理 + await this.checkCacheSize(); + } catch (error) { + console.error('保存缓存失败:', error); + } + } + + /** + * 从网络下载图片并缓存 + * @param {string} url 图片URL + * @returns {Promise} 图片数据 + */ + async downloadAndCache(url) { + let lastError; + + for (let attempt = 1; attempt <= this.config.proxy.retryCount; attempt++) { + try { + const response = await axios({ + method: 'GET', + url: url, + responseType: 'arraybuffer', + headers: { + 'Referer': 'https://www.pixiv.net/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + }, + timeout: this.config.proxy.timeout + }); + + const data = Buffer.from(response.data); + + // 异步保存到缓存(不等待完成) + if (this.config.enabled) { + this.saveToCache(url, data).catch(error => { + console.error('异步保存缓存失败:', error); + }); + } + + return data; + } catch (error) { + lastError = error; + + if (attempt < this.config.proxy.retryCount) { + await new Promise(resolve => setTimeout(resolve, this.config.proxy.retryDelay)); + } + } + } + + throw lastError; + } + + /** + * 获取图片(优先从缓存,缓存不存在则下载) + * @param {string} url 图片URL + * @returns {Promise} 图片数据 + */ + async getImage(url) { + // 检查缓存是否启用 + if (!this.config.enabled) { + return await this.downloadAndCache(url); + } + + // 检查文件类型是否允许缓存 + const ext = this.getFileExtension(url); + if (!this.config.allowedExtensions.includes(ext)) { + return await this.downloadAndCache(url); + } + + // 首先尝试从缓存获取 + const cachedData = await this.getFromCache(url); + if (cachedData) { + return cachedData; + } + + // 缓存不存在,从网络下载 + return await this.downloadAndCache(url); + } + + /** + * 检查缓存大小并清理 + * @returns {Promise} + */ + async checkCacheSize() { + try { + const files = await fs.readdir(this.cacheDir); + let totalSize = 0; + const fileStats = []; + + // 计算总大小和收集文件信息 + for (const file of files) { + const filePath = path.join(this.cacheDir, file); + const stats = await fs.stat(filePath); + totalSize += stats.size; + fileStats.push({ + path: filePath, + size: stats.size, + mtime: stats.mtime + }); + } + + // 如果超过最大大小,删除最旧的文件 + if (totalSize > this.config.maxSize) { + console.log(`缓存大小 ${totalSize} 超过限制 ${this.config.maxSize},开始清理...`); + + // 按修改时间排序,删除最旧的文件 + fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); + + for (const file of fileStats) { + await fs.unlink(file.path); + totalSize -= file.size; + + if (totalSize <= this.config.maxSize * 0.8) { // 清理到80% + break; + } + } + + console.log(`缓存清理完成,当前大小: ${totalSize}`); + } + } catch (error) { + console.error('检查缓存大小失败:', error); + } + } + + /** + * 清理过期缓存 + * @returns {Promise} + */ + async cleanupExpiredCache() { + try { + const files = await fs.readdir(this.cacheDir); + let cleanedCount = 0; + + for (const file of files) { + const filePath = path.join(this.cacheDir, file); + const stats = await fs.stat(filePath); + + const age = Date.now() - stats.mtime.getTime(); + if (age > this.config.maxAge) { + await fs.unlink(filePath); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log(`清理了 ${cleanedCount} 个过期缓存文件`); + } + } catch (error) { + console.error('清理过期缓存失败:', error); + } + } + + /** + * 启动定期清理任务 + */ + startCleanupTask() { + setInterval(() => { + this.cleanupExpiredCache().catch(error => { + console.error('定期清理任务失败:', error); + }); + }, this.config.cleanupInterval); + } + + /** + * 手动清理所有缓存 + * @returns {Promise} + */ + async clearAllCache() { + try { + const files = await fs.readdir(this.cacheDir); + + for (const file of files) { + const filePath = path.join(this.cacheDir, file); + await fs.unlink(filePath); + } + + console.log('所有缓存已清理'); + } catch (error) { + console.error('清理所有缓存失败:', error); + throw error; + } + } + + /** + * 获取缓存统计信息 + * @returns {Promise} 缓存统计信息 + */ + async getCacheStats() { + try { + const files = await fs.readdir(this.cacheDir); + let totalSize = 0; + let fileCount = 0; + + for (const file of files) { + const filePath = path.join(this.cacheDir, file); + const stats = await fs.stat(filePath); + totalSize += stats.size; + fileCount++; + } + + return { + fileCount, + totalSize, + maxSize: this.config.maxSize, + maxAge: this.config.maxAge, + enabled: this.config.enabled, + config: this.config + }; + } catch (error) { + console.error('获取缓存统计失败:', error); + return { + fileCount: 0, + totalSize: 0, + maxSize: this.config.maxSize, + maxAge: this.config.maxAge, + enabled: this.config.enabled, + config: this.config + }; + } + } + + /** + * 获取缓存配置 + * @returns {Promise} 缓存配置 + */ + async getConfig() { + return await this.configManager.loadConfig(); + } + + /** + * 更新缓存配置 + * @param {Object} updates 配置更新 + * @returns {Promise} 更新后的配置 + */ + async updateConfig(updates) { + const newConfig = await this.configManager.updateConfig(updates); + this.config = { ...this.config, ...newConfig }; + return newConfig; + } + + /** + * 重置缓存配置 + * @returns {Promise} 重置后的配置 + */ + async resetConfig() { + const defaultConfig = await this.configManager.resetToDefault(); + this.config = { ...this.config, ...defaultConfig }; + return defaultConfig; + } +} + +module.exports = ImageCacheService; \ No newline at end of file diff --git a/pixiv-manager-portable.rar b/pixiv-manager-portable.rar new file mode 100644 index 0000000..7186ffc Binary files /dev/null and b/pixiv-manager-portable.rar differ diff --git a/ui/src/components/cache/CacheManager.vue b/ui/src/components/cache/CacheManager.vue new file mode 100644 index 0000000..5bcb9ee --- /dev/null +++ b/ui/src/components/cache/CacheManager.vue @@ -0,0 +1,348 @@ + + + + + \ No newline at end of file diff --git a/ui/src/views/ArtistView.vue b/ui/src/views/ArtistView.vue index dee393c..e15dc3b 100644 --- a/ui/src/views/ArtistView.vue +++ b/ui/src/views/ArtistView.vue @@ -56,8 +56,6 @@ - -
@@ -102,11 +100,23 @@
+ +
+
+ + + +
+
+
第 {{ currentPage }} 页,共 {{ totalPages }} 页 @@ -247,7 +257,7 @@ const fetchArtistInfo = async () => { }; // 获取作者作品 -const fetchArtworks = async (page = 1) => { +const fetchArtworks = async (page = 1, isJumpToPage = false) => { if (!artist.value) return; const cacheKey = getCacheKey(artworkType.value, page); @@ -313,8 +323,14 @@ const fetchArtworks = async (page = 1) => { throw new Error(response.error || '获取作品列表失败'); } } catch (err) { - error.value = err instanceof Error ? err.message : '获取作品列表失败'; console.error('获取作品列表失败:', err); + + // 只有在跳转到指定页面失败时才显示错误 + if (isJumpToPage) { + error.value = `跳转失败:无法跳转到第 ${page} 页`; + } else { + error.value = err instanceof Error ? err.message : '获取作品列表失败'; + } } finally { artworksLoading.value = false; } @@ -323,12 +339,30 @@ const fetchArtworks = async (page = 1) => { // 处理类型切换 const handleTypeChange = () => { currentPage.value = 1; + + // 清除URL中的页码参数 + router.push({ + query: { + ...route.query, + page: undefined + } + }); + fetchArtworks(1); }; // 跳转到指定页面 const goToPage = (page: number) => { - if (page < 1 || page > totalPages.value || page === currentPage.value) return; + if (page < 1 || page === currentPage.value) return; + + // 更新URL参数 + router.push({ + query: { + ...route.query, + page: page.toString() + } + }); + fetchArtworks(page); }; @@ -407,22 +441,63 @@ const clearError = () => { error.value = null; }; +// 跳转到指定页面输入框 +const jumpPageInput = ref(''); +const jumping = ref(false); + +// 处理跳转到指定页面 +const handleJumpToPage = async () => { + const page = parseInt(jumpPageInput.value as string); + if (isNaN(page) || page < 1) { + error.value = '请输入有效的页码'; + return; + } + + jumping.value = true; + jumpPageInput.value = ''; // 清空输入框 + + // 更新URL参数 + router.push({ + query: { + ...route.query, + page: page.toString() + } + }); + + try { + await fetchArtworks(page, true); + } finally { + jumping.value = false; + } +}; + // 监听路由变化 watch(() => route.params.id, () => { // 清除缓存并重新加载 clearCache(); fetchArtistInfo(); - // 检查是否有返回的页面信息 + // 检查是否有返回的页面信息或指定的页码 const returnPage = parseInt(route.query.page as string); if (returnPage && returnPage > 0) { currentPage.value = returnPage; - fetchArtworks(returnPage); + fetchArtworks(returnPage, true); } else { fetchArtworks(1); } }); +// 监听URL查询参数变化 +watch(() => route.query.page, (newPage) => { + if (newPage && artist.value) { + const page = parseInt(newPage as string); + if (page > 0 && page !== currentPage.value) { + currentPage.value = page; + fetchArtworks(page, true); + } + } +}); + // 组件卸载时清理缓存 onUnmounted(() => { clearCache(); @@ -431,11 +506,11 @@ onUnmounted(() => { onMounted(async () => { await fetchArtistInfo(); - // 检查是否有返回的页面信息 + // 检查是否有返回的页面信息或指定的页码 const returnPage = parseInt(route.query.page as string); if (returnPage && returnPage > 0) { currentPage.value = returnPage; - await fetchArtworks(returnPage); + await fetchArtworks(returnPage, true); } else { await fetchArtworks(1); } @@ -749,6 +824,73 @@ onMounted(async () => { font-size: 0.875rem; } +.jump-to-page { + display: flex; + justify-content: center; + margin-top: 1rem; + margin-bottom: 1rem; + padding: 1rem; + background: #f9fafb; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; +} + +.jump-input-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.jump-input-group label { + font-size: 0.875rem; + color: #374151; + font-weight: 500; + white-space: nowrap; +} + +.jump-input { + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + background: white; + font-size: 0.875rem; + color: #374151; + min-width: 80px; + text-align: center; + transition: border-color 0.2s; +} + +.jump-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.jump-btn { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + background: #3b82f6; + color: white; + font-weight: 600; + text-decoration: none; + transition: all 0.2s; + border: none; + cursor: pointer; + font-size: 0.875rem; + min-width: 80px; +} + +.jump-btn:hover:not(:disabled) { + background: #2563eb; + transform: translateY(-1px); +} + +.jump-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + @media (max-width: 768px) { .container { padding: 0 1rem; @@ -806,5 +948,23 @@ onMounted(async () => { gap: 0.5rem; text-align: center; } + + .jump-to-page { + flex-direction: column; + gap: 0.5rem; + } + + .jump-input-group { + flex-direction: column; + align-items: flex-start; + } + + .jump-input { + width: 100%; + } + + .jump-btn { + width: 100%; + } } \ No newline at end of file diff --git a/ui/src/views/SearchView.vue b/ui/src/views/SearchView.vue index 6283c82..03c6d3b 100644 --- a/ui/src/views/SearchView.vue +++ b/ui/src/views/SearchView.vue @@ -7,16 +7,19 @@
- - - -
@@ -81,20 +84,20 @@
- - - @@ -203,6 +206,14 @@ const handleSearch = async () => { return; } + // 更新URL参数 + const query: any = { ...route.query }; + query.keyword = searchKeyword.value.trim(); + query.mode = 'keyword'; + // 清除标签相关参数 + delete query.tags; + router.push({ query }); + try { loading.value = true; error.value = null; @@ -299,6 +310,15 @@ const handleArtworkSearch = () => { return; } + // 更新URL参数 + const query: any = { ...route.query }; + query.artworkId = idStr; + query.mode = 'artwork'; + // 清除其他搜索参数 + delete query.keyword; + delete query.tags; + router.push({ query }); + router.push(`/artwork/${id}`); }; @@ -316,6 +336,15 @@ const handleArtistSearch = () => { return; } + // 更新URL参数 + const query: any = { ...route.query }; + query.artistId = idStr; + query.mode = 'artist'; + // 清除其他搜索参数 + delete query.keyword; + delete query.tags; + router.push({ query }); + // 切换到作者搜索模式并跳转 searchMode.value = 'artist'; router.push(`/artist/${id}`); @@ -327,11 +356,80 @@ const addTag = () => { if (tag && !searchTags.value.includes(tag)) { searchTags.value.push(tag); tagInput.value = ''; + + // 更新URL参数 + updateSearchTagsInUrl(); } }; const removeTag = (index: number) => { searchTags.value.splice(index, 1); + + // 更新URL参数 + updateSearchTagsInUrl(); +}; + +// 更新URL中的搜索标签参数 +const updateSearchTagsInUrl = () => { + const query: any = { ...route.query }; + + if (searchTags.value.length > 0) { + query.tags = searchTags.value; + query.mode = 'tags'; + } else { + // 如果没有标签,清除相关参数 + delete query.tags; + delete query.mode; + } + + router.push({ query }); +}; + + + +// 更新搜索过滤器到URL +const updateFiltersInUrl = () => { + const query: any = { ...route.query }; + + if (searchType.value !== 'all') query.type = searchType.value; + else delete query.type; + + if (searchSort.value !== 'date_desc') query.sort = searchSort.value; + else delete query.sort; + + if (searchDuration.value !== 'all') query.duration = searchDuration.value; + else delete query.duration; + + router.push({ query }); +}; + +// 处理搜索模式切换 +const handleSearchModeChange = (mode: 'keyword' | 'tags' | 'artwork' | 'artist') => { + searchMode.value = mode; + + // 清除其他模式的输入 + if (mode !== 'keyword') searchKeyword.value = ''; + if (mode !== 'tags') { + searchTags.value = []; + tagInput.value = ''; + } + if (mode !== 'artwork') artworkId.value = ''; + if (mode !== 'artist') artistId.value = ''; + + // 更新URL参数,清除不相关的参数 + const query: any = { ...route.query }; + query.mode = mode; + + // 根据模式清除不相关的参数 + if (mode !== 'keyword') delete query.keyword; + if (mode !== 'tags') { + delete query.tags; + delete query.tag; + } + if (mode !== 'artwork') delete query.artworkId; + if (mode !== 'artist') delete query.artistId; + + router.push({ query }); }; const handleTagSearch = async () => { @@ -339,6 +437,14 @@ const handleTagSearch = async () => { return; } + // 更新URL参数 + const query: any = { ...route.query }; + query.tags = searchTags.value; + query.mode = 'tags'; + // 清除关键词相关参数 + delete query.keyword; + router.push({ query }); + try { loading.value = true; error.value = null; @@ -384,13 +490,37 @@ const handleArtistDownload = (artist: any) => { // 监听路由变化,处理URL参数 watch(() => route.query, () => { const urlMode = route.query.mode as string; + const urlKeyword = route.query.keyword as string; const urlTag = route.query.tag as string; const urlTags = route.query.tags; + const urlType = route.query.type as string; + const urlSort = route.query.sort as string; + const urlDuration = route.query.duration as string; + const urlArtworkId = route.query.artworkId as string; + const urlArtistId = route.query.artistId as string; + // 恢复搜索模式 + if (urlMode) { + searchMode.value = urlMode as 'keyword' | 'tags' | 'artwork' | 'artist'; + } + + // 恢复关键词 + if (urlKeyword) { + searchKeyword.value = urlKeyword; + } + + // 恢复作品ID + if (urlArtworkId) { + artworkId.value = urlArtworkId; + } + + // 恢复作者ID + if (urlArtistId) { + artistId.value = urlArtistId; + } + + // 恢复标签 if (urlMode === 'tags') { - // 自动设置标签搜索模式 - searchMode.value = 'tags'; - if (urlTags) { // 处理多个标签 if (Array.isArray(urlTags)) { @@ -411,7 +541,15 @@ watch(() => route.query, () => { if (searchTags.value.length > 0) { handleTagSearch(); } + } else if (urlMode === 'keyword' && urlKeyword) { + // 如果是关键词搜索模式且有关键词,自动执行搜索 + handleSearch(); } + + // 恢复过滤器 + if (urlType) searchType.value = urlType as 'all' | 'art' | 'manga' | 'novel'; + if (urlSort) searchSort.value = urlSort as 'date_desc' | 'date_asc' | 'popular_desc'; + if (urlDuration) searchDuration.value = urlDuration as 'all' | 'within_last_day' | 'within_last_week' | 'within_last_month'; }, { immediate: true });