Compare commits
10 Commits
96f49a53b5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce0ab1234 | |||
| 0e8766c0b4 | |||
| 92e307d3e5 | |||
| 91114bcc80 | |||
| 706dfb6667 | |||
| 83bfd9d6d6 | |||
| 8b0359c149 | |||
| 54b9abfeeb | |||
| 5be8ae9520 | |||
| e85f959fa6 |
@@ -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
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Pixiv 下载浏览管理器是一个基于 Web 的应用程序,提供以下功
|
|||||||
```
|
```
|
||||||
|
|
||||||
5. **访问应用**
|
5. **访问应用**
|
||||||
- 打开浏览器访问:http://localhost:3001 (默认端口,可在 config.json 中修改)
|
- 打开浏览器访问:http://localhost:3000 (默认端口,可在 config.json 中修改)
|
||||||
|
|
||||||
## 🌐 代理配置
|
## 🌐 代理配置
|
||||||
|
|
||||||
|
|||||||
@@ -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, // 跳过被占用的文件
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +962,36 @@ class DownloadService {
|
|||||||
|
|
||||||
await this.fileManager.ensureDirectory(artworkDir);
|
await this.fileManager.ensureDirectory(artworkDir);
|
||||||
|
|
||||||
// 获取图片URL
|
// 根据作品类型获取下载资源
|
||||||
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
|
let images = [];
|
||||||
if (!imagesResult.success) {
|
if (artwork.type === 'ugoira') {
|
||||||
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
|
// 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);
|
||||||
|
if (!imagesResult.success) {
|
||||||
|
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', {
|
||||||
artwork_id: artworkId,
|
artwork_id: artworkId,
|
||||||
@@ -1094,14 +1119,33 @@ class DownloadService {
|
|||||||
|
|
||||||
await this.fileManager.ensureDirectory(artworkDir);
|
await this.fileManager.ensureDirectory(artworkDir);
|
||||||
|
|
||||||
// 获取图片URL
|
// 根据作品类型获取下载资源(批量下载场景)
|
||||||
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
|
let images = [];
|
||||||
if (!imagesResult.success) {
|
if (artwork.type === 'ugoira') {
|
||||||
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
|
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);
|
||||||
|
if (!imagesResult.success) {
|
||||||
|
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
|
||||||
|
}
|
||||||
|
images = imagesResult.data.images;
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = imagesResult.data.images;
|
|
||||||
|
|
||||||
// 直接下载,不创建新任务
|
// 直接下载,不创建新任务
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let index = 0; index < images.length; index++) {
|
for (let index = 0; index < images.length; index++) {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
+524
-55
@@ -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,108 +171,228 @@ 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 {
|
||||||
// 确保配置已加载
|
// 确保配置已加载(使用缓存版本)
|
||||||
await this.loadConfig()
|
if (!this.configLoaded) {
|
||||||
|
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 artworkDirs = artworkEntries
|
||||||
|
.filter(entry => entry.isDirectory())
|
||||||
|
.map(entry => ({
|
||||||
|
name: entry.name,
|
||||||
|
path: path.join(artistPath, entry.name)
|
||||||
|
}))
|
||||||
|
|
||||||
// 扫描作者下的作品目录
|
// 并发扫描作品文件
|
||||||
const artworkEntries = await fs.readdir(artistPath, { withFileTypes: true })
|
const artworkPromises = artworkDirs.map(async (artworkDir) => {
|
||||||
|
try {
|
||||||
|
const fullPath = artworkDir.path
|
||||||
|
|
||||||
for (const artworkEntry of artworkEntries) {
|
// 检查是否是作品目录(包含数字ID)
|
||||||
if (!artworkEntry.isDirectory()) continue
|
const artworkMatch = artworkDir.name.match(/^(\d+)_(.+)$/)
|
||||||
|
if (!artworkMatch) return null
|
||||||
|
|
||||||
const fullPath = path.join(artistPath, artworkEntry.name)
|
const artworkId = artworkMatch[1]
|
||||||
|
const title = artworkMatch[2]
|
||||||
|
|
||||||
// 检查是否是作品目录(包含数字ID)
|
// 扫描作品文件
|
||||||
const artworkMatch = artworkEntry.name.match(/^(\d+)_(.+)$/)
|
const files = await this.scanArtworkFiles(fullPath)
|
||||||
if (artworkMatch) {
|
|
||||||
const artworkId = artworkMatch[1]
|
|
||||||
const title = artworkMatch[2]
|
|
||||||
|
|
||||||
// 扫描作品文件
|
if (files.length > 0) {
|
||||||
const files = await this.scanArtworkFiles(fullPath)
|
const artworkSize = files.reduce((sum, file) => sum + file.size, 0)
|
||||||
|
return {
|
||||||
if (files.length > 0) {
|
id: artworkId,
|
||||||
artworks.push({
|
title: title,
|
||||||
id: artworkId,
|
artist: artistName,
|
||||||
title: title,
|
artistPath: artistPath,
|
||||||
artist: artistName,
|
path: fullPath,
|
||||||
artistPath: artistPath,
|
files: files,
|
||||||
path: fullPath,
|
size: artworkSize,
|
||||||
files: files,
|
createdAt: await this.getFileCreationTime(fullPath)
|
||||||
size: files.reduce((sum, file) => sum + file.size, 0),
|
}
|
||||||
createdAt: await this.getFileCreationTime(fullPath)
|
}
|
||||||
})
|
return null
|
||||||
artists.add(artistName)
|
} catch (error) {
|
||||||
totalSize += files.reduce((sum, file) => sum + file.size, 0)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 {
|
||||||
// 确保配置已加载
|
// 确保配置已加载(使用缓存版本)
|
||||||
await this.loadConfig()
|
if (!this.configLoaded) {
|
||||||
|
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
|
||||||
@@ -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
@@ -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('未检测到系统代理,将尝试使用系统代理环境变量');
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"server": {
|
"server": {
|
||||||
"port": 3001,
|
"port": 3000,
|
||||||
"autoOpenBrowser": false
|
"autoOpenBrowser": false
|
||||||
},
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
|
|||||||
+10
-2
@@ -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",
|
||||||
|
|||||||
Generated
+363
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- canvas
|
||||||
+34
-28
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
+2
-1
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<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">
|
||||||
<LoadingSpinner text="图片加载中..." />
|
<LoadingSpinner text="图片加载中..." />
|
||||||
</div>
|
</div>
|
||||||
<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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
// 优先使用原始zip,如果没有则用medium
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<div class="artist-avatar">
|
<h3>暂无作者</h3>
|
||||||
<span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span>
|
<p>这里还没有任何作者信息</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="artist-info">
|
|
||||||
<h4>{{ artist.name }}</h4>
|
<!-- 作者网格 -->
|
||||||
<p>{{ artist.artworkCount }} 个作品</p>
|
<div v-else class="artists-grid">
|
||||||
<p>{{ formatFileSize(artist.totalSize) }}</p>
|
<div v-for="artist in artists" :key="artist.name" class="artist-card">
|
||||||
</div>
|
<!-- 卡片内容 -->
|
||||||
<div class="artist-actions">
|
<div class="card-content" @click="$emit('view-artist-works', artist.name)">
|
||||||
<button @click.stop="$emit('view-artist-works', artist.name)" class="action-btn">
|
<!-- 头像和背景 -->
|
||||||
查看作品
|
<div class="artist-header">
|
||||||
</button>
|
<div class="artist-avatar">
|
||||||
|
<span class="avatar-text">{{ artist.name.charAt(0).toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="artist-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="12" cy="7" r="4"/>
|
||||||
|
</svg>
|
||||||
|
</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">
|
||||||
|
<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 @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,31 +1,102 @@
|
|||||||
<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">
|
|
||||||
👁️ 查看大图
|
<!-- 作品网格 -->
|
||||||
</button>
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="artwork-no-image">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="artwork-info">
|
|
||||||
<h4>{{ artwork.title }}</h4>
|
<!-- 作品信息 -->
|
||||||
<p class="artist-name" @click.stop="$emit('select-artist', artwork.artist)">
|
<div class="artwork-content">
|
||||||
👤 {{ artwork.artist }}
|
<h3 class="artwork-title" :title="artwork.title">{{ artwork.title }}</h3>
|
||||||
</p>
|
|
||||||
<p>{{ formatFileSize(artwork.size) }}</p>
|
<!-- 作者信息 -->
|
||||||
<p class="file-count">{{ artwork.files.length }} 个文件</p>
|
<div class="artwork-artist" @click.stop="$emit('select-artist', artwork.artist)">
|
||||||
</div>
|
<div class="artist-avatar">
|
||||||
<div class="artwork-actions">
|
<span>{{ artwork.artist.charAt(0).toUpperCase() }}</span>
|
||||||
<button @click.stop="$emit('view-artwork', artwork)" class="action-btn">
|
</div>
|
||||||
详情
|
<span class="artist-name">{{ artwork.artist }}</span>
|
||||||
</button>
|
</div>
|
||||||
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="action-btn">
|
|
||||||
预览
|
<!-- 元数据 -->
|
||||||
</button>
|
<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">
|
||||||
|
<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 @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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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,51 +1,121 @@
|
|||||||
<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 class="view-controls">
|
|
||||||
<button @click="setGridSize('small')" :class="['size-btn', { active: gridSize === 'small' }]">
|
|
||||||
小
|
|
||||||
</button>
|
|
||||||
<button @click="setGridSize('medium')" :class="['size-btn', { active: gridSize === 'medium' }]">
|
|
||||||
中
|
|
||||||
</button>
|
|
||||||
<button @click="setGridSize('large')" :class="['size-btn', { active: gridSize === 'large' }]">
|
|
||||||
大
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gallery-grid" :class="`grid-${gridSize}`">
|
<template v-else>
|
||||||
<div v-for="artwork in artworks" :key="artwork.id" class="gallery-item"
|
<!-- 画廊控制栏 -->
|
||||||
@click="$emit('open-image-viewer', artwork, 0)">
|
<div class="gallery-controls">
|
||||||
<div class="gallery-image-container">
|
<div class="controls-section">
|
||||||
<img :src="getPreviewUrl(artwork.files[0].path)" :alt="artwork.title" class="gallery-image"
|
<label class="control-label">网格大小</label>
|
||||||
@load="onImageLoad" @error="onImageError" />
|
<div class="control-buttons">
|
||||||
<div class="gallery-overlay">
|
<button @click="setGridSize('small')" :class="['control-btn', { active: gridSize === 'small' }]">
|
||||||
<div class="overlay-content">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<h4>{{ artwork.title }}</h4>
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
<p>{{ artwork.artist }}</p>
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
<div class="overlay-actions">
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
<button @click.stop="$emit('open-image-viewer', artwork, 0)" class="overlay-btn">
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
👁️ 查看大图
|
</svg>
|
||||||
</button>
|
<span>小</span>
|
||||||
<button @click.stop="$emit('view-artwork', artwork)" class="overlay-btn">
|
</button>
|
||||||
📋 详情
|
<button @click="setGridSize('medium')" :class="['control-btn', { active: gridSize === 'medium' }]">
|
||||||
</button>
|
<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 @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>
|
||||||
|
</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 v-for="artwork in artworks" :key="artwork.id" class="gallery-item">
|
||||||
|
<div class="gallery-card" @click="$emit('open-image-viewer', artwork, 0)">
|
||||||
|
<!-- 图片容器 -->
|
||||||
|
<div class="image-container" :class="`fit-${fitMode}`">
|
||||||
|
<img
|
||||||
|
:src="getPreviewUrl(artwork.files[0].path)"
|
||||||
|
:alt="artwork.title"
|
||||||
|
class="gallery-image"
|
||||||
|
loading="lazy"
|
||||||
|
@load="onImageLoad"
|
||||||
|
@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 @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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,26 +1,65 @@
|
|||||||
<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">
|
||||||
<select v-model="sortBy" @change="handleSortChange" class="filter-select">
|
<div class="filter-group">
|
||||||
<option value="date">按日期排序</option>
|
<label class="filter-label">
|
||||||
<option value="name">按名称排序</option>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<option value="size">按大小排序</option>
|
<line x1="4" y1="21" x2="4" y2="14"/>
|
||||||
</select>
|
<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">
|
||||||
|
<option value="date">按日期</option>
|
||||||
|
<option value="name">按名称</option>
|
||||||
|
<option value="size">按大小</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select v-model="filterBy" @change="handleFilterChange" class="filter-select">
|
<div class="filter-group">
|
||||||
<option value="all">全部</option>
|
<label class="filter-label">
|
||||||
<option value="images">仅图片</option>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<option value="videos">仅视频</option>
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||||
</select>
|
</svg>
|
||||||
|
</label>
|
||||||
|
<select v-model="filterBy" @change="handleFilterChange" class="filter-select">
|
||||||
|
<option value="all">全部类型</option>
|
||||||
|
<option value="images">仅图片</option>
|
||||||
|
<option value="videos">仅视频</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索作品
|
* 搜索作品
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
+84
-89
@@ -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,106 +224,57 @@ 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,
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
createdAt: item.createdAt,
|
const link = document.createElement('a');
|
||||||
updatedAt: item.updatedAt
|
link.href = URL.createObjectURL(dataBlob);
|
||||||
}))
|
link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
};
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
const dataStr = JSON.stringify(exportData, null, 2);
|
document.body.removeChild(link);
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
URL.revokeObjectURL(link.href);
|
||||||
|
return { success: true };
|
||||||
const link = document.createElement('a');
|
} else {
|
||||||
link.href = URL.createObjectURL(dataBlob);
|
throw new Error(response.error || '导出待看名单失败');
|
||||||
link.download = `watchlist-${new Date().toISOString().split('T')[0]}.json`;
|
}
|
||||||
document.body.appendChild(link);
|
} catch (err) {
|
||||||
link.click();
|
handleError(err, '导出待看名单失败');
|
||||||
document.body.removeChild(link);
|
return { success: false };
|
||||||
URL.revokeObjectURL(link.href);
|
} 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 {
|
|
||||||
errorCount++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('导入项目失败:', item, err);
|
|
||||||
errorCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
if (importMode === 'overwrite') {
|
|
||||||
message = `覆盖导入完成:删除 ${deletedCount} 项,成功添加 ${successCount} 项,失败 ${errorCount} 项`;
|
|
||||||
} else {
|
} else {
|
||||||
message = `重合导入完成:成功 ${successCount} 项,跳过 ${skipCount} 项,失败 ${errorCount} 项`;
|
throw new Error(response.error || '导入待看名单失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message,
|
|
||||||
stats: { successCount, skipCount, errorCount, deletedCount }
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('导入失败:', err);
|
handleError(err, '导入待看名单失败');
|
||||||
return {
|
return { success: false, message: error.value || '导入待看名单失败', stats: { successCount: 0, skipCount: 0, errorCount: 0, deletedCount: 0 } };
|
||||||
success: false,
|
} finally {
|
||||||
message: err instanceof Error ? err.message : '导入失败',
|
loading.value = false;
|
||||||
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user