Compare commits

...

10 Commits

Author SHA1 Message Date
kjqwer 1ce0ab1234 修复无法登录问题 2025-11-29 07:16:15 +08:00
kjqwer 0e8766c0b4 修复扫描速度慢,优化仓库显示 2025-10-22 16:25:42 +08:00
kjqwer 92e307d3e5 修复更新提示错误问题 2025-10-17 12:27:40 +08:00
kjqwer 91114bcc80 标题状态更新 2025-10-17 06:30:45 +08:00
kjqwer 706dfb6667 修复待看名单移动端显示问题 2025-10-16 10:45:06 +08:00
kjqwer 83bfd9d6d6 修复移动端层级问题 2025-10-14 15:33:07 +08:00
kjqwer 8b0359c149 增加其他平台的打包指令 2025-10-14 10:13:32 +08:00
kjqwer 54b9abfeeb 待看名单支持数据库同步 2025-10-14 08:31:40 +08:00
kjqwer 5be8ae9520 支持动图下载和预览 2025-10-13 15:43:18 +08:00
kjqwer e85f959fa6 作品页面按钮样式修复 2025-10-13 14:47:08 +08:00
48 changed files with 4365 additions and 642 deletions
+2
View File
@@ -20,6 +20,8 @@ config.json
#打包文件夹 #打包文件夹
dist/ dist/
pixiv-manager-portable/ pixiv-manager-portable/
pixiv-manager-portable-linux/
pixiv-manager-portable-macos/
pixiv-manager-portable.rar pixiv-manager-portable.rar
build/ build/
zzip.bat zzip.bat
+1 -1
View File
@@ -69,7 +69,7 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
``` ```
5. **访问应用** 5. **访问应用**
- 打开浏览器访问:http://localhost:3001 (默认端口,可在 config.json 中修改) - 打开浏览器访问:http://localhost:3000 (默认端口,可在 config.json 中修改)
## 🌐 代理配置 ## 🌐 代理配置
+5
View File
@@ -55,6 +55,11 @@ class CacheConfigManager {
// 存储模式配置 // 存储模式配置
storageMode: 'json', // 存储模式:'json' 或 'database' storageMode: 'json', // 存储模式:'json' 或 'database'
}, },
// 待看名单配置
watchlist: {
// 存储模式:'json' 或 'database'
storageMode: 'json'
},
// 新增Windows特定配置 // 新增Windows特定配置
windows: { windows: {
skipInUseFiles: true, // 跳过被占用的文件 skipInUseFiles: true, // 跳过被占用的文件
+44 -6
View File
@@ -62,6 +62,19 @@ class ConfigManager {
// 检查配置文件是否存在 // 检查配置文件是否存在
await fs.access(this.configDir) await fs.access(this.configDir)
logger.info('用户配置文件已存在') logger.info('用户配置文件已存在')
// 验证配置文件是否有效
try {
const configData = await fs.readFile(this.configDir, 'utf8')
if (!configData || configData.trim() === '') {
throw new Error('配置文件为空')
}
JSON.parse(configData)
logger.info('配置文件验证通过')
} catch (parseError) {
logger.warn('配置文件损坏,将重新创建:', parseError.message)
await this.createDefaultConfig()
}
} catch (error) { } catch (error) {
// 配置文件不存在,创建默认配置 // 配置文件不存在,创建默认配置
logger.info('创建默认用户配置文件...') logger.info('创建默认用户配置文件...')
@@ -118,21 +131,46 @@ class ConfigManager {
} }
const configData = await fs.readFile(this.configDir, 'utf8') const configData = await fs.readFile(this.configDir, 'utf8')
// 检查文件内容是否为空或损坏
if (!configData || configData.trim() === '') {
logger.warn('配置文件为空,重新创建默认配置...')
await this.createDefaultConfig()
return this.defaultConfig
}
const config = JSON.parse(configData) const config = JSON.parse(configData)
// 合并默认配置,确保所有必要的字段都存在 // 合并默认配置,确保所有必要的字段都存在
return { ...this.defaultConfig, ...config } return { ...this.defaultConfig, ...config }
} catch (error) { } catch (error) {
logger.error('读取配置文件失败:', error) logger.error('读取配置文件失败:', error)
logger.info('使用默认配置...') logger.info('配置文件可能损坏,尝试重新创建...')
// 如果读取失败,尝试创建默认配置
// 如果读取失败,尝试备份损坏的文件并创建默认配置
try { try {
// 备份损坏的配置文件
const backupPath = this.configDir + '.backup.' + Date.now()
try {
await fs.copyFile(this.configDir, backupPath)
logger.info(`已备份损坏的配置文件到: ${backupPath}`)
} catch (backupError) {
logger.warn('备份损坏的配置文件失败:', backupError.message)
}
// 删除损坏的配置文件
try {
await fs.unlink(this.configDir)
} catch (unlinkError) {
logger.warn('删除损坏的配置文件失败:', unlinkError.message)
}
// 创建新的默认配置
await this.createDefaultConfig() await this.createDefaultConfig()
return { ...this.defaultConfig } return this.defaultConfig
} catch (createError) { } catch (createError) {
logger.error('创建默认配置失败:', createError) logger.error('创建默认配置失败:', createError)
// 最后返回内存中的默认配置 return this.defaultConfig
return { ...this.defaultConfig }
} }
} }
} }
+188
View File
@@ -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;
+37
View File
@@ -307,4 +307,41 @@ router.get('/:id/related', async (req, res) => {
} }
}); });
/**
* 获取Ugoira动画的ZIP文件URL
* GET /api/artwork/:id/ugoira
*/
router.get('/:id/ugoira', async (req, res) => {
try {
const { id } = req.params;
if (!id || isNaN(parseInt(id))) {
return res.status(400).json({
success: false,
error: 'Invalid artwork ID'
});
}
const artworkService = new ArtworkService(req.backend.getAuth());
const result = await artworkService.getUgoiraZipUrl(parseInt(id));
if (result.success) {
res.json({
success: true,
data: result.data
});
} else {
res.status(404).json({
success: false,
error: result.error
});
}
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router; module.exports = router;
+45 -1
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const ImageCacheService = require('../services/image-cache'); const ImageCacheService = require('../services/image-cache');
const ApiCacheService = require('../services/api-cache'); const ApiCacheService = require('../services/api-cache');
const { defaultLogger } = require('../utils/logger'); const { defaultLogger } = require('../utils/logger');
const axios = require('axios');
// 创建logger实例 // 创建logger实例
const logger = defaultLogger.child('ProxyRouter'); const logger = defaultLogger.child('ProxyRouter');
@@ -53,6 +54,46 @@ router.get('/image', async (req, res) => {
} }
}); });
/**
* 通用文件代理(支持ZIP等二进制资源)
* GET /api/proxy/file?url=<encoded>
*/
router.get('/file', async (req, res) => {
try {
const { url } = req.query;
if (!url) {
return res.status(400).json({ success: false, error: 'File URL is required' });
}
const decodedUrl = decodeURIComponent(url);
// 发起请求到源站(例如 i.pximg.net),设置必要头以通过防盗链
const response = await axios.get(decodedUrl, {
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/121.0.0.0 Safari/537.36',
Accept: '*/*'
},
timeout: 60000
});
const contentType = getContentType(decodedUrl);
res.set({
'Content-Type': contentType,
'Cache-Control': 'public, max-age=600',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type'
});
res.send(response.data);
} catch (error) {
logger.error('File proxy error:', { message: error.message, status: error.response?.status });
res.status(500).json({ success: false, error: 'Failed to proxy file' });
}
});
/** /**
* 缓存管理 - 获取缓存统计信息 * 缓存管理 - 获取缓存统计信息
* GET /api/proxy/cache/stats * GET /api/proxy/cache/stats
@@ -301,7 +342,10 @@ function getContentType(url) {
'gif': 'image/gif', 'gif': 'image/gif',
'webp': 'image/webp', 'webp': 'image/webp',
'bmp': 'image/bmp', 'bmp': 'image/bmp',
'svg': 'image/svg+xml' 'svg': 'image/svg+xml',
'zip': 'application/zip',
'mp4': 'video/mp4',
'webm': 'video/webm'
}; };
return contentTypeMap[ext] || 'image/jpeg'; return contentTypeMap[ext] || 'image/jpeg';
+68
View File
@@ -58,6 +58,74 @@ router.get('/stats', async (req, res) => {
} }
}) })
// 快速扫描 - 仅获取基本信息
router.get('/quick-scan', async (req, res) => {
try {
const result = await repositoryService.quickScan()
res.json(ResponseUtil.success(result))
} catch (error) {
res.status(500).json(ResponseUtil.error(error.message))
}
})
// 完整扫描 - 支持并发和缓存
router.post('/scan', async (req, res) => {
try {
const {
maxConcurrency = 5, // 减少默认并发数
useCache = true,
forceRefresh = false
} = req.body
const result = await repositoryService.scanRepository({
maxConcurrency: parseInt(maxConcurrency),
useCache: useCache === true,
forceRefresh: forceRefresh === true,
progressCallback: (progress) => {
// 可以通过 WebSocket 发送进度更新
console.log('扫描进度:', progress)
}
})
res.json(ResponseUtil.success(result))
} catch (error) {
res.status(500).json(ResponseUtil.error(error.message))
}
})
// 增量扫描 - 只扫描变更的目录和文件
router.post('/incremental-scan', async (req, res) => {
try {
const {
maxConcurrency = 5, // 减少默认并发数
useCache = true
} = req.body
const result = await repositoryService.incrementalScan({
maxConcurrency: parseInt(maxConcurrency),
useCache: useCache === true,
progressCallback: (progress) => {
// 可以通过 WebSocket 发送进度更新
console.log('增量扫描进度:', progress)
}
})
res.json(ResponseUtil.success(result))
} catch (error) {
res.status(500).json(ResponseUtil.error(error.message))
}
})
// 清除扫描缓存
router.post('/clear-scan-cache', async (req, res) => {
try {
const result = await repositoryService.clearScanCache()
res.json(ResponseUtil.success(result))
} catch (error) {
res.status(500).json(ResponseUtil.error(error.message))
}
})
// 清除磁盘使用情况缓存 // 清除磁盘使用情况缓存
router.post('/stats/clear-cache', async (req, res) => { router.post('/stats/clear-cache', async (req, res) => {
try { try {
+246 -4
View File
@@ -2,10 +2,37 @@ const express = require('express');
const WatchlistManager = require('../config/watchlist-manager'); const WatchlistManager = require('../config/watchlist-manager');
const ResponseUtil = require('../utils/response'); const ResponseUtil = require('../utils/response');
const { defaultLogger } = require('../utils/logger'); const { defaultLogger } = require('../utils/logger');
const CacheConfigManager = require('../config/cache-config');
const WatchlistDatabase = require('../database/watchlist-database');
const router = express.Router(); const router = express.Router();
const logger = defaultLogger.child('WatchlistRouter'); const logger = defaultLogger.child('WatchlistRouter');
const watchlistManager = new WatchlistManager(); 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 => { watchlistManager.initialize().catch(error => {
@@ -18,6 +45,12 @@ watchlistManager.initialize().catch(error => {
*/ */
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { 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(); const items = await watchlistManager.getAllItems();
res.json(ResponseUtil.success(items)); res.json(ResponseUtil.success(items));
} catch (error) { } catch (error) {
@@ -120,10 +153,14 @@ router.post('/', async (req, res) => {
} }
} }
const item = { const item = { url, title: defaultTitle };
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); const items = await watchlistManager.addItem(item);
res.json(ResponseUtil.success(items)); res.json(ResponseUtil.success(items));
@@ -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 * PUT /api/watchlist/:id
@@ -146,6 +217,13 @@ router.put('/:id', async (req, res) => {
delete updates.id; delete updates.id;
delete updates.createdAt; 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); const updatedItem = await watchlistManager.updateItem(id, updates);
res.json(ResponseUtil.success(updatedItem)); res.json(ResponseUtil.success(updatedItem));
} catch (error) { } catch (error) {
@@ -165,6 +243,13 @@ router.put('/:id', async (req, res) => {
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
const { id } = req.params; 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); const items = await watchlistManager.removeItem(id);
res.json(ResponseUtil.success(items)); res.json(ResponseUtil.success(items));
} catch (error) { } catch (error) {
@@ -177,4 +262,161 @@ router.delete('/:id', async (req, res) => {
} }
}); });
/**
* 导出待看名单
* 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; module.exports = router;
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* 配置文件修复脚本
* 用于检查和修复损坏的配置文件
*/
const fs = require('fs').promises
const path = require('path')
async function fixConfig() {
try {
console.log('🔧 开始检查配置文件...')
// 检测配置文件路径
const isPkg = process.pkg !== undefined
let configPath
if (isPkg) {
configPath = path.join(process.cwd(), 'data', 'user-config.json')
} else {
configPath = path.join(__dirname, '..', 'config', 'user-config.json')
}
console.log(`📁 配置文件路径: ${configPath}`)
// 检查文件是否存在
try {
await fs.access(configPath)
console.log('✅ 配置文件存在')
} catch (error) {
console.log('❌ 配置文件不存在,将创建默认配置')
return
}
// 检查文件内容
try {
const content = await fs.readFile(configPath, 'utf8')
if (!content || content.trim() === '') {
console.log('⚠️ 配置文件为空')
throw new Error('配置文件为空')
}
// 尝试解析JSON
const config = JSON.parse(content)
console.log('✅ 配置文件格式正确')
console.log('📋 配置内容:', JSON.stringify(config, null, 2))
} catch (error) {
console.log('❌ 配置文件损坏:', error.message)
// 备份损坏的文件
const backupPath = configPath + '.backup.' + Date.now()
try {
await fs.copyFile(configPath, backupPath)
console.log(`💾 已备份损坏的配置文件到: ${backupPath}`)
} catch (backupError) {
console.log('⚠️ 备份失败:', backupError.message)
}
// 创建默认配置
const defaultConfig = {
downloadDir: "./downloads",
fileStructure: "artist/artwork",
namingPattern: "{artist_name}/{artwork_id}_{title}",
maxFileSize: 0,
allowedExtensions: [".jpg", ".png", ".gif", ".webp"],
autoMigration: false,
migrationRules: [],
lastUpdated: new Date().toISOString()
}
// 确保目录存在
const configDir = path.dirname(configPath)
await fs.mkdir(configDir, { recursive: true })
// 写入默认配置
await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8')
console.log('✅ 已创建默认配置文件')
}
console.log('🎉 配置文件检查完成')
} catch (error) {
console.error('❌ 修复配置文件失败:', error.message)
process.exit(1)
}
}
// 如果直接运行此脚本
if (require.main === module) {
fixConfig()
}
module.exports = { fixConfig }
+18 -6
View File
@@ -242,11 +242,20 @@ class PixivServer {
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const os = require('os'); const os = require('os');
const fs = require('fs');
const platform = os.platform();
// 在服务器环境中直接跳过打开浏览器
if (platform === 'linux') {
// 直接跳过 Linux 服务器环境的浏览器打开
logger.info('在 Linux 服务器环境中,跳过打开浏览器');
return;
}
let command; let command;
let args = [url]; let args = [url];
switch (os.platform()) { switch (platform) {
case 'win32': case 'win32':
command = 'cmd'; command = 'cmd';
args = ['/c', 'start', '""', url]; args = ['/c', 'start', '""', url];
@@ -254,9 +263,6 @@ class PixivServer {
case 'darwin': case 'darwin':
command = 'open'; command = 'open';
break; break;
case 'linux':
command = 'xdg-open';
break;
default: default:
logger.warn('不支持的操作系统,无法自动打开浏览器'); logger.warn('不支持的操作系统,无法自动打开浏览器');
return; return;
@@ -265,10 +271,16 @@ class PixivServer {
try { try {
const child = spawn(command, args, { const child = spawn(command, args, {
detached: true, detached: true,
stdio: 'ignore' stdio: 'ignore',
}); });
// 添加错误处理
child.on('error', (err) => {
logger.warn('打开浏览器失败:', err.message);
});
child.unref(); child.unref();
logger.info('浏览器打开'); logger.info('浏览器打开命令已执行');
} catch (error) { } catch (error) {
logger.warn('打开浏览器失败:', error.message); logger.warn('打开浏览器失败:', error.message);
} }
+40
View File
@@ -487,6 +487,46 @@ class ArtworkService {
throw error; throw error;
} }
} }
/**
* 获取Ugoira动画的ZIP文件URL
*/
async getUgoiraZipUrl(artworkId) {
try {
// 首先获取作品详情以确认是否为ugoira类型
const detailResponse = await this.makeRequest('GET', '/v1/illust/detail', { illust_id: artworkId });
const artwork = detailResponse.illust;
// 检查作品类型是否为ugoira
if (artwork.type !== 'ugoira') {
return {
success: false,
error: 'This artwork is not an ugoira animation'
};
}
// 获取ugoira元数据
const metadataResponse = await this.makeRequest('GET', '/v1/ugoira/metadata', { illust_id: artworkId });
// 返回ZIP文件URL和其他元数据
return {
success: true,
data: {
artwork_id: artworkId,
zip_urls: metadataResponse.ugoira_metadata.zip_urls,
frames: metadataResponse.ugoira_metadata.frames,
}
};
} catch (error) {
logger.error('Get ugoira zip URL error:', error.message);
logger.error('Get ugoira zip URL error details:', error.response?.data);
return {
success: false,
error: error.message || 'Failed to get ugoira zip URL'
};
}
}
} }
module.exports = ArtworkService; module.exports = ArtworkService;
+28
View File
@@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { generatePreviewGifFromUgoira } = require('./ugoira-gif');
const { defaultLogger } = require('../utils/logger'); const { defaultLogger } = require('../utils/logger');
const abortControllerManager = require('../utils/abort-controller-manager'); const abortControllerManager = require('../utils/abort-controller-manager');
@@ -172,6 +173,31 @@ class DownloadExecutor {
const infoPath = path.join(artworkDir, 'artwork_info.json'); const infoPath = path.join(artworkDir, 'artwork_info.json');
await fs.writeJson(infoPath, artwork, { spaces: 2 }); await fs.writeJson(infoPath, artwork, { spaces: 2 });
// 若为ugoira,基于已下载的ZIP生成预览GIF(不影响注册表判定)
try {
if (artwork && artwork.type === 'ugoira' && Array.isArray(artwork.ugoira_frames) && artwork.ugoira_frames.length) {
const files = await fs.readdir(artworkDir);
const zipFile = files.find((f) => f.toLowerCase().endsWith('.zip'));
if (zipFile) {
const zipPath = path.join(artworkDir, zipFile);
const previewGifPath = path.join(artworkDir, 'preview.gif');
await generatePreviewGifFromUgoira(zipPath, artwork.ugoira_frames, previewGifPath);
// 完整性校验预览GIF(不计入任务下载计数)
const integrity = await this.fileManager.checkFileIntegrity(previewGifPath, null, 'image/gif');
if (!integrity.valid) {
// 如果GIF生成不完整,尝试删除以避免残留
await this.fileManager.safeDeleteFile(previewGifPath).catch(() => {});
logger.warn('预览GIF完整性校验失败,已清理', { previewGifPath, reason: integrity.reason });
} else {
logger.info('已生成ugoira预览GIF', { previewGifPath });
}
}
}
} catch (gifError) {
logger.warn('生成ugoira预览GIF失败(继续任务流程)', { error: gifError.message });
}
// 更新任务状态 - 确保所有文件都处理完成后再更新 // 更新任务状态 - 确保所有文件都处理完成后再更新
task.status = task.failed_files === 0 ? 'completed' : 'partial'; task.status = task.failed_files === 0 ? 'completed' : 'partial';
task.end_time = new Date(); task.end_time = new Date();
@@ -501,6 +527,8 @@ class DownloadExecutor {
return 'image/webp'; return 'image/webp';
case 'bmp': case 'bmp':
return 'image/bmp'; return 'image/bmp';
case 'zip':
return 'application/zip';
default: default:
return 'image/jpeg'; // 默认为JPEG return 'image/jpeg'; // 默认为JPEG
} }
+79 -10
View File
@@ -7,6 +7,7 @@ const ProgressManager = require('./progress-manager');
const HistoryManager = require('./history-manager'); const HistoryManager = require('./history-manager');
const DownloadExecutor = require('./download-executor'); const DownloadExecutor = require('./download-executor');
const DownloadRegistry = require('./download-registry'); const DownloadRegistry = require('./download-registry');
const { generatePreviewGifFromUgoira } = require('./ugoira-gif');
const CacheConfigManager = require('../config/cache-config'); const CacheConfigManager = require('../config/cache-config');
const fs = require('fs-extra'); // Added for fs-extra const fs = require('fs-extra'); // Added for fs-extra
const { defaultLogger } = require('../utils/logger'); const { defaultLogger } = require('../utils/logger');
@@ -655,6 +656,8 @@ class DownloadService {
return 'image/webp'; return 'image/webp';
case 'bmp': case 'bmp':
return 'image/bmp'; return 'image/bmp';
case 'zip':
return 'application/zip';
default: default:
return 'image/jpeg'; // 默认为JPEG return 'image/jpeg'; // 默认为JPEG
} }
@@ -959,13 +962,35 @@ class DownloadService {
await this.fileManager.ensureDirectory(artworkDir); await this.fileManager.ensureDirectory(artworkDir);
// 获取图片URL // 根据作品类型获取下载资源
let images = [];
if (artwork.type === 'ugoira') {
// Ugoira动图:仅下载ZIP,预览将由本地生成GIF
const zipResult = await this.artworkService.getUgoiraZipUrl(artworkId);
if (!zipResult.success) {
throw new Error(`获取ugoira ZIP失败: ${zipResult.error}`);
}
const zipUrls = zipResult.data?.zip_urls || {};
const zipUrl = zipUrls.medium || zipUrls.large || zipUrls.small || Object.values(zipUrls)[0];
if (!zipUrl) {
throw new Error('未找到ugoira ZIP地址');
}
// 仅添加ZIP到下载列表
images = [
{ original: zipUrl, large: zipUrl, medium: zipUrl, square_medium: zipUrl },
];
// 保存ugoira帧元数据,供执行器生成预览GIF
if (Array.isArray(zipResult.data?.frames)) {
artwork.ugoira_frames = zipResult.data.frames;
}
} else {
// 普通插画/漫画:按原有逻辑获取图片URL
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size); const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) { if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`); throw new Error(`获取图片URL失败: ${imagesResult.error}`);
} }
images = imagesResult.data.images;
const images = imagesResult.data.images; }
// 创建任务记录 // 创建任务记录
const task = this.taskManager.createTask('artwork', { const task = this.taskManager.createTask('artwork', {
@@ -1094,13 +1119,32 @@ class DownloadService {
await this.fileManager.ensureDirectory(artworkDir); await this.fileManager.ensureDirectory(artworkDir);
// 获取图片URL // 根据作品类型获取下载资源(批量下载场景)
let images = [];
if (artwork.type === 'ugoira') {
const zipResult = await this.artworkService.getUgoiraZipUrl(artworkId);
if (!zipResult.success) {
throw new Error(`获取ugoira ZIP失败: ${zipResult.error}`);
}
const zipUrls = zipResult.data?.zip_urls || {};
const zipUrl = zipUrls.medium || zipUrls.large || zipUrls.small || Object.values(zipUrls)[0];
if (!zipUrl) {
throw new Error('未找到ugoira ZIP地址');
}
// 仅添加ZIP到下载列表;预览将由本地生成GIF
images = [
{ original: zipUrl, large: zipUrl, medium: zipUrl, square_medium: zipUrl },
];
if (Array.isArray(zipResult.data?.frames)) {
artwork.ugoira_frames = zipResult.data.frames;
}
} else {
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size); const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
if (!imagesResult.success) { if (!imagesResult.success) {
throw new Error(`获取图片URL失败: ${imagesResult.error}`); throw new Error(`获取图片URL失败: ${imagesResult.error}`);
} }
images = imagesResult.data.images;
const images = imagesResult.data.images; }
// 直接下载,不创建新任务 // 直接下载,不创建新任务
const results = []; const results = [];
@@ -1165,6 +1209,30 @@ class DownloadService {
const infoPath = path.join(artworkDir, 'artwork_info.json'); const infoPath = path.join(artworkDir, 'artwork_info.json');
await fs.writeJson(infoPath, artwork, { spaces: 2 }); await fs.writeJson(infoPath, artwork, { spaces: 2 });
// 若为ugoira,生成预览GIF(不影响注册表判定)
try {
if (artwork && artwork.type === 'ugoira' && Array.isArray(artwork.ugoira_frames) && artwork.ugoira_frames.length) {
const files = await fs.readdir(artworkDir);
const zipFile = files.find((f) => f.toLowerCase().endsWith('.zip'));
if (zipFile) {
const zipPath = path.join(artworkDir, zipFile);
const previewGifPath = path.join(artworkDir, 'preview.gif');
await generatePreviewGifFromUgoira(zipPath, artwork.ugoira_frames, previewGifPath);
// 校验GIF完整性(不计入images数量)
const headerCheck = await this.fileManager.checkFileHeader(previewGifPath, 'image/gif');
if (!headerCheck.valid) {
await this.fileManager.safeDeleteFile(previewGifPath).catch(() => {});
logger.warn('批量生成的预览GIF完整性校验失败,已清理', { previewGifPath, reason: headerCheck.reason });
} else {
logger.info('批量已生成ugoira预览GIF', { previewGifPath });
}
}
}
} catch (gifError) {
logger.warn('批量生成ugoira预览GIF失败(继续流程)', { error: gifError.message });
}
// 检查下载结果 // 检查下载结果
const failedCount = results.filter(r => !r.success).length; const failedCount = results.filter(r => !r.success).length;
const successCount = results.filter(r => r.success && !r.skipped).length; const successCount = results.filter(r => r.success && !r.skipped).length;
@@ -1190,10 +1258,11 @@ class DownloadService {
break; break;
} }
// 检查MIME类型 - 使用checkFileHeader方法来检测文件类型 // 检查MIME类型 - 依据扩展名设置期望类型,支持ZIP
const headerCheck = await this.fileManager.checkFileHeader(filePath); const expectedMimeType = this.getMimeTypeFromExtension(fileName);
if (!headerCheck.valid || !headerCheck.detectedType || !headerCheck.detectedType.startsWith('image/')) { const headerCheck = await this.fileManager.checkFileHeader(filePath, expectedMimeType);
logger.warn(`文件MIME类型检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`); if (!headerCheck.valid) {
logger.warn(`文件MIME类型或头部检查失败: ${filePath}, 检测结果: ${JSON.stringify(headerCheck)}`);
integrityCheckPassed = false; integrityCheckPassed = false;
break; break;
} }
+12
View File
@@ -168,6 +168,8 @@ class FileManager {
if (expectedMimeType) { if (expectedMimeType) {
if (expectedMimeType.startsWith('image/')) { if (expectedMimeType.startsWith('image/')) {
return 1024; // 图片文件至少1KB return 1024; // 图片文件至少1KB
} else if (expectedMimeType.includes('zip')) {
return 1024; // ZIP文件至少1KB
} }
} }
@@ -243,6 +245,16 @@ class FileManager {
return { valid: true, detectedType: 'image/webp' }; return { valid: true, detectedType: 'image/webp' };
} }
// 非图片类型的文件头检查(例如ZIP)
if (expectedMimeType && expectedMimeType.includes('zip')) {
// ZIP文件头常见为 504B0304 或 504B0506 等,以 504B 开头
const headerHex = buffer.toString('hex', 0, Math.min(bytesRead, 4));
if (headerHex.startsWith('504b')) {
return { valid: true, detectedType: 'application/zip' };
}
return { valid: false, reason: '文件格式不匹配:期望ZIP但未检测到ZIP头部' };
}
// 如果没有明确的期望类型,且检测到了有效的图片头部,则认为有效 // 如果没有明确的期望类型,且检测到了有效的图片头部,则认为有效
if (!expectedMimeType) { if (!expectedMimeType) {
return { valid: true, detectedType: 'unknown' }; return { valid: true, detectedType: 'unknown' };
+506 -37
View File
@@ -18,6 +18,10 @@ class RepositoryService {
this.configManager = new ConfigManager() this.configManager = new ConfigManager()
this.config = null this.config = null
// 配置加载状态
this.configLoaded = false
this.configLoading = false
// 磁盘使用情况缓存 // 磁盘使用情况缓存
this.diskUsageCache = { this.diskUsageCache = {
data: null, data: null,
@@ -25,8 +29,16 @@ class RepositoryService {
cacheDuration: 5 * 60 * 1000 // 5分钟缓存 cacheDuration: 5 * 60 * 1000 // 5分钟缓存
} }
// 文件扫描缓存
this.scanCache = {
data: null,
timestamp: 0,
cacheDuration: 10 * 60 * 1000 // 10分钟缓存
}
// 缓存文件路径 // 缓存文件路径
this.cacheFilePath = null this.cacheFilePath = null
this.scanCacheFilePath = null
} }
// 获取当前工作目录(基于配置) // 获取当前工作目录(基于配置)
@@ -56,9 +68,11 @@ class RepositoryService {
// 初始化缓存文件路径 // 初始化缓存文件路径
this.cacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'disk-usage-cache.json') this.cacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'disk-usage-cache.json')
this.scanCacheFilePath = path.join(path.dirname(this.configManager.getConfigPath()), 'scan-cache.json')
// 加载持久化缓存 // 加载持久化缓存
await this.loadPersistentCache() await this.loadPersistentCache()
await this.loadScanCache()
return { success: true, message: '仓库初始化成功' } return { success: true, message: '仓库初始化成功' }
} catch (error) { } catch (error) {
@@ -66,12 +80,31 @@ class RepositoryService {
} }
} }
// 加载配置 // 加载配置 - 优化版本,支持缓存和防重复加载
async loadConfig() { async loadConfig() {
// 如果配置已加载,直接返回
if (this.configLoaded && this.config) {
return this.config
}
// 如果正在加载,等待加载完成
if (this.configLoading) {
while (this.configLoading) {
await new Promise(resolve => setTimeout(resolve, 50))
}
return this.config
}
// 开始加载配置
this.configLoading = true
try { try {
this.config = await this.configManager.readConfig() this.config = await this.configManager.readConfig()
this.configLoaded = true
logger.info('配置加载成功')
return this.config
} catch (error) { } catch (error) {
logger.error('加载配置失败:', error) logger.error('加载配置失败,使用默认配置:', error)
// 如果加载失败,使用默认配置对象 // 如果加载失败,使用默认配置对象
this.config = { this.config = {
downloadDir: "./downloads", downloadDir: "./downloads",
@@ -83,6 +116,10 @@ class RepositoryService {
migrationRules: [], migrationRules: [],
lastUpdated: new Date().toISOString() lastUpdated: new Date().toISOString()
} }
this.configLoaded = true
return this.config
} finally {
this.configLoading = false
} }
} }
@@ -134,44 +171,75 @@ class RepositoryService {
} }
} }
// 扫描仓库 // 扫描仓库 - 优化版本,支持并发扫描
async scanRepository() { async scanRepository(options = {}) {
const {
maxConcurrency = 5, // 减少默认并发数,避免文件句柄过多
useCache = true,
forceRefresh = false,
progressCallback = null
} = options
// 检查缓存
if (useCache && !forceRefresh) {
const cachedResult = await this.getCachedScanResult()
if (cachedResult) {
logger.info('使用缓存的扫描结果')
return cachedResult
}
}
const artworks = [] const artworks = []
const artists = new Set() const artists = new Set()
let totalSize = 0 let totalSize = 0
let processedArtists = 0
try { try {
// 确保配置已加载 // 确保配置已加载(使用缓存版本)
if (!this.configLoaded) {
await this.loadConfig() await this.loadConfig()
}
// 使用当前配置的目录 // 使用当前配置的目录
const currentBaseDir = this.getCurrentBaseDir() const currentBaseDir = this.getCurrentBaseDir()
// 扫描作者目录 // 扫描作者目录
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true }) const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
const artistDirs = artistEntries
.filter(entry => entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== '.repository-config.json')
.map(entry => ({
name: entry.name,
path: path.join(currentBaseDir, entry.name)
}))
for (const artistEntry of artistEntries) { logger.info(`开始并发扫描 ${artistDirs.length} 个作者目录`)
if (!artistEntry.isDirectory()) continue
// 跳过配置文件和隐藏文件 // 并发处理作者目录
if (artistEntry.name.startsWith('.') || artistEntry.name === '.repository-config.json') { const artistPromises = artistDirs.map(async (artistDir) => {
continue try {
} const artistName = artistDir.name
const artistPath = artistDir.path
const artistName = artistEntry.name
const artistPath = path.join(currentBaseDir, artistName)
// 扫描作者下的作品目录 // 扫描作者下的作品目录
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true }) const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
const artworkDirs = artworkEntries
.filter(entry => entry.isDirectory())
.map(entry => ({
name: entry.name,
path: path.join(artistPath, entry.name)
}))
for (const artworkEntry of artworkEntries) { // 并发扫描作品文件
if (!artworkEntry.isDirectory()) continue const artworkPromises = artworkDirs.map(async (artworkDir) => {
try {
const fullPath = path.join(artistPath, artworkEntry.name) const fullPath = artworkDir.path
// 检查是否是作品目录(包含数字ID) // 检查是否是作品目录(包含数字ID)
const artworkMatch = artworkEntry.name.match(/^(\d+)_(.+)$/) const artworkMatch = artworkDir.name.match(/^(\d+)_(.+)$/)
if (artworkMatch) { if (!artworkMatch) return null
const artworkId = artworkMatch[1] const artworkId = artworkMatch[1]
const title = artworkMatch[2] const title = artworkMatch[2]
@@ -179,63 +247,152 @@ class RepositoryService {
const files = await this.scanArtworkFiles(fullPath) const files = await this.scanArtworkFiles(fullPath)
if (files.length > 0) { if (files.length > 0) {
artworks.push({ const artworkSize = files.reduce((sum, file) => sum + file.size, 0)
return {
id: artworkId, id: artworkId,
title: title, title: title,
artist: artistName, artist: artistName,
artistPath: artistPath, artistPath: artistPath,
path: fullPath, path: fullPath,
files: files, files: files,
size: files.reduce((sum, file) => sum + file.size, 0), size: artworkSize,
createdAt: await this.getFileCreationTime(fullPath) createdAt: await this.getFileCreationTime(fullPath)
}
}
return null
} catch (error) {
logger.warn(`扫描作品目录失败 ${artworkDir.path}:`, error.message)
return null
}
})
// 等待所有作品扫描完成
const artworkResults = await Promise.all(artworkPromises)
const validArtworks = artworkResults.filter(artwork => artwork !== null)
processedArtists++
if (progressCallback) {
progressCallback({
type: 'artist_completed',
artist: artistName,
artworkCount: validArtworks.length,
progress: Math.round((processedArtists / artistDirs.length) * 100)
}) })
artists.add(artistName)
totalSize += files.reduce((sum, file) => sum + file.size, 0)
} }
return validArtworks
} catch (error) {
logger.warn(`扫描作者目录失败 ${artistDir.path}:`, error.message)
return []
} }
})
// 分批处理,避免过多并发
const batchSize = maxConcurrency
for (let i = 0; i < artistPromises.length; i += batchSize) {
const batch = artistPromises.slice(i, i + batchSize)
const batchResults = await Promise.all(batch)
// 处理批次结果
for (const artistArtworks of batchResults) {
for (const artwork of artistArtworks) {
artworks.push(artwork)
artists.add(artwork.artist)
totalSize += artwork.size
} }
} }
return { // 更新进度
if (progressCallback) {
progressCallback({
type: 'batch_completed',
processed: Math.min(i + batchSize, artistDirs.length),
total: artistDirs.length,
progress: Math.round((Math.min(i + batchSize, artistDirs.length) / artistDirs.length) * 100)
})
}
}
const result = {
artworks, artworks,
artists: Array.from(artists), artists: Array.from(artists),
totalSize totalSize,
scanTime: Date.now()
} }
// 缓存结果
if (useCache) {
await this.cacheScanResult(result)
}
logger.info(`扫描完成: ${artworks.length} 个作品, ${artists.size} 个作者, 总大小: ${Math.round(totalSize / 1024 / 1024)}MB`)
return result
} catch (error) { } catch (error) {
throw new Error(`扫描仓库失败: ${error.message}`) throw new Error(`扫描仓库失败: ${error.message}`)
} }
} }
// 扫描作品文件 // 扫描作品文件 - 优化版本,支持并发扫描和批量统计
async scanArtworkFiles(artworkPath) { async scanArtworkFiles(artworkPath) {
try { try {
// 确保配置已加载 // 确保配置已加载(使用缓存版本)
if (!this.configLoaded) {
await this.loadConfig() await this.loadConfig()
}
const files = []
const entries = await fs.readdir(artworkPath, { withFileTypes: true }) const entries = await fs.readdir(artworkPath, { withFileTypes: true })
const fileEntries = entries.filter(entry => entry.isFile())
for (const entry of entries) { // 过滤允许的扩展名
if (entry.isFile()) { const allowedFiles = fileEntries.filter(entry => {
const filePath = path.join(artworkPath, entry.name)
const ext = path.extname(entry.name).toLowerCase() const ext = path.extname(entry.name).toLowerCase()
return this.config.allowedExtensions.includes(ext)
})
if (this.config.allowedExtensions.includes(ext)) { if (allowedFiles.length === 0) {
return []
}
// 大幅减少并发数量,避免 "too many open files" 错误
const batchSize = 3 // 进一步减少到3,更安全
const results = []
for (let i = 0; i < allowedFiles.length; i += batchSize) {
const batch = allowedFiles.slice(i, i + batchSize)
// 处理当前批次
const batchPromises = batch.map(async (entry) => {
try {
const filePath = path.join(artworkPath, entry.name)
const stats = await fs.stat(filePath) const stats = await fs.stat(filePath)
const currentBaseDir = this.getCurrentBaseDir() const currentBaseDir = this.getCurrentBaseDir()
files.push({
return {
name: entry.name, name: entry.name,
path: path.relative(currentBaseDir, filePath), path: path.relative(currentBaseDir, filePath),
size: stats.size, size: stats.size,
extension: ext, extension: path.extname(entry.name).toLowerCase(),
modifiedAt: stats.mtime modifiedAt: stats.mtime
})
} }
} catch (error) {
logger.warn(`获取文件统计信息失败 ${entry.name}:`, error.message)
return null
}
})
const batchResults = await Promise.all(batchPromises)
results.push(...batchResults)
// 添加小延迟,让系统有时间关闭文件句柄
if (i + batchSize < allowedFiles.length) {
await new Promise(resolve => setTimeout(resolve, 20)) // 增加延迟时间
} }
} }
return files return results.filter(file => file !== null)
} catch (error) { } catch (error) {
logger.warn(`扫描作品文件失败 ${artworkPath}:`, error.message)
return [] return []
} }
} }
@@ -1044,6 +1201,318 @@ class RepositoryService {
const relativePath = path.relative(this.baseDir, filePath) const relativePath = path.relative(this.baseDir, filePath)
return `/api/repository/preview?path=${encodeURIComponent(relativePath)}` return `/api/repository/preview?path=${encodeURIComponent(relativePath)}`
} }
// 获取缓存的扫描结果
async getCachedScanResult() {
try {
const now = Date.now()
// 检查内存缓存
if (this.scanCache.data &&
(now - this.scanCache.timestamp) < this.scanCache.cacheDuration) {
return this.scanCache.data
}
// 检查持久化缓存
if (this.scanCacheFilePath && await fs.access(this.scanCacheFilePath).then(() => true).catch(() => false)) {
const cacheData = await fs.readFile(this.scanCacheFilePath, 'utf8')
const cache = JSON.parse(cacheData)
// 检查缓存是否有效
const cacheAge = now - cache.timestamp
if (cacheAge < this.scanCache.cacheDuration) {
this.scanCache.data = cache.data
this.scanCache.timestamp = cache.timestamp
return cache.data
}
}
return null
} catch (error) {
logger.warn('获取扫描缓存失败:', error.message)
return null
}
}
// 缓存扫描结果
async cacheScanResult(result) {
try {
const now = Date.now()
// 更新内存缓存
this.scanCache.data = result
this.scanCache.timestamp = now
// 保存到持久化缓存
if (this.scanCacheFilePath) {
const cacheData = {
data: result,
timestamp: now,
savedAt: new Date().toISOString()
}
await fs.writeFile(this.scanCacheFilePath, JSON.stringify(cacheData, null, 2), 'utf8')
logger.info('扫描结果已缓存')
}
} catch (error) {
logger.warn('缓存扫描结果失败:', error.message)
}
}
// 加载扫描缓存
async loadScanCache() {
try {
if (!this.scanCacheFilePath) {
return
}
const cacheData = await fs.readFile(this.scanCacheFilePath, 'utf8')
const cache = JSON.parse(cacheData)
// 检查缓存是否有效(10分钟内)
const now = Date.now()
const cacheAge = now - cache.timestamp
const maxCacheAge = this.scanCache.cacheDuration
if (cacheAge < maxCacheAge) {
this.scanCache.data = cache.data
this.scanCache.timestamp = cache.timestamp
logger.info('已加载扫描缓存,缓存年龄:', Math.round(cacheAge / 1000 / 60), '分钟')
} else {
logger.info('扫描缓存已过期,将重新扫描')
}
} catch (error) {
logger.info('加载扫描缓存失败,将重新扫描:', error.message)
}
}
// 清除扫描缓存
async clearScanCache() {
try {
// 清除内存缓存
this.scanCache.data = null
this.scanCache.timestamp = 0
// 删除持久化缓存文件
if (this.scanCacheFilePath) {
try {
await fs.unlink(this.scanCacheFilePath)
logger.info('扫描缓存文件已删除')
} catch (error) {
logger.info('删除扫描缓存文件失败:', error.message)
}
}
return { success: true, message: '扫描缓存已清除' }
} catch (error) {
throw new Error(`清除扫描缓存失败: ${error.message}`)
}
}
// 快速扫描 - 仅获取基本信息,不扫描文件详情
async quickScan() {
try {
// 确保配置已加载(使用缓存版本)
if (!this.configLoaded) {
await this.loadConfig()
}
const currentBaseDir = this.getCurrentBaseDir()
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
const artistDirs = artistEntries
.filter(entry => entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== '.repository-config.json')
const artists = artistDirs.map(entry => entry.name)
let totalArtworks = 0
// 快速统计作品数量
for (const artistDir of artistDirs) {
try {
const artistPath = path.join(currentBaseDir, artistDir.name)
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
const artworkDirs = artworkEntries.filter(entry => entry.isDirectory())
totalArtworks += artworkDirs.length
} catch (error) {
logger.warn(`快速扫描作者目录失败 ${artistDir.name}:`, error.message)
}
}
return {
totalArtists: artists.length,
totalArtworks,
artists,
scanTime: Date.now()
}
} catch (error) {
throw new Error(`快速扫描失败: ${error.message}`)
}
}
// 增量扫描 - 只扫描变更的目录和文件
async incrementalScan(options = {}) {
const {
maxConcurrency = 10,
useCache = true,
progressCallback = null
} = options
try {
// 确保配置已加载(使用缓存版本)
if (!this.configLoaded) {
await this.loadConfig()
}
const currentBaseDir = this.getCurrentBaseDir()
// 获取上次扫描的时间戳
const lastScanTime = this.scanCache.timestamp || 0
const now = Date.now()
// 如果缓存时间超过1小时,执行完整扫描
if (now - lastScanTime > 60 * 60 * 1000) {
logger.info('缓存过期,执行完整扫描')
return await this.scanRepository(options)
}
const artworks = []
const artists = new Set()
let totalSize = 0
let changedCount = 0
// 扫描作者目录
const artistEntries = await fs.readdir(currentBaseDir, { withFileTypes: true })
const artistDirs = artistEntries
.filter(entry => entry.isDirectory() &&
!entry.name.startsWith('.') &&
entry.name !== '.repository-config.json')
logger.info(`开始增量扫描 ${artistDirs.length} 个作者目录`)
// 并发处理作者目录
const artistPromises = artistDirs.map(async (artistDir) => {
try {
const artistName = artistDir.name
const artistPath = path.join(currentBaseDir, artistName)
// 检查作者目录是否在缓存时间后有变更
const artistStats = await fs.stat(artistPath)
if (artistStats.mtime.getTime() <= lastScanTime) {
// 目录未变更,跳过
return []
}
changedCount++
logger.debug(`检测到变更的作者目录: ${artistName}`)
// 扫描作者下的作品目录
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
const artworkDirs = artworkEntries
.filter(entry => entry.isDirectory())
.map(entry => ({
name: entry.name,
path: path.join(artistPath, entry.name)
}))
// 并发扫描作品文件
const artworkPromises = artworkDirs.map(async (artworkDir) => {
try {
const fullPath = artworkDir.path
// 检查作品目录是否在缓存时间后有变更
const artworkStats = await fs.stat(fullPath)
if (artworkStats.mtime.getTime() <= lastScanTime) {
// 作品目录未变更,跳过
return null
}
// 检查是否是作品目录(包含数字ID)
const artworkMatch = artworkDir.name.match(/^(\d+)_(.+)$/)
if (!artworkMatch) return null
const artworkId = artworkMatch[1]
const title = artworkMatch[2]
// 扫描作品文件
const files = await this.scanArtworkFiles(fullPath)
if (files.length > 0) {
const artworkSize = files.reduce((sum, file) => sum + file.size, 0)
return {
id: artworkId,
title: title,
artist: artistName,
artistPath: artistPath,
path: fullPath,
files: files,
size: artworkSize,
createdAt: await this.getFileCreationTime(fullPath)
}
}
return null
} catch (error) {
logger.warn(`扫描作品目录失败 ${artworkDir.path}:`, error.message)
return null
}
})
// 等待所有作品扫描完成
const artworkResults = await Promise.all(artworkPromises)
return artworkResults.filter(artwork => artwork !== null)
} catch (error) {
logger.warn(`扫描作者目录失败 ${artistDir.path}:`, error.message)
return []
}
})
// 分批处理,避免过多并发
const batchSize = maxConcurrency
for (let i = 0; i < artistPromises.length; i += batchSize) {
const batch = artistPromises.slice(i, i + batchSize)
const batchResults = await Promise.all(batch)
// 处理批次结果
for (const artistArtworks of batchResults) {
for (const artwork of artistArtworks) {
artworks.push(artwork)
artists.add(artwork.artist)
totalSize += artwork.size
}
}
// 更新进度
if (progressCallback) {
progressCallback({
type: 'incremental_progress',
processed: Math.min(i + batchSize, artistDirs.length),
total: artistDirs.length,
changed: changedCount,
progress: Math.round((Math.min(i + batchSize, artistDirs.length) / artistDirs.length) * 100)
})
}
}
const result = {
artworks,
artists: Array.from(artists),
totalSize,
scanTime: now,
isIncremental: true,
changedCount
}
// 更新缓存
if (useCache) {
await this.cacheScanResult(result)
}
logger.info(`增量扫描完成: ${artworks.length} 个作品, ${artists.size} 个作者, 变更: ${changedCount} 个目录`)
return result
} catch (error) {
throw new Error(`增量扫描失败: ${error.message}`)
}
}
} }
module.exports = RepositoryService module.exports = RepositoryService
+80
View File
@@ -0,0 +1,80 @@
const fs = require('fs');
const path = require('path');
const fsExtra = require('fs-extra');
// These dependencies are expected in project dependencies
const AdmZip = require('adm-zip');
const jpeg = require('jpeg-js');
const GIFEncoder = require('gifencoder');
/**
* Generate an animated GIF preview from a Pixiv ugoira ZIP and frame metadata.
* - Extracts frames from ZIP into a temp directory
* - Encodes frames into GIF honoring per-frame delays
* - Writes GIF to outPath
*
* @param {string} zipPath - Path to the downloaded ugoira ZIP file
* @param {Array<{file:string, delay:number}>} frames - Frame metadata from Pixiv API
* @param {string} outPath - Destination path for the generated GIF (e.g., preview.gif)
*/
async function generatePreviewGifFromUgoira(zipPath, frames, outPath) {
if (!zipPath || !Array.isArray(frames) || frames.length === 0 || !outPath) {
throw new Error('Invalid parameters for generating ugoira preview GIF');
}
const tmpDir = path.join(path.dirname(outPath), '.ugoira_tmp');
await fsExtra.ensureDir(tmpDir);
try {
// Extract all frames
const zip = new AdmZip(zipPath);
zip.extractAllTo(tmpDir, true);
// Determine size from first frame
const firstFramePath = path.join(tmpDir, frames[0].file);
const firstBuf = fs.readFileSync(firstFramePath);
const firstDecoded = jpeg.decode(firstBuf, { useTArray: true });
const width = firstDecoded.width;
const height = firstDecoded.height;
// Initialize encoder and output stream
const encoder = new GIFEncoder(width, height);
const ws = fs.createWriteStream(outPath);
encoder.createReadStream().pipe(ws);
encoder.start();
encoder.setRepeat(0); // loop forever
encoder.setQuality(10);
// Add frames honoring per-frame delay
for (const f of frames) {
const framePath = path.join(tmpDir, f.file);
const buf = fs.readFileSync(framePath);
const decoded = jpeg.decode(buf, { useTArray: true });
// Ensure dimensions match; if not, skip or resize (skip for simplicity)
if (decoded.width !== width || decoded.height !== height) {
// Skip mismatched frames to keep encoder stable
continue;
}
encoder.setDelay(typeof f.delay === 'number' ? f.delay : 0);
encoder.addFrame(decoded.data);
}
encoder.finish();
await new Promise((resolve, reject) => {
ws.on('finish', resolve);
ws.on('error', reject);
});
} finally {
// Cleanup extracted frames
try { await fsExtra.remove(tmpDir); } catch (_) {}
}
}
module.exports = {
generatePreviewGifFromUgoira,
};
+65 -2
View File
@@ -6,6 +6,12 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os');
// 只在 Windows 环境下设置终端标题
if (os.platform() === 'win32') {
process.title = 'Pixiv Manager';
}
// 加载配置文件 // 加载配置文件
function loadConfig() { function loadConfig() {
@@ -61,6 +67,31 @@ function loadConfig() {
return config; return config;
} }
// 获取Windows系统代理配置
function getWindowsProxy() {
if (os.platform() !== 'win32') return null;
try {
const { execSync } = require('child_process');
// 查询注册表获取代理设置
const output = execSync('reg query "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings"', { encoding: 'utf8' });
// 检查代理是否启用 (ProxyEnable = 1)
const proxyEnableMatch = output.match(/ProxyEnable\s+REG_DWORD\s+0x([0-9a-fA-F]+)/);
if (proxyEnableMatch && parseInt(proxyEnableMatch[1], 16) === 1) {
// 获取代理服务器地址
const proxyServerMatch = output.match(/ProxyServer\s+REG_SZ\s+([^\r\n]+)/);
if (proxyServerMatch) {
return proxyServerMatch[1];
}
}
} catch (error) {
// 忽略错误,可能没有权限或键不存在
}
return null;
}
// 加载配置 // 加载配置
const config = loadConfig(); const config = loadConfig();
@@ -92,14 +123,46 @@ if (config.proxy.enabled === true || (config.proxy.enabled === "auto" && config.
} }
} else if (config.proxy.enabled === "auto") { } else if (config.proxy.enabled === "auto") {
// 自动检测系统代理 // 自动检测系统代理
const systemProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy; let systemProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy;
// 如果环境变量没有设置,尝试从Windows注册表获取
if (!systemProxy && os.platform() === 'win32') {
const winProxy = getWindowsProxy();
if (winProxy) {
systemProxy = winProxy;
logger.info(`通过注册表检测到系统代理: ${systemProxy}`);
}
}
if (systemProxy) { if (systemProxy) {
logger.info(`检测到系统代理: ${systemProxy}`); logger.info(`检测到系统代理: ${systemProxy}`);
// 从系统代理URL中提取端口 // 从系统代理URL中提取端口
const match = systemProxy.match(/http:\/\/127\.0\.0\.1:(\d+)/); // 支持格式:
// 1. http://127.0.0.1:7890
// 2. 127.0.0.1:7890
// 3. localhost:7890
const match = systemProxy.match(/(?:127\.0\.0\.1|localhost):(\d+)/);
if (match) { if (match) {
process.env.PROXY_PORT = match[1]; process.env.PROXY_PORT = match[1];
// 确保HTTP_PROXY环境变量被设置,供axios/proxy-agent使用
let proxyUrl = systemProxy;
if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) {
proxyUrl = `http://${proxyUrl}`;
}
if (!process.env.HTTP_PROXY) {
process.env.HTTP_PROXY = proxyUrl;
logger.info(`已设置 HTTP_PROXY 环境变量: ${proxyUrl}`);
}
if (!process.env.HTTPS_PROXY) {
process.env.HTTPS_PROXY = proxyUrl;
logger.info(`已设置 HTTPS_PROXY 环境变量: ${proxyUrl}`);
}
logger.info(`自动设置代理端口为: ${match[1]}`); logger.info(`自动设置代理端口为: ${match[1]}`);
} else {
logger.warn(`无法从代理字符串中解析端口: ${systemProxy}`);
} }
} else { } else {
logger.info('未检测到系统代理,将尝试使用系统代理环境变量'); logger.info('未检测到系统代理,将尝试使用系统代理环境变量');
+1
View File
@@ -63,6 +63,7 @@ const ModuleColors = {
'AbortControllerManager': '\x1b[94m', // 亮蓝色 'AbortControllerManager': '\x1b[94m', // 亮蓝色
'DatabaseManager': '\x1b[95m', // 亮紫色 'DatabaseManager': '\x1b[95m', // 亮紫色
'RegistrySchema': '\x1b[94m', // 亮蓝色 'RegistrySchema': '\x1b[94m', // 亮蓝色
'WatchlistDatabase': '\x1b[94m', // 亮蓝色
'RegistryDatabase': '\x1b[94m', // 亮蓝色 'RegistryDatabase': '\x1b[94m', // 亮蓝色
'Default': '\x1b[39m' // 默认颜色 'Default': '\x1b[39m' // 默认颜色
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"server": { "server": {
"port": 3001, "port": 3000,
"autoOpenBrowser": false "autoOpenBrowser": false
}, },
"proxy": { "proxy": {
+10 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "pixiv-manager", "name": "pixiv-manager",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"main": "backend/start.js", "main": "backend/start.js",
"bin": "backend/start.js", "bin": "backend/start.js",
@@ -17,15 +17,23 @@
"scripts": { "scripts": {
"dev": "node backend/start.js", "dev": "node backend/start.js",
"build": "pkg . && node scripts/add-icon.js", "build": "pkg . && node scripts/add-icon.js",
"bp": "npm run build && node scripts/create-portable.js" "bp": "npm run build && node scripts/create-portable.js",
"build:linux": "pkg . --targets=node18-linux-x64 --output=dist/pixiv-manager",
"build:macos": "pkg . --targets=node18-macos-x64 --output=dist/pixiv-manager",
"bp:linux": "npm run build:linux && node scripts/create-portable.js linux",
"bp:macos": "npm run build:macos && node scripts/create-portable.js macos"
}, },
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16",
"appdata-path": "^1.0.0", "appdata-path": "^1.0.0",
"axios": "0.27.0", "axios": "0.27.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"fs-extra": "^11.3.2", "fs-extra": "^11.3.2",
"gifencoder": "^2.0.1",
"jpeg-js": "^0.4.4",
"js-base64": "^3.7.8", "js-base64": "^3.7.8",
"jszip": "^3.10.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"mysql2": "^3.15.2", "mysql2": "^3.15.2",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
+363
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
adm-zip:
specifier: ^0.5.16
version: 0.5.16
appdata-path: appdata-path:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.0.0 version: 1.0.0
@@ -23,9 +26,18 @@ importers:
fs-extra: fs-extra:
specifier: ^11.3.2 specifier: ^11.3.2
version: 11.3.2 version: 11.3.2
gifencoder:
specifier: ^2.0.1
version: 2.0.1
jpeg-js:
specifier: ^0.4.4
version: 0.4.4
js-base64: js-base64:
specifier: ^3.7.8 specifier: ^3.7.8
version: 3.7.8 version: 3.7.8
jszip:
specifier: ^3.10.1
version: 3.10.1
moment: moment:
specifier: ^2.30.1 specifier: ^2.30.1
version: 2.30.1 version: 2.30.1
@@ -91,6 +103,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mapbox/node-pre-gyp@1.0.11':
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
hasBin: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -106,10 +122,17 @@ packages:
'@tootallnate/quickjs-emscripten@0.23.0': '@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
accepts@2.0.0: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agent-base@6.0.2: agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@@ -129,6 +152,14 @@ packages:
appdata-path@1.0.0: appdata-path@1.0.0:
resolution: {integrity: sha512-ZbH3ezXfnT/YE3NdqduIt4lBV+H0ybvA2Qx3K76gIjQvh8gROpDFdDLpx6B1QJtW7zxisCbpTlCLhKqoR8cDBw==} resolution: {integrity: sha512-ZbH3ezXfnT/YE3NdqduIt4lBV+H0ybvA2Qx3K76gIjQvh8gROpDFdDLpx6B1QJtW7zxisCbpTlCLhKqoR8cDBw==}
aproba@2.1.0:
resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==}
are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
array-union@2.1.0: array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -152,6 +183,9 @@ packages:
resolution: {integrity: sha512-XV/WrPxXfzgZ8j4lcB5i6LyaXmi90yetmV/Fem0kmglGx+mpY06CiweL3YxU6wOTNLmqLUePW4G8h45nGZ/+pA==} resolution: {integrity: sha512-XV/WrPxXfzgZ8j4lcB5i6LyaXmi90yetmV/Fem0kmglGx+mpY06CiweL3YxU6wOTNLmqLUePW4G8h45nGZ/+pA==}
deprecated: Formdata complete broken, incorrect build size deprecated: Formdata complete broken, incorrect build size
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -166,6 +200,9 @@ packages:
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
engines: {node: '>=18'} engines: {node: '>=18'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3: braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -185,6 +222,10 @@ packages:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
canvas@2.11.2:
resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==}
engines: {node: '>=6'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -192,6 +233,10 @@ packages:
chownr@1.1.4: chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
cliui@7.0.4: cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -202,10 +247,20 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@1.0.0: content-disposition@1.0.0:
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -242,6 +297,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decompress-response@4.2.1:
resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==}
engines: {node: '>=8'}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -258,6 +317,9 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
denque@2.1.0: denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@@ -394,9 +456,21 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gauge@3.0.2:
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
engines: {node: '>=10'}
deprecated: This package is no longer supported.
generate-function@2.3.1: generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
@@ -416,6 +490,9 @@ packages:
resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
gifencoder@2.0.1:
resolution: {integrity: sha512-x19DcyWY10SkshBpokqFOo/HBht9GB75evRYvaLMbez9p+yB/o+kt0fK9AwW59nFiAMs2UUQsjv1lX/hvu9Ong==}
github-from-package@0.0.0: github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@@ -423,6 +500,10 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
globby@11.1.0: globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -446,6 +527,9 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
has@1.0.4: has@1.0.4:
resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==} resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
@@ -485,6 +569,13 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -535,6 +626,9 @@ packages:
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
js-base64@3.7.8: js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -546,6 +640,12 @@ packages:
jsonfile@6.2.0: jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
long@5.3.2: long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
@@ -557,6 +657,10 @@ packages:
resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==} resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -593,16 +697,40 @@ packages:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
mimic-response@2.1.0:
resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==}
engines: {node: '>=8'}
mimic-response@3.1.0: mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
mkdirp-classic@0.5.3: mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
moment@2.30.1: moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
@@ -620,6 +748,9 @@ packages:
resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
nan@2.23.0:
resolution: {integrity: sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==}
napi-build-utils@1.0.2: napi-build-utils@1.0.2:
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
@@ -644,6 +775,15 @@ packages:
encoding: encoding:
optional: true optional: true
nopt@5.0.0:
resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==}
engines: {node: '>=6'}
hasBin: true
npmlog@5.0.1:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -671,10 +811,17 @@ packages:
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -779,6 +926,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
router@2.2.0: router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -795,6 +947,10 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.2: semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -811,6 +967,12 @@ packages:
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -830,9 +992,15 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
simple-concat@1.0.1: simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@3.1.1:
resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==}
simple-get@4.0.1: simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
@@ -904,6 +1072,10 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
to-fast-properties@2.0.0: to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -954,6 +1126,9 @@ packages:
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -965,6 +1140,9 @@ packages:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@20.2.9: yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1009,6 +1187,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mapbox/node-pre-gyp@1.0.11':
dependencies:
detect-libc: 2.1.1
https-proxy-agent: 5.0.1
make-dir: 3.1.0
node-fetch: 2.7.0
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.7.2
tar: 6.2.1
transitivePeerDependencies:
- encoding
- supports-color
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -1023,11 +1216,15 @@ snapshots:
'@tootallnate/quickjs-emscripten@0.23.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {}
abbrev@1.1.1: {}
accepts@2.0.0: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.1 mime-types: 3.0.1
negotiator: 1.0.0 negotiator: 1.0.0
adm-zip@0.5.16: {}
agent-base@6.0.2: agent-base@6.0.2:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -1044,6 +1241,13 @@ snapshots:
appdata-path@1.0.0: {} appdata-path@1.0.0: {}
aproba@2.1.0: {}
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
array-union@2.1.0: {} array-union@2.1.0: {}
ast-types@0.13.4: ast-types@0.13.4:
@@ -1063,6 +1267,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
balanced-match@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
basic-ftp@5.0.5: {} basic-ftp@5.0.5: {}
@@ -1087,6 +1293,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
braces@3.0.3: braces@3.0.3:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
@@ -1108,6 +1319,15 @@ snapshots:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
canvas@2.11.2:
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
nan: 2.23.0
simple-get: 3.1.1
transitivePeerDependencies:
- encoding
- supports-color
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -1115,6 +1335,8 @@ snapshots:
chownr@1.1.4: {} chownr@1.1.4: {}
chownr@2.0.0: {}
cliui@7.0.4: cliui@7.0.4:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@@ -1127,10 +1349,16 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
color-support@1.1.3: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
concat-map@0.0.1: {}
console-control-strings@1.1.0: {}
content-disposition@1.0.0: content-disposition@1.0.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -1154,6 +1382,10 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decompress-response@4.2.1:
dependencies:
mimic-response: 2.1.0
decompress-response@6.0.0: decompress-response@6.0.0:
dependencies: dependencies:
mimic-response: 3.1.0 mimic-response: 3.1.0
@@ -1168,6 +1400,8 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
delegates@1.0.0: {}
denque@2.1.0: {} denque@2.1.0: {}
depd@2.0.0: {} depd@2.0.0: {}
@@ -1324,8 +1558,26 @@ snapshots:
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
fs.realpath@1.0.0: {}
function-bind@1.1.2: {} function-bind@1.1.2: {}
gauge@3.0.2:
dependencies:
aproba: 2.1.0
color-support: 1.1.3
console-control-strings: 1.1.0
has-unicode: 2.0.1
object-assign: 4.1.1
signal-exit: 3.0.7
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
generate-function@2.3.1: generate-function@2.3.1:
dependencies: dependencies:
is-property: 1.0.2 is-property: 1.0.2
@@ -1358,12 +1610,28 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
gifencoder@2.0.1:
dependencies:
canvas: 2.11.2
transitivePeerDependencies:
- encoding
- supports-color
github-from-package@0.0.0: {} github-from-package@0.0.0: {}
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
globby@11.1.0: globby@11.1.0:
dependencies: dependencies:
array-union: 2.1.0 array-union: 2.1.0
@@ -1385,6 +1653,8 @@ snapshots:
dependencies: dependencies:
has-symbols: 1.1.0 has-symbols: 1.1.0
has-unicode@2.0.1: {}
has@1.0.4: {} has@1.0.4: {}
hasown@2.0.2: hasown@2.0.2:
@@ -1432,6 +1702,13 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immediate@3.0.6: {}
inflight@1.0.6:
dependencies:
once: 1.4.0
wrappy: 1.0.2
inherits@2.0.4: {} inherits@2.0.4: {}
ini@1.3.8: {} ini@1.3.8: {}
@@ -1469,6 +1746,8 @@ snapshots:
isarray@1.0.0: {} isarray@1.0.0: {}
jpeg-js@0.4.4: {}
js-base64@3.7.8: {} js-base64@3.7.8: {}
jsesc@2.5.2: {} jsesc@2.5.2: {}
@@ -1479,12 +1758,27 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
long@5.3.2: {} long@5.3.2: {}
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lru.min@1.1.2: {} lru.min@1.1.2: {}
make-dir@3.1.0:
dependencies:
semver: 6.3.1
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
media-typer@1.1.0: {} media-typer@1.1.0: {}
@@ -1510,12 +1804,31 @@ snapshots:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
mimic-response@2.1.0: {}
mimic-response@3.1.0: {} mimic-response@3.1.0: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@3.3.6:
dependencies:
yallist: 4.0.0
minipass@5.0.0: {}
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
mkdirp-classic@0.5.3: {} mkdirp-classic@0.5.3: {}
mkdirp@1.0.4: {}
moment@2.30.1: {} moment@2.30.1: {}
ms@2.1.3: {} ms@2.1.3: {}
@@ -1541,6 +1854,8 @@ snapshots:
dependencies: dependencies:
lru-cache: 7.18.3 lru-cache: 7.18.3
nan@2.23.0: {}
napi-build-utils@1.0.2: {} napi-build-utils@1.0.2: {}
negotiator@1.0.0: {} negotiator@1.0.0: {}
@@ -1555,6 +1870,17 @@ snapshots:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
npmlog@5.0.1:
dependencies:
are-we-there-yet: 2.0.0
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -1587,8 +1913,12 @@ snapshots:
degenerator: 5.0.1 degenerator: 5.0.1
netmask: 2.0.2 netmask: 2.0.2
pako@1.0.11: {}
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-is-absolute@1.0.1: {}
path-parse@1.0.7: {} path-parse@1.0.7: {}
path-to-regexp@8.3.0: {} path-to-regexp@8.3.0: {}
@@ -1731,6 +2061,10 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rimraf@3.0.2:
dependencies:
glob: 7.2.3
router@2.2.0: router@2.2.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -1751,6 +2085,8 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
semver@6.3.1: {}
semver@7.7.2: {} semver@7.7.2: {}
send@1.2.0: send@1.2.0:
@@ -1780,6 +2116,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
set-blocking@2.0.0: {}
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
side-channel-list@1.0.0: side-channel-list@1.0.0:
@@ -1810,8 +2150,16 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
signal-exit@3.0.7: {}
simple-concat@1.0.1: {} simple-concat@1.0.1: {}
simple-get@3.1.1:
dependencies:
decompress-response: 4.2.1
once: 1.4.0
simple-concat: 1.0.1
simple-get@4.0.1: simple-get@4.0.1:
dependencies: dependencies:
decompress-response: 6.0.0 decompress-response: 6.0.0
@@ -1889,6 +2237,15 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
tar@6.2.1:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
to-fast-properties@2.0.0: {} to-fast-properties@2.0.0: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
@@ -1928,6 +2285,10 @@ snapshots:
tr46: 0.0.3 tr46: 0.0.3
webidl-conversions: 3.0.1 webidl-conversions: 3.0.1
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -1938,6 +2299,8 @@ snapshots:
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@4.0.0: {}
yargs-parser@20.2.9: {} yargs-parser@20.2.9: {}
yargs@16.2.0: yargs@16.2.0:
+2
View File
@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- canvas
+34 -28
View File
@@ -5,9 +5,24 @@ const { defaultLogger } = require('../backend/utils/logger');
// 创建logger实例 // 创建logger实例
const logger = defaultLogger.child('CreatePortable'); const logger = defaultLogger.child('CreatePortable');
async function createPortable() { async function createPortable(platform = 'win') {
const distDir = path.join(__dirname, '..', 'dist'); const distDir = path.join(__dirname, '..', 'dist');
const portableDir = path.join(__dirname, '..', 'pixiv-manager-portable'); let portableDir = path.join(__dirname, '..', 'pixiv-manager-portable');
// 根据平台设置不同的目录名和可执行文件名
let exeName = 'pixiv-manager.exe';
let exePath = path.join(distDir, exeName);
// 根据平台参数设置
if (platform === 'linux') {
portableDir = path.join(__dirname, '..', 'pixiv-manager-portable-linux');
exeName = 'pixiv-manager';
exePath = path.join(distDir, exeName);
} else if (platform === 'macos') {
portableDir = path.join(__dirname, '..', 'pixiv-manager-portable-macos');
exeName = 'pixiv-manager';
exePath = path.join(distDir, exeName);
}
try { try {
// 清理之前的便携版 // 清理之前的便携版
@@ -15,40 +30,29 @@ async function createPortable() {
await fs.ensureDir(portableDir); await fs.ensureDir(portableDir);
// 复制可执行文件 // 复制可执行文件
const exeName = 'pixiv-manager.exe';
const exePath = path.join(distDir, exeName);
if (await fs.pathExists(exePath)) { if (await fs.pathExists(exePath)) {
await fs.copy(exePath, path.join(portableDir, exeName)); await fs.copy(exePath, path.join(portableDir, exeName));
} }
// 创建配置文件
const config = {
server: {
port: 3000,
autoOpenBrowser: true
},
proxy: {
port: null,
enabled: "auto"
},
logging: {
level: "INFO"
},
system: {
threadPoolSize: 16
}
};
await fs.writeFile(path.join(portableDir, 'config.json'), JSON.stringify(config, null, 2), 'utf8');
// 创建README // 创建README
let executableInstructions = '';
if (platform === 'linux') {
executableInstructions = `1. 添加执行权限: \`chmod +x pixiv-manager-linux\`
2. 运行程序: \`./pixiv-manager-linux\``;
} else if (platform === 'macos') {
executableInstructions = `1. 添加执行权限: \`chmod +x pixiv-manager-macos\`
2. 运行程序: \`./pixiv-manager-macos\``;
} else {
executableInstructions = `1. 双击 \`pixiv-manager.exe\` 启动程序`;
}
const readme = `# Pixiv Manager 便携版 const readme = `# Pixiv Manager 便携版
## 使用说明 ## 使用说明
1. 双击 \`pixiv-manager.exe\` 启动程序 ${executableInstructions}
2. 在浏览器中访问 http://localhost:3000 ${platform === 'win' ? '' : '3. '}在浏览器中访问 http://localhost:3000
3. 按 Ctrl+C 停止服务器 ${platform === 'win' ? '3' : '4'}. 按 Ctrl+C 停止服务器
## 配置设置 ## 配置设置
@@ -139,4 +143,6 @@ async function createPortable() {
} }
} }
createPortable(); // 获取命令行参数
const platform = process.argv[2] || 'win';
createPortable(platform);
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pixivmanager", "name": "pixivmanager",
"version": "1.0.4", "version": "1.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -18,6 +18,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"jszip": "^3.10.1",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
+5 -2
View File
@@ -10,6 +10,7 @@ import DownloadProgressWidget from '@/components/common/DownloadProgressWidget.v
import WatchlistWidget from '@/components/common/WatchlistWidget.vue' import WatchlistWidget from '@/components/common/WatchlistWidget.vue'
import RegistryWidget from '@/components/common/RegistryWidget.vue' import RegistryWidget from '@/components/common/RegistryWidget.vue'
import UpdateChecker from '@/components/common/UpdateChecker.vue' import UpdateChecker from '@/components/common/UpdateChecker.vue'
import TitleStatusWatcher from '@/components/common/TitleStatusWatcher.vue'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -93,6 +94,9 @@ onMounted(async () => {
<!-- 更新检查器 --> <!-- 更新检查器 -->
<UpdateChecker /> <UpdateChecker />
<!-- 页面标题状态监听无UI仅逻辑 -->
<TitleStatusWatcher />
<!-- GitHub 链接 --> <!-- GitHub 链接 -->
<a href="https://github.com/kjqwer/pixiv-D" target="_blank" rel="noopener noreferrer" class="github-link" <a href="https://github.com/kjqwer/pixiv-D" target="_blank" rel="noopener noreferrer" class="github-link"
title="查看项目源码"> title="查看项目源码">
@@ -165,7 +169,7 @@ onMounted(async () => {
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1002; /* 提高导航栏z-index,确保在所有元素之上 */ z-index: 1005; /* 提高导航栏z-index,确保在所有元素之上 */
} }
.nav-container { .nav-container {
@@ -282,7 +286,6 @@ onMounted(async () => {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: all 0.3s ease; transition: all 0.3s ease;
z-index: 1001; /* 提高z-index,确保在小组件之上 */
} }
.mobile-nav-menu.active { .mobile-nav-menu.active {
+5 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="artwork-gallery"> <div class="artwork-gallery">
<div class="main-image"> <div v-if="artwork.type !== 'ugoira'" class="main-image">
<img :src="getImageUrl(currentImageUrl)" :alt="artwork.title" @load="imageLoaded = true" <img :src="getImageUrl(currentImageUrl)" :alt="artwork.title" @load="imageLoaded = true"
@error="imageError = true" :class="{ loaded: imageLoaded, error: imageError }" crossorigin="anonymous" /> @error="imageError = true" :class="{ loaded: imageLoaded, error: imageError }" crossorigin="anonymous" />
<div v-if="!imageLoaded && !imageError" class="image-placeholder"> <div v-if="!imageLoaded && !imageError" class="image-placeholder">
@@ -9,12 +9,14 @@
<div v-if="imageError" class="image-error"> <div v-if="imageError" class="image-error">
<span>图片加载失败</span> <span>图片加载失败</span>
</div> </div>
<!-- 页面切换时的遮罩层 -->
<div v-if="loading" class="image-overlay"> <div v-if="loading" class="image-overlay">
<LoadingSpinner text="切换中..." /> <LoadingSpinner text="切换中..." />
</div> </div>
</div> </div>
<!-- Ugoira动图播放器 -->
<UgoiraPlayer v-else :artwork="artwork" />
<!-- 多页作品缩略图 --> <!-- 多页作品缩略图 -->
<div v-if="artwork.page_count > 1" class="thumbnails"> <div v-if="artwork.page_count > 1" class="thumbnails">
<button v-for="(page, index) in artwork.meta_pages" :key="index" @click="$emit('pageChange', index)" <button v-for="(page, index) in artwork.meta_pages" :key="index" @click="$emit('pageChange', index)"
@@ -40,6 +42,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { getImageProxyUrl } from '@/services/api'; import { getImageProxyUrl } from '@/services/api';
import type { Artwork } from '@/types'; import type { Artwork } from '@/types';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'; import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import UgoiraPlayer from '@/components/artwork/UgoiraPlayer.vue';
interface Props { interface Props {
artwork: Artwork; artwork: Artwork;
+18 -8
View File
@@ -517,6 +517,7 @@ const handleCaptionToggleChange = (event: Event) => {
padding: var(--spacing-lg); padding: var(--spacing-lg);
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
justify-content: center;
} }
.nav-btn { .nav-btn {
@@ -723,6 +724,7 @@ input:checked+.slider:before {
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
padding: var(--spacing-lg); padding: var(--spacing-lg);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-xl); margin-bottom: var(--spacing-xl);
} }
@@ -738,7 +740,6 @@ input:checked+.slider:before {
margin: 0 auto; margin: 0 auto;
} }
/* 移动端导航优化 */
.artwork-navigation { .artwork-navigation {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -746,14 +747,19 @@ input:checked+.slider:before {
right: 0; right: 0;
background: var(--color-bg-primary); background: var(--color-bg-primary);
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-radius: var(--radius-xl);
padding: var(--spacing-md); padding: var(--spacing-lg);
margin: var(--spacing-xl) calc(-1 * var(--spacing-lg)) 0; margin: var(--spacing-xl) 0 0 0;
display: grid; display: flex;
grid-template-columns: auto 1fr auto; justify-content: space-between;
gap: var(--spacing-sm); align-items: center;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); gap: var(--spacing-md);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.15);
z-index: 1001; z-index: 1001;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
flex-wrap: nowrap;
min-height: 56px; /* 确保最小高度以适应按钮 */
} }
.nav-back { .nav-back {
@@ -763,6 +769,7 @@ input:checked+.slider:before {
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg-secondary); background: var(--color-bg-secondary);
border-color: var(--color-border); border-color: var(--color-border);
flex-shrink: 0;
} }
.nav-back span { .nav-back span {
@@ -776,6 +783,9 @@ input:checked+.slider:before {
font-weight: 600; font-weight: 600;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
height: 44px; height: 44px;
flex: 1;
flex-shrink: 1;
min-width: 100px; /* 确保按钮最小宽度 */
} }
.nav-prev { .nav-prev {
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="ugoira-player">
<div class="player-stage">
<img v-if="currentFrameUrl" :src="currentFrameUrl" class="stage-image" crossorigin="anonymous" />
<div v-else class="stage-placeholder">
<LoadingSpinner text="动图加载中..." />
</div>
<div v-if="error" class="stage-error">{{ error }}</div>
</div>
<div class="player-controls">
<button class="btn btn-primary btn-small" @click="togglePlay" :disabled="loading || !!error">
{{ playing ? '暂停' : '播放' }}
</button>
<span class="status-text" v-if="loading">预加载帧 {{ loadedCount }}/{{ frames.length }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import JSZip from 'jszip';
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
import artworkService from '@/services/artwork';
import { getPximgFileProxyUrl } from '@/services/api';
import type { Artwork } from '@/types';
interface Props {
artwork: Artwork;
}
const props = defineProps<Props>();
const loading = ref(true);
const error = ref<string | null>(null);
const frames = ref<{ file: string; delay: number }[]>([]);
const frameUrls = ref<string[]>([]);
const currentFrameIndex = ref(0);
const playing = ref(true);
let timer: number | null = null;
const loadedCount = ref(0);
const currentFrameUrl = ref<string>('');
const clearTimer = () => {
if (timer) {
window.clearTimeout(timer);
timer = null;
}
};
const cleanupUrls = () => {
frameUrls.value.forEach((url) => URL.revokeObjectURL(url));
frameUrls.value = [];
};
const scheduleNextFrame = () => {
clearTimer();
if (!playing.value || frames.value.length === 0) return;
const delay = frames.value[currentFrameIndex.value]?.delay || 60;
timer = window.setTimeout(() => {
currentFrameIndex.value = (currentFrameIndex.value + 1) % frames.value.length;
currentFrameUrl.value = frameUrls.value[currentFrameIndex.value] || '';
scheduleNextFrame();
}, delay);
};
const togglePlay = () => {
playing.value = !playing.value;
if (playing.value) scheduleNextFrame();
else clearTimer();
};
const loadUgoira = async () => {
try {
loading.value = true;
error.value = null;
//
const metaResp = await artworkService.getUgoiraMeta(props.artwork.id);
if (!metaResp.success || !metaResp.data) throw new Error(metaResp.error || '获取ugoira元数据失败');
frames.value = metaResp.data.frames || [];
// 使zipmedium
const zipUrl = metaResp.data.zip_urls.original || metaResp.data.zip_urls.medium || '';
if (!zipUrl) throw new Error('缺少Ugoira ZIP地址');
const proxied = getPximgFileProxyUrl(zipUrl);
// ZIP
const resp = await fetch(proxied);
if (!resp.ok) throw new Error(`下载ZIP失败: ${resp.status}`);
const buf = await resp.arrayBuffer();
const zip = await JSZip.loadAsync(buf);
//
const orderedFrames = frames.value.slice().sort((a, b) => a.file.localeCompare(b.file));
for (const fr of orderedFrames) {
const fileEntry = zip.file(fr.file);
if (!fileEntry) continue;
const blob = await fileEntry.async('blob');
const url = URL.createObjectURL(blob);
frameUrls.value.push(url);
loadedCount.value = frameUrls.value.length;
}
if (frameUrls.value.length === 0) throw new Error('ZIP中未找到帧图片');
currentFrameIndex.value = 0;
currentFrameUrl.value = frameUrls.value[0];
loading.value = false;
playing.value = true;
scheduleNextFrame();
} catch (e: any) {
error.value = e?.message || '加载ugoira失败';
loading.value = false;
playing.value = false;
clearTimer();
}
};
onMounted(() => {
loadUgoira();
});
onUnmounted(() => {
clearTimer();
cleanupUrls();
});
// artwork
watch(() => props.artwork.id, () => {
clearTimer();
cleanupUrls();
loadUgoira();
});
</script>
<style scoped>
.ugoira-player {
background: var(--color-bg-primary);
border-radius: var(--radius-xl);
overflow: hidden;
}
.player-stage {
position: relative;
aspect-ratio: 1;
background: var(--color-bg-tertiary);
}
.stage-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.stage-placeholder,
.stage-error {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.player-controls {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.status-text {
color: var(--color-text-secondary);
font-size: 12px;
}
</style>
+2 -2
View File
@@ -494,10 +494,10 @@ const rebuildRegistry = async () => {
// //
const startProgressPolling = () => { const startProgressPolling = () => {
if (progressPollingInterval.value) { if (progressPollingInterval.value) {
clearInterval(progressPollingInterval.value); window.clearInterval(progressPollingInterval.value);
} }
progressPollingInterval.value = setInterval(async () => { progressPollingInterval.value = window.setInterval(async () => {
if (!rebuildTaskId.value) return; if (!rebuildTaskId.value) return;
try { try {
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch, ref } from 'vue'
import { useDownloadStore } from '@/stores/download'
const downloadStore = useDownloadStore()
const baseTitle = ref(document.title)
const wasDownloadingWhenHidden = ref(false)
const animationTimer = ref<number | null>(null)
let marqueeIndex = 0
const clearAnimation = () => {
if (animationTimer.value) {
clearInterval(animationTimer.value)
animationTimer.value = null
}
}
const startDownloadingAnimation = () => {
//
clearAnimation()
const text = ' 作品下载中... '
marqueeIndex = 0
animationTimer.value = window.setInterval(() => {
const rotated = text.slice(marqueeIndex) + text.slice(0, marqueeIndex)
document.title = rotated
marqueeIndex = (marqueeIndex + 1) % text.length
}, 250)
}
const startCompletedAnimation = () => {
//
clearAnimation()
const frames = ['下载完成', '下载完成 ✓']
let idx = 0
animationTimer.value = window.setInterval(() => {
document.title = frames[idx]
idx = (idx + 1) % frames.length
}, 800)
}
const updateTitleForHidden = () => {
const len = downloadStore.downloadingTasks.length
if (len > 0) {
startDownloadingAnimation()
wasDownloadingWhenHidden.value = true
} else {
if (wasDownloadingWhenHidden.value) {
startCompletedAnimation()
} else {
clearAnimation()
document.title = baseTitle.value
}
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
updateTitleForHidden()
} else {
document.title = baseTitle.value
wasDownloadingWhenHidden.value = false
clearAnimation()
}
}
onMounted(() => {
baseTitle.value = document.title
document.addEventListener('visibilitychange', handleVisibilityChange)
watch(
() => downloadStore.downloadingTasks.length,
() => {
if (document.visibilityState === 'hidden') {
updateTitleForHidden()
}
},
{ immediate: false }
)
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
clearAnimation()
})
</script>
<template>
<!-- 纯逻辑组件不渲染任何内容 -->
</template>
+1 -6
View File
@@ -63,7 +63,7 @@
<div class="instruction-step"> <div class="instruction-step">
<span class="step-number">1</span> <span class="step-number">1</span>
<span class="step-text">下载新版本的 <code>pixiv-manager-portable.rar</code> <span class="step-text">下载新版本的 <code>pixiv-manager-portable.rar</code>
文件</span> 文件如果是linux下载 <code>pixiv-manager-portable-linux.zip</code></span>
</div> </div>
<div class="instruction-step"> <div class="instruction-step">
<span class="step-number">2</span> <span class="step-number">2</span>
@@ -71,11 +71,6 @@
</div> </div>
<div class="instruction-step"> <div class="instruction-step">
<span class="step-number">3</span> <span class="step-number">3</span>
<span class="step-text"> <strong>重要</strong>重新检查 <code>start.bat</code>
中的代理端口和启动端口配置</span>
</div>
<div class="instruction-step">
<span class="step-number">4</span>
<span class="step-text">重新启动程序即可</span> <span class="step-text">重新启动程序即可</span>
</div> </div>
</div> </div>
+3 -3
View File
@@ -147,10 +147,10 @@ const filteredAndSortedItems = computed(() => {
otherItems = filteredItems; otherItems = filteredItems;
} }
// //
otherItems.sort((a, b) => { otherItems.sort((a, b) => {
const dateA = new Date(a.createdAt).getTime(); const dateA = new Date(a.updatedAt || a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime(); const dateB = new Date(b.updatedAt || b.createdAt).getTime();
return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB; return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB;
}); });
@@ -33,7 +33,7 @@
<div class="form-group"> <div class="form-group">
<label>URL或路由路径</label> <label>URL或路由路径</label>
<input :value="url" @input="$emit('update:url', ($event.target as HTMLInputElement).value)" type="text" <input :value="url" @input="$emit('update:url', ($event.target as HTMLInputElement).value)" type="text"
class="form-input" placeholder="例如: /artist/12345?page=2 或 http://localhost:3001/artwork/98765" class="form-input" placeholder="例如: /artist/12345?page=2 或 http://localhost:3000/artwork/98765"
@keyup.enter="handleSave"> @keyup.enter="handleSave">
<small class="form-help"> <small class="form-help">
支持完整URL或相对路径/artist/12345/search?keyword=插画 支持完整URL或相对路径/artist/12345/search?keyword=插画
@@ -86,8 +86,8 @@
<textarea :value="batchUrls" <textarea :value="batchUrls"
@input="$emit('update:batchUrls', ($event.target as HTMLTextAreaElement).value)" class="form-textarea" @input="$emit('update:batchUrls', ($event.target as HTMLTextAreaElement).value)" class="form-textarea"
rows="8" placeholder="请输入多个URL每行一个例如 rows="8" placeholder="请输入多个URL每行一个例如
http://localhost:3001/artist/72143697 http://localhost:3000/artist/72143697
http://localhost:3001/artist/103047332 http://localhost:3000/artist/103047332
/artist/113088709 /artist/113088709
/artwork/98765?page=2 /artwork/98765?page=2
@@ -49,6 +49,27 @@
@update:search-query="$emit('update:searchQuery', $event)" @clear-search="$emit('clearSearch')" @update:search-query="$emit('update:searchQuery', $event)" @clear-search="$emit('clearSearch')"
@toggle-sort="$emit('toggleSort')" /> @toggle-sort="$emit('toggleSort')" />
<!-- 存储模式设置 -->
<div class="storage-config">
<div class="storage-row">
<span class="storage-label">存储模式</span>
<select v-model="selectedStorageMode" class="storage-select">
<option value="json">JSON文件存储</option>
<option value="database">MySQL数据库存储</option>
</select>
<span class="config-indicator" :class="{ unsaved: hasStorageModeChanges, saved: !hasStorageModeChanges }">
<SvgIcon :name="hasStorageModeChanges ? 'warning' : 'check'" class="indicator-icon" />
{{ hasStorageModeChanges ? '配置已修改' : `当前模式: ${storageModeText}` }}
</span>
<button class="save-config-btn" :disabled="configLoading || !hasStorageModeChanges" @click="saveStorageMode">
保存
</button>
<button class="reset-config-btn" :disabled="configLoading || !hasStorageModeChanges" @click="resetStorageMode">
重置
</button>
</div>
</div>
<!-- 内容区域 --> <!-- 内容区域 -->
<WatchlistContent :loading="loading" :error="error" :items="items" :filtered-items="filteredItems" <WatchlistContent :loading="loading" :error="error" :items="items" :filtered-items="filteredItems"
:search-query="searchQuery" :is-current-url="isCurrentUrl" :is-duplicate-author="isDuplicateAuthor" :search-query="searchQuery" :is-current-url="isCurrentUrl" :is-duplicate-author="isDuplicateAuthor"
@@ -58,7 +79,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useWatchlistStore } from '@/stores/watchlist'; import { useWatchlistStore } from '@/stores/watchlist';
import WatchlistControls from './WatchlistControls.vue'; import WatchlistControls from './WatchlistControls.vue';
import WatchlistContent from './WatchlistContent.vue'; import WatchlistContent from './WatchlistContent.vue';
@@ -143,6 +164,31 @@ const handleFileImport = async (event: Event) => {
target.value = ''; target.value = '';
} }
}; };
//
const selectedStorageMode = ref<'json' | 'database'>('json');
const configLoading = computed(() => watchlistStore.configLoading);
const hasStorageModeChanges = computed(() => selectedStorageMode.value !== watchlistStore.storageMode);
const storageModeText = computed(() => watchlistStore.storageMode === 'database' ? 'MySQL数据库存储' : 'JSON文件存储');
const saveStorageMode = async () => {
if (configLoading.value || !hasStorageModeChanges.value) return;
const ok = await watchlistStore.saveStorageModeConfig(selectedStorageMode.value);
if (!ok) return;
//
selectedStorageMode.value = watchlistStore.storageMode;
await watchlistStore.fetchConfig();
await watchlistStore.fetchItems();
};
const resetStorageMode = () => {
selectedStorageMode.value = watchlistStore.storageMode;
};
onMounted(async () => {
await watchlistStore.fetchConfig();
selectedStorageMode.value = watchlistStore.storageMode;
});
</script> </script>
<style scoped> <style scoped>
@@ -150,7 +196,7 @@ const handleFileImport = async (event: Event) => {
position: absolute; position: absolute;
top: 50px; top: 50px;
left: 0; left: 0;
width: 400px; width: 570px;
max-height: 600px; max-height: 600px;
background: var(--color-bg-primary); background: var(--color-bg-primary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -198,6 +244,65 @@ const handleFileImport = async (event: Event) => {
gap: 12px; gap: 12px;
} }
.storage-config {
display: flex;
align-items: center;
padding: 10px 16px;
gap: 8px;
border-bottom: 1px solid var(--color-border);
}
.storage-row {
display: flex;
align-items: center;
gap: 10px;
}
.storage-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.storage-select {
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.config-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.config-indicator.saved {
color: var(--color-success, #16a34a);
}
.config-indicator.unsaved {
color: var(--color-warning, #ca8a04);
}
.indicator-icon {
width: 14px;
height: 14px;
}
.save-config-btn,
.reset-config-btn {
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
cursor: pointer;
}
.save-config-btn:disabled,
.reset-config-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.item-count-text { .item-count-text {
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
@@ -296,7 +401,8 @@ const handleFileImport = async (event: Event) => {
@media (max-width: 768px) { @media (max-width: 768px) {
.watchlist-panel { .watchlist-panel {
position: fixed; position: fixed;
top: 0; /* 向下偏移导航栏高度,避免与顶部导航重叠 */
top: 3.5rem;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@@ -320,6 +426,36 @@ const handleFileImport = async (event: Event) => {
.watchlist-header { .watchlist-header {
border-radius: 0; border-radius: 0;
/* 让标题和操作区在小屏下更好适配 */
display: flex;
justify-content: space-between;
align-items: center;
}
/* 标题可收缩,给右侧操作区更多空间 */
.watchlist-header h3 {
flex: 1;
min-width: 0;
}
/* 移动端隐藏容易占空间的元素,避免溢出 */
.item-count-text,
.import-mode-selector {
display: none;
}
/* 压缩按钮尺寸与间距,保证关闭按钮可见 */
.header-actions {
gap: 8px;
flex-wrap: nowrap;
}
.add-btn,
.close-btn,
.export-btn,
.import-btn {
width: 26px;
height: 26px;
} }
} }
@@ -1,20 +1,76 @@
<template> <template>
<div class="artists-view"> <div class="artists-view">
<div class="artists-grid"> <!-- 空状态 -->
<div v-for="artist in artists" :key="artist.name" class="artist-card" <div v-if="artists.length === 0" class="empty-state">
@click="$emit('select-artist', artist.name)"> <div class="empty-icon">👤</div>
<h3>暂无作者</h3>
<p>这里还没有任何作者信息</p>
</div>
<!-- 作者网格 -->
<div v-else class="artists-grid">
<div v-for="artist in artists" :key="artist.name" class="artist-card">
<!-- 卡片内容 -->
<div class="card-content" @click="$emit('view-artist-works', artist.name)">
<!-- 头像和背景 -->
<div class="artist-header">
<div class="artist-avatar"> <div class="artist-avatar">
<span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span> <span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span>
</div> </div>
<div class="artist-info"> <div class="artist-badge">
<h4>{{ artist.name }}</h4> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<p>{{ artist.artworkCount }} 个作品</p> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<p>{{ formatFileSize(artist.totalSize) }}</p> <circle cx="12" cy="7" r="4"/>
</svg>
</div> </div>
</div>
<!-- 作者信息 -->
<div class="artist-details">
<h3 class="artist-name" :title="artist.name">{{ artist.name }}</h3>
<!-- 统计信息 -->
<div class="artist-stats">
<div class="stat-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<span class="stat-value">{{ artist.artworkCount }}</span>
<span class="stat-label">作品</span>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="stat-value">{{ formatFileSize(artist.totalSize) }}</span>
<span class="stat-label">大小</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="artist-actions"> <div class="artist-actions">
<button @click.stop="$emit('view-artist-works', artist.name)" class="action-btn"> <button @click.stop="$emit('view-artist-works', artist.name)" class="action-btn primary">
查看作品 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span>查看作品</span>
</button> </button>
<button @click.stop="$emit('select-artist', artist.name)" class="action-btn secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -39,86 +95,293 @@ defineEmits<Emits>()
</script> </script>
<style scoped> <style scoped>
.artists-view {
width: 100%;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #6b7280;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 0.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
/* 作者网格 */
.artists-grid { .artists-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem; gap: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* 作者卡片 */
.artist-card { .artist-card {
background: white; background: white;
border: 1px solid #e5e7eb; border-radius: 0.75rem;
border-radius: 0.5rem; overflow: hidden;
padding: 1.5rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 1rem;
} }
.artist-card:hover { .artist-card:hover {
border-color: #3b82f6; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transform: translateY(-4px);
}
.card-content {
display: flex;
flex-direction: column;
height: 100%;
cursor: pointer;
}
/* 作者头部 */
.artist-header {
position: relative;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.artist-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><defs><pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"><path d="M 20 0 L 0 0 0 20" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
} }
.artist-avatar { .artist-avatar {
width: 60px; position: relative;
height: 60px; width: 80px;
background: #3b82f6; height: 80px;
background: white;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: white; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 1.5rem; z-index: 1;
font-weight: bold;
} }
.artist-info { .avatar-text {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.artist-badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 2rem;
height: 2rem;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
z-index: 1;
}
.artist-badge svg {
width: 1.25rem;
height: 1.25rem;
stroke-width: 2;
}
/* 作者详情 */
.artist-details {
padding: 1.25rem;
flex: 1; flex: 1;
} }
.artist-info h4 { .artist-name {
margin: 0 0 0.5rem 0; margin: 0 0 1rem 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 统计信息 */
.artist-stats {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
}
.stat-item svg {
width: 1.25rem;
height: 1.25rem;
color: #6b7280;
stroke-width: 2;
}
.stat-value {
font-size: 1rem;
font-weight: 600;
color: #1f2937; color: #1f2937;
} }
.artist-info p { .stat-label {
margin: 0.25rem 0; font-size: 0.75rem;
color: #6b7280; color: #6b7280;
font-size: 0.875rem;
} }
.stat-divider {
width: 1px;
height: 2.5rem;
background: #e5e7eb;
}
/* 操作按钮 */
.artist-actions { .artist-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
padding: 1rem 1.25rem 1.25rem;
} }
.action-btn { .action-btn {
padding: 0.5rem 1rem; display: flex;
border: 1px solid #d1d5db; align-items: center;
background: white; justify-content: center;
border-radius: 0.375rem; gap: 0.5rem;
cursor: pointer; padding: 0.75rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.action-btn:hover { .action-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.action-btn.primary {
flex: 1;
background: #3b82f6;
color: white;
}
.action-btn.primary:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}
.action-btn.secondary {
background: #f3f4f6; background: #f3f4f6;
border-color: #3b82f6; color: #4b5563;
padding: 0.75rem;
}
.action-btn.secondary:hover {
background: #e5e7eb;
color: #1f2937;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.artists-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.artists-grid { .artists-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
} }
.artist-card { .artist-header {
flex-direction: column; height: 100px;
text-align: center; }
.artist-avatar {
width: 64px;
height: 64px;
}
.avatar-text {
font-size: 1.5rem;
}
.artist-details {
padding: 1rem;
}
.artist-name {
font-size: 1rem;
}
.action-btn.primary span {
display: none;
}
.action-btn.primary {
justify-content: center;
}
}
@media (max-width: 480px) {
.artists-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 0.875rem;
}
.stat-label {
font-size: 0.6875rem;
} }
} }
</style> </style>
@@ -1,35 +1,106 @@
<template> <template>
<div class="artworks-view"> <div class="artworks-view">
<div class="artworks-grid"> <!-- 无作品提示 -->
<div v-for="artwork in artworks" :key="artwork.id" class="artwork-card" @click="$emit('view-artwork', artwork)"> <div v-if="artworks.length === 0" class="empty-state">
<div class="artwork-preview" v-if="artwork.files.length > 0"> <div class="empty-icon">🎨</div>
<img :src="getPreviewUrl(artwork.files[0].path)" :alt="artwork.title" class="preview-image" <h3>暂无作品</h3>
@click.stop="$emit('open-image-viewer', artwork, 0)" /> <p>这里还没有任何作品快去下载一些吧</p>
<div class="artwork-overlay"> </div>
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="view-btn-overlay">
👁 查看大图 <!-- 作品网格 -->
<div v-else class="artworks-grid">
<div v-for="artwork in artworks" :key="artwork.id" class="artwork-card">
<!-- 图片容器 - 点击预览大图 -->
<div class="artwork-image-wrapper" @click="$emit('open-image-viewer', artwork, 0)">
<div v-if="artwork.files.length > 0" class="artwork-image-container">
<img
:src="getPreviewUrl(artwork.files[0].path)"
:alt="artwork.title"
class="artwork-image"
loading="lazy"
/>
<!-- 多图标识 -->
<div v-if="artwork.files.length > 1" class="multi-image-badge">
<svg class="badge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span>{{ artwork.files.length }}</span>
</div>
<!-- 悬浮遮罩 -->
<div class="artwork-hover-overlay">
<button class="quick-view-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span>查看大图</span>
</button> </button>
</div> </div>
</div> </div>
<div class="artwork-info"> <div v-else class="artwork-no-image">
<h4>{{ artwork.title }}</h4> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<p class="artist-name" @click.stop="$emit('select-artist', artwork.artist)"> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
👤 {{ artwork.artist }} <circle cx="8.5" cy="8.5" r="1.5"/>
</p> <polyline points="21 15 16 10 5 21"/>
<p>{{ formatFileSize(artwork.size) }}</p> </svg>
<p class="file-count">{{ artwork.files.length }} 个文件</p> <span>暂无预览</span>
</div> </div>
</div>
<!-- 作品信息 -->
<div class="artwork-content">
<h3 class="artwork-title" :title="artwork.title">{{ artwork.title }}</h3>
<!-- 作者信息 -->
<div class="artwork-artist" @click.stop="$emit('select-artist', artwork.artist)">
<div class="artist-avatar">
<span>{{ artwork.artist.charAt(0).toUpperCase() }}</span>
</div>
<span class="artist-name">{{ artwork.artist }}</span>
</div>
<!-- 元数据 -->
<div class="artwork-meta">
<div class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span>{{ formatFileSize(artwork.size) }}</span>
</div>
<div class="meta-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/>
</svg>
<span>{{ artwork.files.length }} 个文件</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="artwork-actions"> <div class="artwork-actions">
<button @click.stop="$emit('view-artwork', artwork)" class="action-btn"> <button @click.stop="$emit('view-artwork', artwork)" class="action-btn action-btn-primary">
详情 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
查看详情
</button> </button>
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="action-btn"> <button @click.stop="$emit('open-image-viewer', artwork, 0)" class="action-btn action-btn-secondary">
预览 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -51,128 +122,372 @@ defineEmits<Emits>()
</script> </script>
<style scoped> <style scoped>
.artworks-view {
width: 100%;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #6b7280;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 0.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
/* 作品网格 - 响应式布局 */
.artworks-grid { .artworks-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem; gap: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* 作品卡片 */
.artwork-card { .artwork-card {
background: white; background: white;
border: 1px solid #e5e7eb; border-radius: 0.75rem;
border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
cursor: pointer; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
} }
.artwork-card:hover { .artwork-card:hover {
border-color: #3b82f6; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transform: translateY(-4px);
} }
.artwork-preview { /* 图片容器 */
.artwork-image-wrapper {
position: relative; position: relative;
height: 200px; width: 100%;
overflow: hidden; cursor: pointer;
background: #f9fafb;
} }
.preview-image { .artwork-image-container {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.artwork-image {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
transition: transform 0.2s; transition: transform 0.3s ease;
background: white;
} }
.artwork-card:hover .preview-image { .artwork-card:hover .artwork-image {
transform: scale(1.05); transform: scale(1.02);
} }
.artwork-overlay { /* 多图标识 */
.multi-image-badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
color: white;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
z-index: 2;
}
.badge-icon {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
/* 悬浮遮罩 */
.artwork-hover-overlay {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.3s ease;
z-index: 1;
} }
.artwork-card:hover .artwork-overlay { .artwork-image-wrapper:hover .artwork-hover-overlay {
opacity: 1; opacity: 1;
} }
.view-btn-overlay { .quick-view-btn {
background: white; background: white;
color: #1f2937; color: #1f2937;
border: none; border: none;
padding: 0.5rem 1rem; padding: 0.75rem 1.5rem;
border-radius: 0.375rem; border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; transition: all 0.2s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.artwork-info { .quick-view-btn:hover {
padding: 1rem; background: #3b82f6;
color: white;
transform: scale(1.05);
} }
.artwork-info h4 { .quick-view-btn svg {
margin: 0 0 0.5rem 0; width: 1.25rem;
height: 1.25rem;
stroke-width: 2;
}
/* 无图片状态 */
.artwork-no-image {
width: 100%;
aspect-ratio: 4 / 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f3f4f6;
color: #9ca3af;
gap: 0.75rem;
}
.artwork-no-image svg {
width: 3rem;
height: 3rem;
stroke-width: 1.5;
}
.artwork-no-image span {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
}
/* 作品内容 */
.artwork-content {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
}
.artwork-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1f2937; color: #1f2937;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-clamp: 2;
display: box;
box-orient: vertical;
}
/* 作者信息 */
.artwork-artist {
display: flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
transition: all 0.2s;
padding: 0.5rem;
margin: -0.5rem;
border-radius: 0.5rem;
}
.artwork-artist:hover {
background: #f3f4f6;
}
.artist-avatar {
width: 2rem;
height: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
} }
.artist-name { .artist-name {
color: #3b82f6 !important; color: #4b5563;
cursor: pointer; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s;
} }
.artist-name:hover { .artwork-artist:hover .artist-name {
text-decoration: underline; color: #3b82f6;
} }
.artwork-info p { /* 元数据 */
margin: 0.25rem 0; .artwork-meta {
font-size: 0.75rem; display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
color: #6b7280; color: #6b7280;
font-size: 0.8125rem;
} }
.file-count { .meta-item svg {
font-weight: 500; width: 1rem;
color: #6b5563 !important; height: 1rem;
stroke-width: 2;
flex-shrink: 0;
} }
/* 操作按钮 */
.artwork-actions { .artwork-actions {
padding: 0 1rem 1rem;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid #f3f4f6;
} }
.action-btn { .action-btn {
flex: 1; flex: 1;
padding: 0.5rem; padding: 0.625rem 1rem;
border: 1px solid #d1d5db; border: none;
background: white; border-radius: 0.5rem;
border-radius: 0.375rem; font-size: 0.875rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s; transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
} }
.action-btn:hover { .action-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.action-btn-primary {
background: #3b82f6;
color: white;
}
.action-btn-primary:hover {
background: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}
.action-btn-secondary {
background: #f3f4f6; background: #f3f4f6;
border-color: #3b82f6; color: #4b5563;
}
.action-btn-secondary:hover {
background: #e5e7eb;
color: #1f2937;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.artworks-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.artworks-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.artwork-content {
padding: 1rem;
}
.artwork-title {
font-size: 0.9375rem;
}
.action-btn span {
display: none;
}
.action-btn svg {
margin: 0;
}
}
@media (max-width: 480px) {
.artworks-grid { .artworks-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.multi-image-badge {
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.badge-icon {
width: 0.875rem;
height: 0.875rem;
} }
} }
</style> </style>
@@ -1,44 +1,113 @@
<template> <template>
<div class="gallery-view"> <div class="gallery-view">
<div class="gallery-controls"> <!-- 空状态 -->
<div class="zoom-controls"> <div v-if="artworks.length === 0" class="empty-state">
<button @click="zoomOut" class="zoom-btn" :disabled="zoomLevel <= 0.5"> <div class="empty-icon">🖼</div>
🔍- <h3>暂无作品</h3>
</button> <p>这里还没有任何作品</p>
<span class="zoom-level">{{ Math.round(zoomLevel * 100) }}%</span>
<button @click="zoomIn" class="zoom-btn" :disabled="zoomLevel >= 3">
🔍+
</button>
</div> </div>
<div class="view-controls">
<button @click="setGridSize('small')" :class="['size-btn', { active: gridSize === 'small' }]"> <template v-else>
<!-- 画廊控制栏 -->
<div class="gallery-controls">
<div class="controls-section">
<label class="control-label">网格大小</label>
<div class="control-buttons">
<button @click="setGridSize('small')" :class="['control-btn', { active: gridSize === 'small' }]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<span></span>
</button> </button>
<button @click="setGridSize('medium')" :class="['size-btn', { active: gridSize === 'medium' }]"> <button @click="setGridSize('medium')" :class="['control-btn', { active: gridSize === 'medium' }]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="8" height="8"/>
<rect x="3" y="13" width="8" height="8"/>
<rect x="13" y="3" width="8" height="8"/>
<rect x="13" y="13" width="8" height="8"/>
</svg>
<span></span>
</button> </button>
<button @click="setGridSize('large')" :class="['size-btn', { active: gridSize === 'large' }]"> <button @click="setGridSize('large')" :class="['control-btn', { active: gridSize === 'large' }]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="7"/>
<rect x="3" y="14" width="18" height="7"/>
</svg>
<span></span>
</button> </button>
</div> </div>
</div> </div>
<div class="controls-section">
<label class="control-label">图片适应</label>
<div class="control-buttons">
<button @click="setFitMode('contain')" :class="['control-btn', { active: fitMode === 'contain' }]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<rect x="7" y="7" width="10" height="10"/>
</svg>
<span>完整</span>
</button>
<button @click="setFitMode('cover')" :class="['control-btn', { active: fitMode === 'cover' }]">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<rect x="2" y="2" width="20" height="20"/>
</svg>
<span>填充</span>
</button>
</div>
</div>
</div>
<!-- 画廊网格 -->
<div class="gallery-grid" :class="`grid-${gridSize}`"> <div class="gallery-grid" :class="`grid-${gridSize}`">
<div v-for="artwork in artworks" :key="artwork.id" class="gallery-item" <div v-for="artwork in artworks" :key="artwork.id" class="gallery-item">
@click="$emit('open-image-viewer', artwork, 0)"> <div class="gallery-card" @click="$emit('open-image-viewer', artwork, 0)">
<div class="gallery-image-container"> <!-- 图片容器 -->
<img :src="getPreviewUrl(artwork.files[0].path)" :alt="artwork.title" class="gallery-image" <div class="image-container" :class="`fit-${fitMode}`">
@load="onImageLoad" @error="onImageError" /> <img
<div class="gallery-overlay"> :src="getPreviewUrl(artwork.files[0].path)"
<div class="overlay-content"> :alt="artwork.title"
<h4>{{ artwork.title }}</h4> class="gallery-image"
<p>{{ artwork.artist }}</p> loading="lazy"
<div class="overlay-actions"> @load="onImageLoad"
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="overlay-btn"> @error="onImageError"
👁 查看大图 />
<!-- 多图徽章 -->
<div v-if="artwork.files.length > 1" class="image-count-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span>{{ artwork.files.length }}</span>
</div>
<!-- 悬浮信息遮罩 -->
<div class="image-overlay">
<div class="overlay-top">
<h4 class="overlay-title">{{ artwork.title }}</h4>
<p class="overlay-artist">{{ artwork.artist }}</p>
</div>
<div class="overlay-bottom">
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="overlay-action-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
查看大图
</button> </button>
<button @click.stop="$emit('view-artwork', artwork)" class="overlay-btn"> <button @click.stop="$emit('view-artwork', artwork)" class="overlay-action-btn secondary">
📋 详情 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
详情
</button> </button>
</div> </div>
</div> </div>
@@ -46,6 +115,7 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
@@ -67,239 +137,432 @@ defineProps<Props>()
defineEmits<Emits>() defineEmits<Emits>()
// //
const zoomLevel = ref(1) const gridSize = ref<'small' | 'medium' | 'large'>('medium')
const gridSize = ref('medium') const fitMode = ref<'contain' | 'cover'>('contain')
// //
const zoomIn = () => {
zoomLevel.value = Math.min(zoomLevel.value + 0.1, 3)
}
const zoomOut = () => {
zoomLevel.value = Math.max(zoomLevel.value - 0.1, 0.5)
}
//
const setGridSize = (size: 'small' | 'medium' | 'large') => { const setGridSize = (size: 'small' | 'medium' | 'large') => {
gridSize.value = size gridSize.value = size
} }
// //
const onImageLoad = () => { const setFitMode = (mode: 'contain' | 'cover') => {
// fitMode.value = mode
} }
//
const onImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
img.classList.add('loaded')
}
//
const onImageError = (event: Event) => { const onImageError = (event: Event) => {
console.error('图片加载失败:', (event.target as HTMLImageElement).src) const img = event.target as HTMLImageElement
// console.error('图片加载失败:', img.src)
img.classList.add('error')
//
img.style.display = 'none'
const container = img.parentElement
if (container) {
container.classList.add('has-error')
}
} }
</script> </script>
<style scoped> <style scoped>
.gallery-view { .gallery-view {
position: relative; width: 100%;
margin-top: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
} }
/* 空状态 */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #6b7280;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 0.5rem 0;
color: #1f2937;
font-size: 1.5rem;
}
.empty-state p {
margin: 0;
font-size: 1rem;
}
/* 画廊控制栏 */
.gallery-controls { .gallery-controls {
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
align-items: center; gap: 1.5rem;
margin-bottom: 1rem; margin-bottom: 1.5rem;
padding: 0.5rem 1rem; padding: 1.25rem;
background: white; background: white;
border-radius: 0.375rem; border-radius: 0.75rem;
border: 1px solid #e5e7eb; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.zoom-controls { .controls-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
} }
.zoom-btn { .control-label {
background: #3b82f6;
color: white;
border: none;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s;
}
.zoom-btn:hover:not(:disabled) {
background: #2563eb;
}
.zoom-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.zoom-level {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500; font-weight: 500;
color: #4b5563;
white-space: nowrap;
} }
.view-controls { .control-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
background: #f3f4f6;
padding: 0.25rem;
border-radius: 0.5rem;
} }
.size-btn { .control-btn {
background: #3b82f6; display: flex;
color: white; align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: none; border: none;
padding: 0.5rem 0.75rem; background: transparent;
color: #6b7280;
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.size-btn:hover:not(:disabled) { .control-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.control-btn:hover {
color: #1f2937;
background: #e5e7eb;
}
.control-btn.active {
background: #3b82f6;
color: white;
}
.control-btn.active:hover {
background: #2563eb; background: #2563eb;
} }
.size-btn:disabled { /* 画廊网格 */
opacity: 0.5;
cursor: not-allowed;
}
.gallery-grid { .gallery-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.grid-small .gallery-grid { .gallery-grid.grid-small {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
} }
.grid-medium .gallery-grid { .gallery-grid.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
} }
.grid-large .gallery-grid { .gallery-grid.grid-large {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
} }
/* 画廊项目 */
.gallery-item { .gallery-item {
position: relative; position: relative;
height: 250px; }
border-radius: 0.5rem;
.gallery-card {
position: relative;
background: white;
border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background: white; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.2s;
} }
.gallery-item:hover { .gallery-card:hover {
border-color: #3b82f6; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transform: translateY(-4px);
} }
.gallery-image-container { /* 图片容器 */
.image-container {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; aspect-ratio: 4 / 3;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
overflow: hidden; overflow: hidden;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
} }
.gallery-image { .image-container::before {
width: 100%; content: '';
height: 100%;
object-fit: contain;
transition: transform 0.3s ease;
transform: scale(v-bind(zoomLevel));
}
.gallery-item:hover .gallery-image {
transform: scale(v-bind(zoomLevel) * 1.05);
}
.gallery-overlay {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.7); background: white;
display: flex; z-index: 0;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
} }
.gallery-item:hover .gallery-overlay { .gallery-image {
position: relative;
width: 100%;
height: 100%;
transition: all 0.3s ease;
opacity: 0;
z-index: 1;
}
.gallery-image.loaded {
opacity: 1; opacity: 1;
} }
.overlay-content { /* 图片适应模式 */
text-align: center; .image-container.fit-contain .gallery-image {
color: white; object-fit: contain;
padding: 1rem;
} }
.overlay-content h4 { .image-container.fit-cover .gallery-image {
margin: 0 0 0.25rem 0; object-fit: cover;
}
.gallery-card:hover .gallery-image {
transform: scale(1.05);
}
/* 错误状态 */
.image-container.has-error::after {
content: '图片加载失败';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #9ca3af;
font-size: 0.875rem; font-size: 0.875rem;
text-align: center;
z-index: 2;
} }
.overlay-content p { /* 多图徽章 */
.image-count-badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
color: white;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
z-index: 3;
}
.image-count-badge svg {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
/* 悬浮遮罩 */
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0) 30%,
rgba(0, 0, 0, 0) 70%,
rgba(0, 0, 0, 0.8) 100%
);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 2;
}
.gallery-card:hover .image-overlay {
opacity: 1;
}
.overlay-top {
color: white;
}
.overlay-title {
margin: 0 0 0.25rem 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-clamp: 2;
display: box;
box-orient: vertical;
}
.overlay-artist {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.875rem;
opacity: 0.8; opacity: 0.9;
} }
.overlay-actions { .overlay-bottom {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem;
} }
.overlay-btn { .overlay-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.625rem 1rem;
background: white; background: white;
color: #1f2937; color: #1f2937;
border: none; border: none;
padding: 0.5rem 1rem; border-radius: 0.5rem;
border-radius: 0.375rem; font-size: 0.875rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s; transition: all 0.2s;
} }
.overlay-btn:hover { .overlay-action-btn svg {
background: #f3f4f6; width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.overlay-action-btn:hover {
background: #3b82f6;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.overlay-action-btn.secondary {
background: rgba(255, 255, 255, 0.9);
}
.overlay-action-btn.secondary:hover {
background: rgba(255, 255, 255, 1);
color: #3b82f6; color: #3b82f6;
} }
/* 响应式设计 */
@media (max-width: 1024px) {
.gallery-grid.grid-small {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.gallery-grid.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.gallery-grid.grid-large {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.gallery-controls { .gallery-controls {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.zoom-controls, .controls-section {
.view-controls { width: 100%;
flex-direction: column;
align-items: stretch;
}
.control-buttons {
justify-content: stretch;
}
.control-btn {
flex: 1;
justify-content: center; justify-content: center;
} }
.gallery-grid { .gallery-grid.grid-small {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.gallery-grid.grid-medium {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.gallery-grid.grid-large {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.overlay-action-btn span {
display: none;
}
.overlay-action-btn svg {
margin: 0;
}
}
@media (max-width: 480px) {
.image-count-badge {
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.image-count-badge svg {
width: 0.875rem;
height: 0.875rem;
}
.overlay-top {
display: none;
}
.image-overlay {
justify-content: flex-end;
} }
} }
</style> </style>
@@ -1,25 +1,74 @@
<template> <template>
<div class="pagination" v-if="totalPages > 1"> <div class="pagination" v-if="totalPages > 1">
<button @click="$emit('change-page', 1)" :disabled="currentPage <= 1" class="page-btn"> <!-- 首页 -->
首页 <button
</button> @click="$emit('change-page', 1)"
<button @click="$emit('change-page', currentPage - 1)" :disabled="currentPage <= 1" class="page-btn"> :disabled="currentPage <= 1"
上一页 class="page-btn nav-btn"
title="首页"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="11 17 6 12 11 7"/>
<polyline points="18 17 13 12 18 7"/>
</svg>
</button> </button>
<!-- 上一页 -->
<button
@click="$emit('change-page', currentPage - 1)"
:disabled="currentPage <= 1"
class="page-btn nav-btn"
title="上一页"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<!-- 页码 -->
<div class="page-numbers"> <div class="page-numbers">
<button v-for="page in visiblePages" :key="page" @click="$emit('change-page', page)" <button
:class="['page-btn', { active: page === currentPage }]"> v-for="page in visiblePages"
:key="page"
@click="$emit('change-page', page)"
:class="['page-btn', 'number-btn', { active: page === currentPage }]"
:title="`第 ${page} 页`"
>
{{ page }} {{ page }}
</button> </button>
</div> </div>
<button @click="$emit('change-page', currentPage + 1)" :disabled="currentPage >= totalPages" class="page-btn"> <!-- 下一页 -->
下一页 <button
@click="$emit('change-page', currentPage + 1)"
:disabled="currentPage >= totalPages"
class="page-btn nav-btn"
title="下一页"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button> </button>
<button @click="$emit('change-page', totalPages)" :disabled="currentPage >= totalPages" class="page-btn">
末页 <!-- 末页 -->
<button
@click="$emit('change-page', totalPages)"
:disabled="currentPage >= totalPages"
class="page-btn nav-btn"
title="末页"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polyline points="13 17 18 12 13 7"/>
<polyline points="6 17 11 12 6 7"/>
</svg>
</button> </button>
<!-- 页码信息 -->
<div class="page-info">
<span class="current-page">{{ currentPage }}</span>
<span class="divider">/</span>
<span class="total-pages">{{ totalPages }}</span>
</div>
</div> </div>
</template> </template>
@@ -63,49 +112,148 @@ const visiblePages = computed(() => {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 2rem; margin-top: 2rem;
padding: 1.5rem 0;
} }
.page-btn { .page-btn {
padding: 0.5rem 0.75rem; display: flex;
border: 1px solid #d1d5db; align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
border: none;
background: white; background: white;
border-radius: 0.375rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
color: #4b5563;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.page-btn:hover:not(:disabled) { .page-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.page-btn:hover:not(:disabled):not(.active) {
background: #f3f4f6; background: #f3f4f6;
border-color: #3b82f6; color: #1f2937;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
} }
.page-btn.active { .page-btn:active:not(:disabled) {
background: #3b82f6; transform: translateY(0);
color: white;
border-color: #3b82f6;
} }
.page-btn:disabled { .page-btn:disabled {
opacity: 0.5; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
box-shadow: none;
}
/* 导航按钮 */
.nav-btn {
min-width: 2.5rem;
}
/* 页码按钮 */
.number-btn {
min-width: 2.5rem;
font-weight: 600;
}
.number-btn.active {
background: #3b82f6;
color: white;
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.3);
}
.number-btn.active:hover {
background: #2563eb;
transform: translateY(-1px);
} }
.page-numbers { .page-numbers {
display: flex; display: flex;
gap: 0.25rem; gap: 0.375rem;
} }
/* 页码信息 */
.page-info {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: 0.5rem;
padding: 0.5rem 0.875rem;
background: #f9fafb;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
}
.current-page {
color: #3b82f6;
font-weight: 600;
}
.divider {
color: #d1d5db;
margin: 0 0.125rem;
}
.total-pages {
color: #9ca3af;
}
/* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.pagination { .pagination {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.375rem;
padding: 1rem 0;
}
.page-btn {
min-width: 2.25rem;
height: 2.25rem;
} }
.page-numbers { .page-numbers {
order: 3; order: -1;
width: 100%; width: 100%;
justify-content: center; justify-content: center;
margin-top: 0.5rem; margin-bottom: 0.5rem;
gap: 0.25rem;
}
.page-info {
order: -2;
width: 100%;
justify-content: center;
margin: 0 0 0.75rem 0;
}
.nav-btn {
flex: 1;
}
}
@media (max-width: 480px) {
.page-btn {
min-width: 2rem;
height: 2rem;
font-size: 0.8125rem;
}
.page-btn svg {
width: 1rem;
height: 1rem;
} }
} }
</style> </style>
@@ -1,29 +1,68 @@
<template> <template>
<div class="search-panel"> <div class="search-panel">
<div class="search-filters"> <div class="search-container">
<!-- 搜索框 -->
<div class="search-box"> <div class="search-box">
<input v-model="searchQuery" type="text" placeholder="搜索作品标题、作者名称..." class="search-input" <div class="search-icon">
@input="debounceSearch" /> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<button @click="clearSearch" class="clear-btn" v-if="searchQuery"> <circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="搜索作品标题、作者名称..."
class="search-input"
@input="debounceSearch"
@keyup.enter="emit('search', searchQuery)"
/>
<button v-if="searchQuery" @click="clearSearch" class="clear-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button> </button>
</div> </div>
<!-- 筛选控制 -->
<div class="filter-controls"> <div class="filter-controls">
<div class="filter-group">
<label class="filter-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="4" y1="21" x2="4" y2="14"/>
<line x1="4" y1="10" x2="4" y2="3"/>
<line x1="12" y1="21" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12" y2="3"/>
<line x1="20" y1="21" x2="20" y2="16"/>
<line x1="20" y1="12" x2="20" y2="3"/>
<line x1="1" y1="14" x2="7" y2="14"/>
<line x1="9" y1="8" x2="15" y2="8"/>
<line x1="17" y1="16" x2="23" y2="16"/>
</svg>
</label>
<select v-model="sortBy" @change="handleSortChange" class="filter-select"> <select v-model="sortBy" @change="handleSortChange" class="filter-select">
<option value="date">按日期排序</option> <option value="date">按日期</option>
<option value="name">按名称排序</option> <option value="name">按名称</option>
<option value="size">按大小排序</option> <option value="size">按大小</option>
</select> </select>
</div>
<div class="filter-group">
<label class="filter-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
</label>
<select v-model="filterBy" @change="handleFilterChange" class="filter-select"> <select v-model="filterBy" @change="handleFilterChange" class="filter-select">
<option value="all">全部</option> <option value="all">全部类型</option>
<option value="images">仅图片</option> <option value="images">仅图片</option>
<option value="videos">仅视频</option> <option value="videos">仅视频</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -58,8 +97,8 @@ const filterBy = ref(props.initialFilter)
// //
let searchTimeout: number let searchTimeout: number
const debounceSearch = () => { const debounceSearch = () => {
clearTimeout(searchTimeout) window.clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { searchTimeout = window.setTimeout(() => {
emit('search', searchQuery.value) emit('search', searchQuery.value)
}, 300) }, 300)
} }
@@ -85,75 +124,184 @@ watch(() => props.initialQuery, (newQuery) => {
<style scoped> <style scoped>
.search-panel { .search-panel {
margin-bottom: 2rem; margin-bottom: 1.5rem;
} }
.search-filters { .search-container {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: stretch;
background: white;
padding: 1rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
/* 搜索框 */
.search-box { .search-box {
position: relative; position: relative;
flex: 1; flex: 1;
max-width: 400px; display: flex;
align-items: center;
background: #f9fafb;
border-radius: 0.5rem;
border: 2px solid transparent;
transition: all 0.2s;
}
.search-box:focus-within {
background: white;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-icon {
display: flex;
align-items: center;
padding: 0 0.75rem;
color: #6b7280;
}
.search-icon svg {
width: 1.25rem;
height: 1.25rem;
stroke-width: 2;
} }
.search-input { .search-input {
width: 100%; flex: 1;
padding: 0.75rem 2.5rem 0.75rem 1rem; padding: 0.75rem 0;
border: 1px solid #d1d5db; border: none;
border-radius: 0.375rem; background: transparent;
font-size: 0.875rem; font-size: 0.9375rem;
outline: none;
color: #1f2937;
}
.search-input::placeholder {
color: #9ca3af;
} }
.clear-btn { .clear-btn {
position: absolute; display: flex;
right: 0.5rem; align-items: center;
top: 50%; justify-content: center;
transform: translateY(-50%); width: 2rem;
background: none; height: 2rem;
margin-right: 0.5rem;
background: transparent;
border: none; border: none;
color: #6b7280; color: #6b7280;
cursor: pointer; cursor: pointer;
padding: 0.25rem; border-radius: 0.375rem;
border-radius: 0.25rem; transition: all 0.2s;
}
.clear-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
} }
.clear-btn:hover { .clear-btn:hover {
background: #f3f4f6; background: #e5e7eb;
color: #1f2937;
} }
/* 筛选控制 */
.filter-controls { .filter-controls {
display: flex; display: flex;
gap: 0.75rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
background: #f9fafb;
padding: 0.5rem;
border-radius: 0.5rem;
border: 2px solid transparent;
transition: all 0.2s;
}
.filter-group:focus-within {
background: white;
border-color: #3b82f6;
}
.filter-label {
display: flex;
align-items: center;
color: #6b7280;
padding: 0 0.25rem;
}
.filter-label svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
} }
.filter-select { .filter-select {
padding: 0.5rem; padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db; border: none;
border-radius: 0.375rem; background: transparent;
background: white;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
color: #1f2937;
cursor: pointer;
outline: none;
appearance: none;
padding-right: 1.5rem;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 5L6 8L9 5' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.25rem center;
} }
@media (max-width: 768px) { .filter-select:hover {
.search-filters { color: #3b82f6;
flex-direction: column; }
width: 100%;
/* 响应式设计 */
@media (max-width: 1024px) {
.search-container {
flex-wrap: wrap;
} }
.search-box { .search-box {
max-width: none; min-width: 100%;
}
.filter-controls {
flex: 1;
}
}
@media (max-width: 768px) {
.search-container {
flex-direction: column;
gap: 0.75rem;
} }
.filter-controls { .filter-controls {
width: 100%; width: 100%;
flex-wrap: wrap;
} }
.filter-select { .filter-group {
flex: 1; flex: 1;
min-width: calc(50% - 0.375rem);
}
}
@media (max-width: 480px) {
.filter-controls {
flex-direction: column;
}
.filter-group {
min-width: 100%;
} }
} }
</style> </style>
@@ -1,19 +1,47 @@
<template> <template>
<div class="view-toggle"> <div class="view-toggle">
<button class="view-btn" :class="{ active: modelValue === 'artists' }" <button
@click="$emit('update:modelValue', 'artists')"> class="view-btn"
<span class="btn-icon">👥</span> :class="{ active: modelValue === 'artists' }"
按作者浏览 @click="$emit('update:modelValue', 'artists')"
:title="'按作者浏览'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="btn-text">作者</span>
</button> </button>
<button class="view-btn" :class="{ active: modelValue === 'artworks' }"
@click="$emit('update:modelValue', 'artworks')"> <button
<span class="btn-icon">🖼</span> class="view-btn"
所有作品 :class="{ active: modelValue === 'artworks' }"
@click="$emit('update:modelValue', 'artworks')"
:title="'作品列表'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
<span class="btn-text">列表</span>
</button> </button>
<button class="view-btn" :class="{ active: modelValue === 'gallery' }"
@click="$emit('update:modelValue', 'gallery')"> <button
<span class="btn-icon">🎨</span> class="view-btn"
画廊模式 :class="{ active: modelValue === 'gallery' }"
@click="$emit('update:modelValue', 'gallery')"
:title="'画廊模式'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span class="btn-text">画廊</span>
</button> </button>
</div> </div>
</template> </template>
@@ -33,38 +61,72 @@ defineEmits<Emits>()
<style scoped> <style scoped>
.view-toggle { .view-toggle {
display: flex; display: inline-flex;
gap: 0.5rem; gap: 0;
margin-bottom: 2rem; background: #f3f4f6;
padding: 0.25rem;
border-radius: 0.625rem;
margin-bottom: 1.5rem;
} }
.view-btn { .view-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.625rem 1.125rem;
border: 1px solid #d1d5db; border: none;
background: white; background: transparent;
border-radius: 0.375rem; color: #6b7280;
border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
}
.view-btn svg {
width: 1.125rem;
height: 1.125rem;
stroke-width: 2;
}
.view-btn:hover:not(.active) {
color: #1f2937;
background: rgba(255, 255, 255, 0.6);
} }
.view-btn.active { .view-btn.active {
background: #3b82f6; background: white;
color: white; color: #3b82f6;
border-color: #3b82f6; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.btn-icon { .btn-text {
font-size: 1rem; font-weight: 500;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.view-toggle { .view-toggle {
width: 100%; width: 100%;
justify-content: center; display: flex;
}
.view-btn {
flex: 1;
padding: 0.75rem 0.875rem;
}
.btn-text {
display: none;
}
}
@media (max-width: 480px) {
.view-btn svg {
width: 1.25rem;
height: 1.25rem;
} }
} }
</style> </style>
+10
View File
@@ -20,6 +20,16 @@ export const getImageProxyUrl = (originalUrl: string) => {
return originalUrl; return originalUrl;
}; };
// 获取Pximg资源(包括ZIP等文件)的代理URL
export const getPximgFileProxyUrl = (originalUrl: string) => {
if (!originalUrl) return '';
if (originalUrl.includes('i.pximg.net')) {
const encodedUrl = encodeURIComponent(originalUrl);
return `${getApiBaseUrl()}/api/proxy/file?url=${encodedUrl}`;
}
return originalUrl;
};
class ApiService { class ApiService {
private client: AxiosInstance; private client: AxiosInstance;
+7
View File
@@ -51,6 +51,13 @@ class ArtworkService {
return apiService.get<ArtworkImagesResponse>(`/api/artwork/${id}/images?size=${size}`); return apiService.get<ArtworkImagesResponse>(`/api/artwork/${id}/images?size=${size}`);
} }
/**
* Ugoira元数据zip_urls和frames
*/
async getUgoiraMeta(id: number): Promise<ApiResponse<{ artwork_id: number; zip_urls: { medium?: string; original?: string }; frames: { file: string; delay: number }[] }>> {
return apiService.get(`/api/artwork/${id}/ugoira`);
}
/** /**
* *
*/ */
+43
View File
@@ -22,6 +22,11 @@ export interface UpdateWatchlistItemParams {
url?: string; url?: string;
} }
// 存储配置接口
export interface WatchlistConfig {
storageMode: 'json' | 'database';
}
class WatchlistService { class WatchlistService {
/** /**
* *
@@ -50,6 +55,44 @@ class WatchlistService {
async deleteItem(id: string): Promise<ApiResponse<WatchlistItem[]>> { async deleteItem(id: string): Promise<ApiResponse<WatchlistItem[]>> {
return apiService.delete<WatchlistItem[]>(`/api/watchlist/${id}`); return apiService.delete<WatchlistItem[]>(`/api/watchlist/${id}`);
} }
/**
*
*/
async export(): Promise<ApiResponse<{ version: string; exportTime: string; items: WatchlistItem[] }>> {
return apiService.get<{ version: string; exportTime: string; items: WatchlistItem[] }>(
'/api/watchlist/export'
);
}
/**
*
*/
async import(data: any, importMode: 'merge' | 'overwrite' = 'merge'): Promise<
ApiResponse<{ message: string; stats: { successCount: number; skipCount: number; errorCount: number; deletedCount: number }; items: WatchlistItem[] }>
> {
return apiService.post(
'/api/watchlist/import',
{
watchlistData: data,
importMode
}
);
}
/**
*
*/
async getConfig(): Promise<ApiResponse<WatchlistConfig>> {
return apiService.get<WatchlistConfig>('/api/watchlist/config');
}
/**
*
*/
async updateConfig(storageMode: 'json' | 'database'): Promise<ApiResponse<WatchlistConfig>> {
return apiService.put<WatchlistConfig>('/api/watchlist/config', { storageMode });
}
} }
export const watchlistService = new WatchlistService(); export const watchlistService = new WatchlistService();
+29
View File
@@ -209,6 +209,32 @@ export const useRepositoryStore = defineStore('repository', () => {
}) })
} }
// 快速扫描 - 仅获取基本信息
const quickScan = async () => {
return await apiCall('/quick-scan')
}
// 完整扫描 - 支持并发和缓存
const scanRepository = async (options: {
maxConcurrency?: number
useCache?: boolean
forceRefresh?: boolean
} = {}) => {
return await apiCall('/scan', {
method: 'POST',
body: JSON.stringify({
maxConcurrency: options.maxConcurrency || 5, // 减少默认并发数
useCache: options.useCache !== false,
forceRefresh: options.forceRefresh === true,
}),
})
}
// 清除扫描缓存
const clearScanCache = async () => {
return await apiCall('/clear-scan-cache', { method: 'POST' })
}
return { return {
// 状态 // 状态
config, config,
@@ -232,5 +258,8 @@ export const useRepositoryStore = defineStore('repository', () => {
checkArtworkDownloaded, checkArtworkDownloaded,
checkDirectoryExists, checkDirectoryExists,
migrateFromOldToNew, migrateFromOldToNew,
quickScan,
scanRepository,
clearScanCache,
} }
}) })
+76 -81
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { watchlistService, type WatchlistItem, type AddWatchlistItemParams, type UpdateWatchlistItemParams } from '@/services/watchlist'; import { watchlistService, type WatchlistItem, type AddWatchlistItemParams, type UpdateWatchlistItemParams, type WatchlistConfig } from '@/services/watchlist';
export const useWatchlistStore = defineStore('watchlist', () => { export const useWatchlistStore = defineStore('watchlist', () => {
// 状态 // 状态
const items = ref<WatchlistItem[]>([]); const items = ref<WatchlistItem[]>([]);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const configLoading = ref(false);
const storageMode = ref<'json' | 'database'>('json');
// 计算属性 // 计算属性
const itemCount = computed(() => items.value.length); const itemCount = computed(() => items.value.length);
@@ -37,6 +39,44 @@ export const useWatchlistStore = defineStore('watchlist', () => {
} }
}; };
// 获取存储配置
const fetchConfig = async () => {
try {
configLoading.value = true;
const response = await watchlistService.getConfig();
if (response.success && response.data) {
storageMode.value = (response.data.storageMode ?? 'json') as 'json' | 'database';
return true;
} else {
throw new Error(response.error || '获取存储配置失败');
}
} catch (err) {
handleError(err, '获取存储配置失败');
return false;
} finally {
configLoading.value = false;
}
};
// 保存存储模式
const saveStorageModeConfig = async (mode: 'json' | 'database') => {
try {
configLoading.value = true;
const response = await watchlistService.updateConfig(mode);
if (response.success && response.data) {
storageMode.value = response.data.storageMode;
return true;
} else {
throw new Error(response.error || '更新存储配置失败');
}
} catch (err) {
handleError(err, '更新存储配置失败');
return false;
} finally {
configLoading.value = false;
}
};
// 添加待看项目 // 添加待看项目
const addItem = async (params: AddWatchlistItemParams) => { const addItem = async (params: AddWatchlistItemParams) => {
try { try {
@@ -184,23 +224,15 @@ export const useWatchlistStore = defineStore('watchlist', () => {
}); });
}; };
// 导出待看名单数据 // 导出待看名单数据(通过后端)
const exportWatchlist = () => { const exportWatchlist = async () => {
const exportData = { try {
version: '1.0', loading.value = true;
exportTime: new Date().toISOString(), error.value = null;
items: items.value.map(item => ({ const response = await watchlistService.export();
id: item.id, if (response.success && response.data) {
title: item.title, const dataStr = JSON.stringify(response.data, null, 2);
url: item.url,
createdAt: item.createdAt,
updatedAt: item.updatedAt
}))
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' }); const dataBlob = new Blob([dataStr], { type: 'application/json' });
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob); link.href = URL.createObjectURL(dataBlob);
link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`; link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`;
@@ -208,82 +240,41 @@ export const useWatchlistStore = defineStore('watchlist', () => {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href);
return { success: true };
} else {
throw new Error(response.error || '导出待看名单失败');
}
} catch (err) {
handleError(err, '导出待看名单失败');
return { success: false };
} finally {
loading.value = false;
}
}; };
// 导入待看名单数据 // 导入待看名单数据(通过后端)
const importWatchlist = async (file: File, importMode: 'merge' | 'overwrite' = 'merge') => { const importWatchlist = async (file: File, importMode: 'merge' | 'overwrite' = 'merge') => {
try { try {
loading.value = true;
error.value = null;
const text = await file.text(); const text = await file.text();
const importData = JSON.parse(text); const importData = JSON.parse(text);
// 验证数据格式
if (!importData.items || !Array.isArray(importData.items)) { if (!importData.items || !Array.isArray(importData.items)) {
throw new Error('无效的导入文件格式'); throw new Error('无效的导入文件格式');
} }
const response = await watchlistService.import(importData, importMode);
// 统计导入结果 if (response.success && response.data) {
let successCount = 0; // 刷新本地列表
let skipCount = 0; items.value = response.data.items || items.value;
let errorCount = 0; return { success: true, message: response.data.message, stats: response.data.stats };
let deletedCount = 0;
// 如果是覆盖模式,先删除所有现有项目
if (importMode === 'overwrite') {
const allItems = items.value;
for (const item of allItems) {
try {
await deleteItem(item.id);
deletedCount++;
} catch (err) {
console.error('删除项目失败:', item, err);
}
}
}
for (const item of importData.items) {
try {
// 在重合模式下检查是否已存在
if (importMode === 'merge' && hasUrl(item.url)) {
skipCount++;
continue;
}
// 添加项目
const success = await addItem({
url: item.url,
title: item.title
});
if (success) {
successCount++;
} else { } else {
errorCount++; throw new Error(response.error || '导入待看名单失败');
} }
} catch (err) { } catch (err) {
console.error('导入项目失败:', item, err); handleError(err, '导入待看名单失败');
errorCount++; return { success: false, message: error.value || '导入待看名单失败', stats: { successCount: 0, skipCount: 0, errorCount: 0, deletedCount: 0 } };
} } finally {
} loading.value = false;
let message = '';
if (importMode === 'overwrite') {
message = `覆盖导入完成:删除 ${deletedCount} 项,成功添加 ${successCount} 项,失败 ${errorCount}`;
} else {
message = `重合导入完成:成功 ${successCount} 项,跳过 ${skipCount} 项,失败 ${errorCount}`;
}
return {
success: true,
message,
stats: { successCount, skipCount, errorCount, deletedCount }
};
} catch (err) {
console.error('导入失败:', err);
return {
success: false,
message: err instanceof Error ? err.message : '导入失败',
stats: { successCount: 0, skipCount: 0, errorCount: 0, deletedCount: 0 }
};
} }
}; };
@@ -292,11 +283,15 @@ export const useWatchlistStore = defineStore('watchlist', () => {
items, items,
loading, loading,
error, error,
configLoading,
storageMode,
// 计算属性 // 计算属性
itemCount, itemCount,
hasItems, hasItems,
// 方法 // 方法
fetchItems, fetchItems,
fetchConfig,
saveStorageModeConfig,
addItem, addItem,
updateItem, updateItem,
deleteItem, deleteItem,
+155
View File
@@ -20,6 +20,26 @@
</button> </button>
</div> </div>
<!-- 扫描控制面板 -->
<div class="scan-controls">
<div class="scan-buttons">
<button @click="performFullScan" :disabled="isScanning" class="scan-button">
{{ isScanning ? '扫描中...' : '完整扫描' }}
</button>
<button @click="clearCache" class="clear-cache-button">
清除缓存
</button>
</div>
<!-- 扫描进度 -->
<div v-if="isScanning" class="scan-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
</div>
<div class="progress-text">{{ scanStatus }}</div>
</div>
</div>
<!-- 配置管理 --> <!-- 配置管理 -->
<div v-if="activeTab === 'config'" class="tab-content"> <div v-if="activeTab === 'config'" class="tab-content">
<RepositoryConfigComponent :config="config" :migrating="migrating" :migration-progress="migrationProgress" <RepositoryConfigComponent :config="config" :migrating="migrating" :migration-progress="migrationProgress"
@@ -65,6 +85,11 @@ const config = ref<RepositoryConfig>({
migrationRules: [] migrationRules: []
}) })
//
const isScanning = ref(false)
const scanProgress = ref(0)
const scanStatus = ref('')
// //
const searchQuery = ref('') const searchQuery = ref('')
const viewMode = ref('artworks') // const viewMode = ref('artworks') //
@@ -96,6 +121,15 @@ onMounted(async () => {
// //
const loadStats = async () => { const loadStats = async () => {
try { try {
//
try {
const quickResult = await repositoryStore.quickScan()
console.log('快速扫描结果:', quickResult)
} catch (error) {
console.warn('快速扫描失败,使用传统方法:', error)
}
//
stats.value = await repositoryStore.getStats() stats.value = await repositoryStore.getStats()
} catch (error: any) { } catch (error: any) {
console.error('加载统计信息失败:', error) console.error('加载统计信息失败:', error)
@@ -408,6 +442,49 @@ const handleConfigSaved = async () => {
console.error('配置保存后刷新数据失败:', error) console.error('配置保存后刷新数据失败:', error)
} }
} }
//
const performFullScan = async () => {
try {
isScanning.value = true
scanProgress.value = 0
scanStatus.value = '开始扫描...'
const result = await repositoryStore.scanRepository({
maxConcurrency: 5, //
useCache: true,
forceRefresh: true
})
scanStatus.value = '扫描完成'
scanProgress.value = 100
//
await loadStats()
await loadArtists()
await loadAllArtworks(1)
alert(`扫描完成!发现 ${result.artworks.length} 个作品,${result.artists.length} 个作者`)
} catch (error: any) {
console.error('扫描失败:', error)
alert('扫描失败: ' + error.message)
} finally {
isScanning.value = false
scanStatus.value = ''
scanProgress.value = 0
}
}
//
const clearCache = async () => {
try {
await repositoryStore.clearScanCache()
alert('扫描缓存已清除')
} catch (error: any) {
console.error('清除缓存失败:', error)
alert('清除缓存失败: ' + error.message)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -471,9 +548,87 @@ const handleConfigSaved = async () => {
padding: 2rem; padding: 2rem;
} }
.scan-controls {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 2rem;
}
.scan-buttons {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.scan-button {
background: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.scan-button:hover:not(:disabled) {
background: #2563eb;
}
.scan-button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.clear-cache-button {
background: #6b7280;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.clear-cache-button:hover {
background: #4b5563;
}
.scan-progress {
margin-top: 1rem;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.container { .container {
padding: 0 1rem; padding: 0 1rem;
} }
.scan-buttons {
flex-direction: column;
}
} }
</style> </style>