From 54b9abfeebd9af33ae06e685977029102dfa36db Mon Sep 17 00:00:00 2001 From: kjqwer <2990346238@qq.com> Date: Tue, 14 Oct 2025 08:31:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=85=E7=9C=8B=E5=90=8D=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=95=B0=E6=8D=AE=E5=BA=93=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/config/cache-config.js | 5 + backend/database/watchlist-database.js | 188 +++++++++++++ backend/routes/watchlist.js | 254 +++++++++++++++++- backend/utils/logger.js | 1 + config.json.example | 2 +- ui/.env.development | 1 + ui/src/components/common/WatchlistWidget.vue | 6 +- .../components/common/watchlist/AddModal.vue | 6 +- .../common/watchlist/WatchlistPanel.vue | 109 +++++++- ui/src/services/watchlist.ts | 45 +++- ui/src/stores/watchlist.ts | 173 ++++++------ 12 files changed, 686 insertions(+), 106 deletions(-) create mode 100644 backend/database/watchlist-database.js create mode 100644 ui/.env.development diff --git a/README.md b/README.md index 0e08ff8..df987dd 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功 ``` 5. **访问应用** - - 打开浏览器访问:http://localhost:3001 (默认端口,可在 config.json 中修改) + - 打开浏览器访问:http://localhost:3000 (默认端口,可在 config.json 中修改) ## 🌐 代理配置 diff --git a/backend/config/cache-config.js b/backend/config/cache-config.js index 3bdb7d2..d086240 100644 --- a/backend/config/cache-config.js +++ b/backend/config/cache-config.js @@ -55,6 +55,11 @@ class CacheConfigManager { // 存储模式配置 storageMode: 'json', // 存储模式:'json' 或 'database' }, + // 待看名单配置 + watchlist: { + // 存储模式:'json' 或 'database' + storageMode: 'json' + }, // 新增Windows特定配置 windows: { skipInUseFiles: true, // 跳过被占用的文件 diff --git a/backend/database/watchlist-database.js b/backend/database/watchlist-database.js new file mode 100644 index 0000000..a4f1035 --- /dev/null +++ b/backend/database/watchlist-database.js @@ -0,0 +1,188 @@ +const { defaultLogger } = require('../utils/logger'); +const crypto = require('crypto'); + +const logger = defaultLogger.child('WatchlistDatabase'); + +/** + * 数据库版本的待看名单 + * 提供与JSON版本相同的接口,但使用MySQL数据库存储 + */ +class WatchlistDatabase { + constructor(databaseManager) { + this.db = databaseManager; + this.loaded = false; + this.tableName = 'watchlist_items'; + } + + /** + * 初始化数据库表 + */ + async init() { + try { + // 如果表不存在则创建 + if (!(await this.db.tableExists(this.tableName))) { + const createSQL = ` + CREATE TABLE ${this.tableName} ( + id VARCHAR(64) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + url_hash CHAR(64) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_url_hash (url_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='待看名单表'; + `; + await this.db.createTable(this.tableName, createSQL); + } + this.loaded = true; + logger.info('待看名单数据库表初始化完成'); + return { success: true }; + } catch (error) { + logger.error('初始化待看名单数据库失败:', error); + throw error; + } + } + + /** + * 获取所有待看项目 + */ + async getAllItems() { + try { + const result = await this.db.query(` + SELECT id, title, url, created_at, updated_at + FROM ${this.tableName} + ORDER BY created_at ASC + `); + return result.data.map(row => ({ + id: row.id.toString(), + title: row.title, + url: row.url, + createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at, + updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at + })); + } catch (error) { + logger.error('获取待看项目失败(数据库):', error); + throw error; + } + } + + /** + * 添加或更新待看项目(按URL唯一) + */ + async addItem(item) { + try { + const { url, title } = item; + if (!url) throw new Error('URL是必填项'); + const urlHash = crypto.createHash('sha256').update(url).digest('hex'); + + // 先查是否存在 + const exists = await this.db.select(this.tableName, { url_hash: urlHash }); + if (exists.data && exists.data.length > 0) { + // 更新标题 + await this.db.update(this.tableName, { title }, { url_hash: urlHash }); + } else { + const id = (item.id?.toString()) || Date.now().toString(); + await this.db.insert(this.tableName, { + id, + title: title || '待看页面', + url, + url_hash: urlHash + }); + } + return await this.getAllItems(); + } catch (error) { + logger.error('添加待看项目失败(数据库):', error); + throw error; + } + } + + /** + * 删除待看项目 + */ + async removeItem(id) { + try { + const result = await this.db.delete(this.tableName, { id }); + if (result.affectedRows === 0) { + throw new Error('待看项目不存在'); + } + return await this.getAllItems(); + } catch (error) { + logger.error('删除待看项目失败(数据库):', error); + throw error; + } + } + + /** + * 更新待看项目 + */ + async updateItem(id, updates) { + try { + const allowed = {}; + if (typeof updates.title === 'string') allowed.title = updates.title; + if (typeof updates.url === 'string') { + allowed.url = updates.url; + allowed.url_hash = crypto.createHash('sha256').update(updates.url).digest('hex'); + } + if (Object.keys(allowed).length === 0) { + // 没有可更新字段则返回原项 + const rows = await this.db.select(this.tableName, { id }); + if (!rows.data || rows.data.length === 0) throw new Error('待看项目不存在'); + const row = rows.data[0]; + return { + id: row.id.toString(), + title: row.title, + url: row.url, + createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at, + updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at + }; + } + await this.db.update(this.tableName, allowed, { id }); + const rows = await this.db.select(this.tableName, { id }); + const row = rows.data[0]; + return { + id: row.id.toString(), + title: row.title, + url: row.url, + createdAt: (row.created_at instanceof Date) ? row.created_at.toISOString() : row.created_at, + updatedAt: (row.updated_at instanceof Date) ? row.updated_at.toISOString() : row.updated_at + }; + } catch (error) { + logger.error('更新待看项目失败(数据库):', error); + throw error; + } + } + + /** + * 清空表 + */ + async clearAll() { + await this.db.query(`DELETE FROM ${this.tableName}`); + } + + /** + * 批量插入 + */ + async batchInsert(items) { + const rows = items.map(i => { + const url = i.url; + const url_hash = url ? crypto.createHash('sha256').update(url).digest('hex') : null; + return { + id: i.id?.toString() || Date.now().toString(), + title: i.title || '待看页面', + url, + url_hash + }; + }); + return await this.db.batchInsert(this.tableName, rows); + } + + /** + * 获取已有URL集合 + */ + async getExistingUrlSet() { + const result = await this.db.query(`SELECT url FROM ${this.tableName}`); + return new Set(result.data.map(r => r.url)); + } +} + +module.exports = WatchlistDatabase; \ No newline at end of file diff --git a/backend/routes/watchlist.js b/backend/routes/watchlist.js index 1b01570..0b99fed 100644 --- a/backend/routes/watchlist.js +++ b/backend/routes/watchlist.js @@ -2,10 +2,37 @@ const express = require('express'); const WatchlistManager = require('../config/watchlist-manager'); const ResponseUtil = require('../utils/response'); const { defaultLogger } = require('../utils/logger'); +const CacheConfigManager = require('../config/cache-config'); +const WatchlistDatabase = require('../database/watchlist-database'); const router = express.Router(); const logger = defaultLogger.child('WatchlistRouter'); const watchlistManager = new WatchlistManager(); +const cacheConfigManager = new CacheConfigManager(); +let watchlistDb = null; // 延迟初始化 + +// 获取当前存储模式 +async function getStorageMode() { + try { + await cacheConfigManager.initialize(); + const config = await cacheConfigManager.loadConfig(); + return config.watchlist?.storageMode === 'database' ? 'database' : 'json'; + } catch (e) { + logger.warn('读取待看名单存储模式失败,回退到json:', e.message); + return 'json'; + } +} + +// 获取数据库适配器(复用Registry连接) +async function getDbAdapter(req) { + const dbm = req.backend?.databaseManager; + if (!dbm) return null; + if (!watchlistDb) { + watchlistDb = new WatchlistDatabase(dbm); + await watchlistDb.init(); + } + return watchlistDb; +} // 初始化待看名单 watchlistManager.initialize().catch(error => { @@ -18,6 +45,12 @@ watchlistManager.initialize().catch(error => { */ router.get('/', async (req, res) => { try { + const mode = await getStorageMode(); + if (mode === 'database') { + const db = await getDbAdapter(req); + const items = await db.getAllItems(); + return res.json(ResponseUtil.success(items)); + } const items = await watchlistManager.getAllItems(); res.json(ResponseUtil.success(items)); } catch (error) { @@ -120,11 +153,15 @@ router.post('/', async (req, res) => { } } - const item = { - url, - title: defaultTitle - }; - + const item = { url, title: defaultTitle }; + + const mode = await getStorageMode(); + if (mode === 'database') { + const db = await getDbAdapter(req); + const items = await db.addItem(item); + return res.json(ResponseUtil.success(items)); + } + const items = await watchlistManager.addItem(item); res.json(ResponseUtil.success(items)); } catch (error) { @@ -133,6 +170,40 @@ router.post('/', async (req, res) => { } }); +/** + * 更新待看名单存储配置(仅切换,不做迁移) + * PUT /api/watchlist/config + * body: { storageMode: 'json' | 'database' } + * + * 注意:必须放在 PUT '/:id' 之前,避免被动态路由拦截! + */ +router.put('/config', async (req, res) => { + try { + const { storageMode } = req.body; + if (!['json', 'database'].includes(storageMode)) { + return res.status(400).json(ResponseUtil.error('存储模式必须是 json 或 database')); + } + + // 更新配置 + const current = await cacheConfigManager.loadConfig(); + const updated = await cacheConfigManager.updateConfig({ + ...current, + watchlist: { ...(current.watchlist || {}), storageMode } + }); + + // 如果切换到数据库模式,确保表已初始化(复用 Registry 的连接) + if (storageMode === 'database' && req.backend?.databaseManager) { + const db = await getDbAdapter(req); + await db.init(); + } + + res.json(ResponseUtil.success({ storageMode: updated.watchlist?.storageMode || 'json' })); + } catch (error) { + logger.error('更新待看名单配置失败:', error); + res.status(500).json(ResponseUtil.error(error.message)); + } +}); + /** * 更新待看项目 * PUT /api/watchlist/:id @@ -146,6 +217,13 @@ router.put('/:id', async (req, res) => { delete updates.id; delete updates.createdAt; + const mode = await getStorageMode(); + if (mode === 'database') { + const db = await getDbAdapter(req); + const updatedItem = await db.updateItem(id, updates); + return res.json(ResponseUtil.success(updatedItem)); + } + const updatedItem = await watchlistManager.updateItem(id, updates); res.json(ResponseUtil.success(updatedItem)); } catch (error) { @@ -165,6 +243,13 @@ router.put('/:id', async (req, res) => { router.delete('/:id', async (req, res) => { try { const { id } = req.params; + const mode = await getStorageMode(); + if (mode === 'database') { + const db = await getDbAdapter(req); + const items = await db.removeItem(id); + return res.json(ResponseUtil.success(items)); + } + const items = await watchlistManager.removeItem(id); res.json(ResponseUtil.success(items)); } catch (error) { @@ -177,4 +262,161 @@ router.delete('/:id', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +/** + * 导出待看名单 + * GET /api/watchlist/export + */ +router.get('/export', async (req, res) => { + try { + const mode = await getStorageMode(); + let exportItems = []; + if (mode === 'database') { + const db = await getDbAdapter(req); + exportItems = await db.getAllItems(); + } else { + const data = await watchlistManager.readData(); + exportItems = Array.isArray(data.items) ? data.items : []; + } + // 组装导出结构,向后兼容 + const exportData = { + version: '1.0', + exportTime: new Date().toISOString(), + items: exportItems + }; + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename="watchlist.json"'); + res.json(ResponseUtil.success(exportData)); + } catch (error) { + logger.error('导出待看名单失败:', error); + res.status(500).json(ResponseUtil.error(error.message)); + } +}); + +/** + * 导入待看名单 + * POST /api/watchlist/import + * body: { watchlistData: { items: [...] }, importMode: 'merge' | 'overwrite' } + */ +router.post('/import', async (req, res) => { + try { + const { watchlistData, importMode = 'merge' } = req.body; + + if (!watchlistData || !Array.isArray(watchlistData.items)) { + return res.status(400).json(ResponseUtil.error('缺少有效的待看名单数据')); + } + + const normalizeItem = (item) => { + const now = new Date().toISOString(); + return { + id: item.id?.toString() || Date.now().toString(), + title: item.title || '待看页面', + url: item.url, + createdAt: item.createdAt || now, + updatedAt: item.updatedAt || now + }; + }; + + const importItems = watchlistData.items + .filter(i => i && i.url) + .map(normalizeItem); + + let successCount = 0; + let skipCount = 0; + let errorCount = 0; + let deletedCount = 0; + + const mode = await getStorageMode(); + let updatedItems = []; + + if (mode === 'database') { + const db = await getDbAdapter(req); + if (importMode === 'overwrite') { + // 覆盖:清空后批量插入 + const before = await db.getAllItems(); + deletedCount = before.length; + await db.clearAll(); + await db.batchInsert(importItems); + successCount = importItems.length; + } else { + // 合并:按URL去重,仅新增不存在的 + const existingUrlSet = await db.getExistingUrlSet(); + const toInsert = []; + for (const item of importItems) { + try { + if (existingUrlSet.has(item.url)) { + skipCount++; + continue; + } + toInsert.push(item); + successCount++; + } catch (e) { + errorCount++; + } + } + await db.batchInsert(toInsert); + } + updatedItems = await db.getAllItems(); + } else { + const currentData = await watchlistManager.readData(); + const existingItems = Array.isArray(currentData.items) ? currentData.items : []; + if (importMode === 'overwrite') { + // 覆盖:直接保存导入数据为新清单 + deletedCount = existingItems.length; + const newData = { items: importItems, lastUpdated: new Date().toISOString() }; + await watchlistManager.saveData(newData); + successCount = importItems.length; + } else { + // 合并:按URL去重,仅新增不存在的 + const existingUrlSet = new Set(existingItems.map(i => i.url)); + const merged = [...existingItems]; + for (const item of importItems) { + try { + if (existingUrlSet.has(item.url)) { + skipCount++; + continue; + } + merged.push(item); + existingUrlSet.add(item.url); + successCount++; + } catch (e) { + errorCount++; + } + } + await watchlistManager.saveData({ items: merged, lastUpdated: new Date().toISOString() }); + } + updatedItems = await watchlistManager.getAllItems(); + } + + const message = importMode === 'overwrite' + ? `覆盖导入完成:删除 ${deletedCount} 项,成功添加 ${successCount} 项,失败 ${errorCount} 项` + : `重合导入完成:成功 ${successCount} 项,跳过 ${skipCount} 项,失败 ${errorCount} 项`; + + res.json(ResponseUtil.success({ + message, + stats: { successCount, skipCount, errorCount, deletedCount }, + items: updatedItems + })); + } catch (error) { + logger.error('导入待看名单失败:', error); + res.status(500).json(ResponseUtil.error(error.message)); + } +}); + +/** + * 获取待看名单存储配置 + * GET /api/watchlist/config + */ +router.get('/config', async (req, res) => { + try { + const mode = await getStorageMode(); + res.json(ResponseUtil.success({ storageMode: mode })); + } catch (error) { + logger.error('获取待看名单配置失败:', error); + res.status(500).json(ResponseUtil.error(error.message)); + } +}); + + + +module.exports = router; \ No newline at end of file diff --git a/backend/utils/logger.js b/backend/utils/logger.js index 94a621a..69ab3cd 100644 --- a/backend/utils/logger.js +++ b/backend/utils/logger.js @@ -63,6 +63,7 @@ const ModuleColors = { 'AbortControllerManager': '\x1b[94m', // 亮蓝色 'DatabaseManager': '\x1b[95m', // 亮紫色 'RegistrySchema': '\x1b[94m', // 亮蓝色 + 'WatchlistDatabase': '\x1b[94m', // 亮蓝色 'RegistryDatabase': '\x1b[94m', // 亮蓝色 'Default': '\x1b[39m' // 默认颜色 }; diff --git a/config.json.example b/config.json.example index a316111..03ec404 100644 --- a/config.json.example +++ b/config.json.example @@ -1,6 +1,6 @@ { "server": { - "port": 3001, + "port": 3000, "autoOpenBrowser": false }, "proxy": { diff --git a/ui/.env.development b/ui/.env.development new file mode 100644 index 0000000..2ae3c1c --- /dev/null +++ b/ui/.env.development @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3000 \ No newline at end of file diff --git a/ui/src/components/common/WatchlistWidget.vue b/ui/src/components/common/WatchlistWidget.vue index d2a8d2c..2398868 100644 --- a/ui/src/components/common/WatchlistWidget.vue +++ b/ui/src/components/common/WatchlistWidget.vue @@ -147,10 +147,10 @@ const filteredAndSortedItems = computed(() => { otherItems = filteredItems; } - // 对其他项目进行排序 + // 对其他项目进行排序(按更新时间排序) otherItems.sort((a, b) => { - const dateA = new Date(a.createdAt).getTime(); - const dateB = new Date(b.createdAt).getTime(); + const dateA = new Date(a.updatedAt || a.createdAt).getTime(); + const dateB = new Date(b.updatedAt || b.createdAt).getTime(); return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB; }); diff --git a/ui/src/components/common/watchlist/AddModal.vue b/ui/src/components/common/watchlist/AddModal.vue index 3bd26cb..6a2ab23 100644 --- a/ui/src/components/common/watchlist/AddModal.vue +++ b/ui/src/components/common/watchlist/AddModal.vue @@ -33,7 +33,7 @@
支持完整URL或相对路径,如:/artist/12345、/search?keyword=插画 等 @@ -86,8 +86,8 @@