8 Commits

Author SHA1 Message Date
dfcdddabad chore: 统一版本号为v0.1.1 2026-05-15 18:25:42 +08:00
4b9bcd7a96 fix: cookie加密后cloud.service.ts未解密导致API 401; 更新版本号至2.1.1/1.1.9 2026-05-15 18:23:40 +08:00
a12fec4d82 fix: 警示文件夹创建到转存目录内而非根目录
- createWarningDirectories 加 parentDirFid 参数
- createSingleDir 加 pdirFid 参数, pdir_fid 用传参而非硬编码 0
- runAdCleanup 传入 savedDirFid
- listDirAllPages 列出目标目录而非根目录
2026-05-15 07:05:58 +08:00
1c0c024b9a feat: 转存记录记录文件大小, 详情展示使用账号+文件大小+时间格式
- quark/baidu 驱动 saveFromShare 返回 fileSize 总大小
- cloud.service.ts 写入 file_size 字段(非null时转字符串)
- 详情页新增文件大小展示(formatFileSize 自动格式化)
- 详情页时间改为 formatTime(yyyy-MM-dd HH:mm:ss)
- SaveRecords 时间格式: 05-15 → 2026-05-15
2026-05-15 07:05:03 +08:00
359e15a82d fix: save_records JOIN 查询列名歧义, 加 sr. 前缀
- getRecords JOIN cloud_configs 后 WHERE 列需加 sr. 前缀
- 不带日期筛选时不触发 (srWhere 为空字符串)
- summaryRows 查询也用 srWhere 但去掉 sr. 前缀 (查询 save_records 不需要)
- 详情补充文件大小/使用账号/耗时
2026-05-15 06:57:50 +08:00
b7702d0285 feat: 同IP默认账号配额改为 primaryCount × 2
- 动态计算该类型默认账号数量 primaryCount
- 前 primaryCount × 2 次在同IP的两个默认账号间轮询
- 超限后再去其他非默认账号
- 无默认账号时 threshold=0, 直接走轮询
2026-05-15 06:51:15 +08:00
37aa05b1e1 revert: 去掉资源历史查询逻辑, 保留纯IP+is_primary分配
- 网盘分享按新资源奖励机制, 不需追资源转存历史
- getAndValidateCredential 去掉 shareUrl 参数
- 保留 save_records.config_id 字段(仅用于日志排查)
2026-05-15 06:48:23 +08:00
329256bd33 fix: 转存时先查资源历史, 复用原账号; save_records加config_id
- 资源维度优先级 > IP维度: 先查share_url是否被转存过
- save_records 表新增 config_id 字段 + 写入时记录
- cloud.service.ts 所有 INSERT 写入 config.id
- credential.service.ts: getAndValidateCredential 加 shareUrl 参数
- 数据库 migration: config_id 到 save_records
2026-05-15 06:45:48 +08:00
10 changed files with 125 additions and 80 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "cloudsearch-backend", "name": "cloudsearch-backend",
"version": "2.1.0", "version": "0.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",

View File

@@ -1,6 +1,7 @@
import { getDb } from '../database/database'; import { getDb } from '../database/database';
import { localTimestamp, formatLocalDateTime } from '../utils/time'; import { localTimestamp, formatLocalDateTime } from '../utils/time';
import { getSystemConfig } from '../admin/system-config.service'; import { getSystemConfig } from '../admin/system-config.service';
import { decrypt } from '../utils/crypto';
import { QuarkDriver } from './drivers/quark.driver'; import { QuarkDriver } from './drivers/quark.driver';
import { BaiduDriver } from './drivers/baidu.driver'; import { BaiduDriver } from './drivers/baidu.driver';
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
@@ -70,14 +71,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
if (alreadySaved && recentRecord.share_url) { if (alreadySaved && recentRecord.share_url) {
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`); console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
db.prepare( db.prepare(
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
cloudType, sourceTitle || null, shareUrl, cloudType, cloudType, sourceTitle || null, shareUrl, cloudType,
recentRecord.share_url, recentRecord.share_pwd || null, recentRecord.share_url, recentRecord.share_pwd || null,
null, 0, 0, 0, 'reused', null, null, 0, 0, 0, 'reused', null,
recentRecord.folder_name || null, recentRecord.original_folder_name || null, recentRecord.folder_name || null, recentRecord.original_folder_name || null,
ipAddress || null, ipLocation, localTimestamp(), ipAddress || null, ipLocation, localTimestamp(), null,
); );
return { return {
success: true, success: true,
@@ -155,16 +156,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
const startTime = Date.now(); const startTime = Date.now();
try { try {
let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string }; let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; fileSize?: number; originalFolderName?: string };
switch (cloudType) { switch (cloudType) {
case 'quark': { case 'quark': {
const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname }); const driver = new QuarkDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle); driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break; break;
} }
case 'baidu': { case 'baidu': {
const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname }); const driver = new BaiduDriver({ cookie: decrypt(config.cookie!), nickname: config.nickname });
driverResult = await driver.saveFromShare(shareUrl, sourceTitle); driverResult = await driver.saveFromShare(shareUrl, sourceTitle);
break; break;
} }
@@ -174,6 +175,21 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
return { success: false, message: `暂不支持 ${cloudType} 的保存功能` }; return { success: false, message: `暂不支持 ${cloudType} 的保存功能` };
} }
// ── If save failed, get actual error reason from PanSou validation ──
let actualError: string | null = null;
if (!driverResult.success) {
try {
const { LinkValidator } = await import('../validation/link-validator.service');
const validator = new LinkValidator();
const validation = await validator.validate(shareUrl, cloudType);
if (validation.message) {
actualError = validation.message;
}
} catch {
// PanSou unreachable
}
}
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
if (driverResult.success) { if (driverResult.success) {
@@ -189,16 +205,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
} }
db.prepare( db.prepare(
`INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run( ).run(
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
driverResult.shareUrl || null, driverResult.sharePwd || null, driverResult.shareUrl || null, driverResult.sharePwd || null,
null, driverResult.fileCount || 0, driverResult.folderCount || 0, driverResult.fileSize == null ? null : String(driverResult.fileSize), driverResult.fileCount || 0, driverResult.folderCount || 0,
durationMs, driverResult.success ? 'success' : 'failed', durationMs, driverResult.success ? 'success' : 'failed',
driverResult.success ? null : driverResult.message, driverResult.success ? null : (actualError ? `${driverResult.message} | ${actualError}` : driverResult.message),
driverResult.folderName || null, driverResult.originalFolderName || null, driverResult.folderName || null, driverResult.originalFolderName || null,
ipAddress || null, ipLocation, localTimestamp(), ipAddress || null, ipLocation, localTimestamp(), config.id
); );
return { return {
@@ -221,9 +237,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
).run(config.id); ).run(config.id);
db.prepare( db.prepare(
`INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at) `INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at, config_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp()); ).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp(), null);
return { success: false, message: errorMessage }; return { success: false, message: errorMessage };
} }
@@ -270,15 +286,19 @@ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDat
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType); summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
} }
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); } if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; const srWhere = conditions.length > 0 ? 'WHERE sr.' + conditions.join(' AND sr.') : '';
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count; const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${srWhere.replace(/sr\./g, '')}`).get(...params) as any).count;
const records = db.prepare( const records = db.prepare(
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?` `SELECT sr.*, cc.nickname as config_nickname
).all(...params, pageSize, offset) as SaveRecord[]; FROM save_records sr
LEFT JOIN cloud_configs cc ON sr.config_id = cc.id
${srWhere}
ORDER BY sr.created_at DESC LIMIT ? OFFSET ?`
).all(...params, pageSize, offset) as any[];
const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : ''; const summaryWhere = summaryConditions.length > 0 ? 'WHERE sr.' + summaryConditions.join(' AND sr.') : '';
const summaryRows = db.prepare( const summaryRows = db.prepare(
`SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status` `SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere.replace(/sr\./g, '')} GROUP BY status`
).all(...summaryParams) as { status: string; cnt: number }[]; ).all(...summaryParams) as { status: string; cnt: number }[];
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0; let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
for (const r of summaryRows) { for (const r of summaryRows) {
@@ -308,7 +328,7 @@ export async function refreshAllStorageInfo(): Promise<void> {
for (const cfg of configs) { for (const cfg of configs) {
try { try {
const { QuarkDriver } = require('./drivers/quark.driver'); const { QuarkDriver } = require('./drivers/quark.driver');
const driver = new QuarkDriver({ cookie: cfg.cookie, nickname: cfg.nickname }); const driver = new QuarkDriver({ cookie: decrypt(cfg.cookie!), nickname: cfg.nickname });
const storage = await driver.getStorageInfo(); const storage = await driver.getStorageInfo();
if (storage.totalBytes > 0 || storage.usedBytes > 0) { if (storage.totalBytes > 0 || storage.usedBytes > 0) {
const db = getDb(); const db = getDb();
@@ -320,4 +340,4 @@ export async function refreshAllStorageInfo(): Promise<void> {
console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message); console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message);
} }
} }
} }

View File

@@ -189,7 +189,7 @@ export function saveCloudConfig(data: {
consecutive_failures = 0, consecutive_failures = 0,
updated_at = ? updated_at = ?
WHERE id = ?` WHERE id = ?`
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id); ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id);
} else { } else {
// Try to find existing config by cloud_type + cloud_type_uid // Try to find existing config by cloud_type + cloud_type_uid
let existing: any = null; let existing: any = null;
@@ -220,7 +220,7 @@ export function saveCloudConfig(data: {
consecutive_failures = 0, consecutive_failures = 0,
updated_at = ? updated_at = ?
WHERE id = ?` WHERE id = ?`
).run(encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id); ).run(encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id);
// Re-read savedId for return // Re-read savedId for return
const savedId = existing.id; const savedId = existing.id;
@@ -236,7 +236,7 @@ export function saveCloudConfig(data: {
// No existing config found — insert new // No existing config found — insert new
db.prepare( db.prepare(
'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' 'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)'
).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null); ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active == null ? 1 : Number(data.is_active), data.promotion_account ?? '', data.is_transfer_enabled == null ? 1 : Number(data.is_transfer_enabled), data.storage_used || null, data.storage_total || null, cloudTypeUid || null);
} }
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id; const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
@@ -428,6 +428,8 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
let config: CloudConfig | undefined; let config: CloudConfig | undefined;
if (!ipAddress) { if (!ipAddress) {
// No IP info — fallback to simple LUR // No IP info — fallback to simple LUR
config = db.prepare( config = db.prepare(
@@ -454,8 +456,15 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
const ipTodayCount = ipCountRow?.total || 0; const ipTodayCount = ipCountRow?.total || 0;
if (ipTodayCount < 3) { // How many primary accounts does this cloud type have?
// First 2 saves — use a primary account (is_primary=1), fallback to any healthy const primaryCountRow = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND is_active = 1`
).get(cloudType) as { c: number };
const primaryCount = primaryCountRow?.c || 0;
const primaryThreshold = primaryCount * 2; // Each primary account gets 2 uses per IP
if (ipTodayCount < primaryThreshold) {
// First N saves (primaryCount × 2) — use primary accounts (is_primary=1), fallback to any healthy
config = db.prepare( config = db.prepare(
`SELECT * FROM cloud_configs `SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1 WHERE cloud_type = ? AND is_active = 1
@@ -464,7 +473,7 @@ export async function getAndValidateCredential(cloudType: string, ipAddress?: st
LIMIT 1` LIMIT 1`
).get(cloudType) as CloudConfig | undefined; ).get(cloudType) as CloudConfig | undefined;
} else { } else {
// 3rd+ save — exclude accounts this IP has already used today, // After primary threshold — exclude accounts this IP has already used today,
// fall back to other available accounts round-robin // fall back to other available accounts round-robin
const usedConfigIds = db.prepare( const usedConfigIds = db.prepare(
`SELECT DISTINCT config_id FROM ip_daily_save_counts `SELECT DISTINCT config_id FROM ip_daily_save_counts

View File

@@ -166,12 +166,13 @@ async function batchDeleteFiles(
export async function createWarningDirectories( export async function createWarningDirectories(
cookie: string, cookie: string,
dirNames: string[], dirNames: string[],
parentDirFid: string = "0",
): Promise<void> { ): Promise<void> {
if (!dirNames.length) return; if (!dirNames.length) return;
// 先获取根目录下所有文件夹,避免重复创建 // 先获取根目录下所有文件夹,避免重复创建
await humanDelay(); await humanDelay();
const rootFiles = await listDirAllPages(cookie, "0"); const rootFiles = await listDirAllPages(cookie, parentDirFid);
const existingDirs = new Set( const existingDirs = new Set(
rootFiles.filter((f) => f.dir).map((f) => f.file_name), rootFiles.filter((f) => f.dir).map((f) => f.file_name),
); );
@@ -192,7 +193,7 @@ export async function createWarningDirectories(
continue; continue;
} }
await createSingleDir(cookie, formattedName); await createSingleDir(cookie, formattedName, parentDirFid);
// 加入已存在集合,防止同名重试 // 加入已存在集合,防止同名重试
existingDirs.add(formattedName); existingDirs.add(formattedName);
} }
@@ -204,6 +205,7 @@ export async function createWarningDirectories(
async function createSingleDir( async function createSingleDir(
cookie: string, cookie: string,
dirName: string, dirName: string,
pdirFid: string = "0",
): Promise<boolean> { ): Promise<boolean> {
try { try {
const resp = await fetch( const resp = await fetch(
@@ -215,7 +217,7 @@ async function createSingleDir(
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
pdir_fid: "0", pdir_fid: pdirFid,
file_name: dirName, file_name: dirName,
dir: true, dir: true,
dir_path: "", dir_path: "",
@@ -276,7 +278,7 @@ export async function runAdCleanup(
console.log( console.log(
`[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`, `[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length}`,
); );
await createWarningDirectories(cookie, warningNames); await createWarningDirectories(cookie, warningNames, savedDirFid);
warningDirs = warningNames.length; warningDirs = warningNames.length;
console.log( console.log(
`[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`, `[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`,

View File

@@ -89,14 +89,8 @@ export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string
*/ */
export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> { export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> {
try { try {
const mparam = getMparam(cookie);
let totalBytes = 0; let totalBytes = 0;
const params = new URLSearchParams({ const params = new URLSearchParams(getCommonParams());
...getCommonParams(),
kps: mparam.kps || '',
sign: mparam.sign || '',
vcode: mparam.vcode || '',
});
const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, { const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, {
headers: getHeaders(cookie), headers: getHeaders(cookie),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),

View File

@@ -28,6 +28,7 @@ export async function saveFromShare(
renamed?: string[]; renamed?: string[];
fileCount?: number; fileCount?: number;
folderCount?: number; folderCount?: number;
fileSize?: number;
originalFolderName?: string; originalFolderName?: string;
}> { }> {
try { try {
@@ -203,6 +204,10 @@ export async function saveFromShare(
} }
} }
// Calculate total file size
const allFiles = topDir && childFiles ? childFiles : topFiles;
const fileSize = allFiles.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
const renameMsg = renamed.length > 0 const renameMsg = renamed.length > 0
? `,已重命名 ${renamed.length} 个文件` ? `,已重命名 ${renamed.length} 个文件`
: ''; : '';
@@ -218,6 +223,7 @@ export async function saveFromShare(
renamed: renamed.map(r => `${r.original}${r.renamed}`), renamed: renamed.map(r => `${r.original}${r.renamed}`),
fileCount, fileCount,
folderCount, folderCount,
fileSize,
originalFolderName, originalFolderName,
}; };
} catch (err: any) { } catch (err: any) {

View File

@@ -138,27 +138,6 @@ function runMigrations(db: Database.Database): void {
} }
/** 迁移: 给已有 save_records 表补充新列 */ /** 迁移: 给已有 save_records 表补充新列 */
function migrateSaveRecords(db: Database.Database): void {
const newCols: { col: string; def: string }[] = [
{ col: 'share_pwd', def: 'TEXT' },
{ col: 'file_count', def: 'INTEGER DEFAULT 0' },
{ col: 'folder_count', def: 'INTEGER DEFAULT 0' },
{ col: 'duration_ms', def: 'INTEGER DEFAULT 0' },
{ col: 'status', def: "TEXT NOT NULL DEFAULT ''" },
{ col: 'error_message', def: 'TEXT' },
{ col: 'folder_name', def: 'TEXT' },
{ col: 'request_url', def: 'TEXT' },
{ col: 'ip_location', def: 'TEXT' },
{ col: 'original_folder_name', def: 'TEXT' },
];
for (const { col, def } of newCols) {
try {
db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`);
} catch {
// Column already exists — ignore
}
}
}
/** 迁移: 给 content_cache 表加 douban_url 列 */ /** 迁移: 给 content_cache 表加 douban_url 列 */
function migrateContentCache(db: Database.Database): void { function migrateContentCache(db: Database.Database): void {
@@ -263,6 +242,14 @@ function migrateCloudConfigs(db: Database.Database): void {
} }
} }
/** 迁移: 给 save_records 表加 config_id 字段 */
function migrateSaveRecords(db: any): void {
if (!db.prepare("SELECT sql FROM sqlite_master WHERE name='save_records' AND sql LIKE '%config_id%'").get()) {
db.exec("ALTER TABLE save_records ADD COLUMN config_id INTEGER DEFAULT NULL");
console.log('[DB] save_records migration: config_id column added');
}
}
function seedAdmin(db: Database.Database): void { function seedAdmin(db: Database.Database): void {
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
if (existing) return; if (existing) return;
@@ -334,6 +321,8 @@ function seedSystemConfigs(db: Database.Database): void {
{ key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' }, { key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' },
{ key: 'quark_warning_folder_names', value: '⚠️ 网盘内除您所需资源外', description: '夸克转存后自动创建的警示文件夹名(一行一个,自动加上 ⚠️ 前缀)' }, { key: 'quark_warning_folder_names', value: '⚠️ 网盘内除您所需资源外', description: '夸克转存后自动创建的警示文件夹名(一行一个,自动加上 ⚠️ 前缀)' },
{ key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' }, { key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' },
{ key: 'link_valid_keywords', value: '链接有效', description: 'PanSou 链接有效关键词(一行一条)' },
{ key: 'link_invalid_keywords', value: '链接失效', description: 'PanSou 链接失效关键词和本地验证失效关键词(一行一条)' },
]; ];
const insert = db.prepare( const insert = db.prepare(
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'

View File

@@ -1,6 +1,6 @@
{ {
"name": "cloudsearch-frontend", "name": "cloudsearch-frontend",
"version": "1.1.8", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -351,7 +351,7 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
nickname: row.nickname || '', nickname: row.nickname || '',
promotion_account: row.promotion_account || '', promotion_account: row.promotion_account || '',
is_transfer_enabled: newVal, is_transfer_enabled: newVal,
is_active: row.is_active !== 0, is_active: row.is_active,
cookie: undefined, // don't send cookie on toggle-only cookie: undefined, // don't send cookie on toggle-only
}) })
row.is_transfer_enabled = newVal row.is_transfer_enabled = newVal
@@ -503,9 +503,9 @@ async function handleSave() {
cloud_type: form.cloud_type as CloudType, cloud_type: form.cloud_type as CloudType,
nickname: form.nickname, nickname: form.nickname,
promotion_account: form.promotion_account, promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled, is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie || undefined, cookie: form.cookie || undefined,
is_active: true, is_active: 1,
storage_used: form._storageUsed || undefined, storage_used: form._storageUsed || undefined,
storage_total: form._storageTotal || undefined, storage_total: form._storageTotal || undefined,
}) })
@@ -515,9 +515,9 @@ async function handleSave() {
cloud_type: form.cloud_type as CloudType, cloud_type: form.cloud_type as CloudType,
nickname: form.nickname, nickname: form.nickname,
promotion_account: form.promotion_account, promotion_account: form.promotion_account,
is_transfer_enabled: form.is_transfer_enabled, is_transfer_enabled: form.is_transfer_enabled ? 1 : 0,
cookie: form.cookie, cookie: form.cookie,
is_active: true, is_active: 1,
storage_used: form._storageUsed || undefined, storage_used: form._storageUsed || undefined,
storage_total: form._storageTotal || undefined, storage_total: form._storageTotal || undefined,
}) })

View File

@@ -85,15 +85,15 @@
<el-table-column type="expand" width="36"> <el-table-column type="expand" width="36">
<template #default="{ row }"> <template #default="{ row }">
<div class="expand-detail"> <div class="expand-detail">
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数 --> <!-- Row 1: 原始链接 + 文件大小 + 文件+文件 -->
<div class="detail-row"> <div class="detail-row">
<div class="detail-cell"> <div class="detail-cell" style="flex:2">
<span class="detail-label">原始链接</span> <span class="detail-label">原始链接</span>
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a> <a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
</div> </div>
<div class="detail-cell" v-if="row.original_folder_name"> <div class="detail-cell" v-if="row.file_size">
<span class="detail-label">原始文件夹名</span> <span class="detail-label">文件大小</span>
<code class="detail-code">{{ row.original_folder_name }}</code> <code class="detail-code">{{ row.file_size ? (function(n){if(n==null||n==='')return'-';var v=typeof n==='string'?parseInt(n,10):n;if(!v||v<=0)return'-';var u=['B','KB','MB','GB','TB'];var i=0,s=v;while(s>=1024&&i<4){s/=1024;i++}return s.toFixed(i===0?0:2)+' '+u[i]})(row.file_size) : '-' }}</code>
</div> </div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)"> <div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件夹</span> <span class="detail-label">文件夹</span>
@@ -108,9 +108,24 @@
<span class="reuse-msg"> 直接使用已有分享链接无需实际转存</span> <span class="reuse-msg"> 直接使用已有分享链接无需实际转存</span>
</div> </div>
</div> </div>
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 --> <!-- Row 2: 使用账号 + 原始文件夹 -->
<div class="detail-row"> <div class="detail-row">
<div class="detail-cell" v-if="row.share_url"> <div class="detail-cell" v-if="row.config_nickname">
<span class="detail-label">使用账号</span>
<el-tag size="small" type="success" effect="plain">{{ row.config_nickname }}</el-tag>
</div>
<div class="detail-cell" v-if="row.original_folder_name">
<span class="detail-label">原始文件夹名</span>
<code class="detail-code">{{ row.original_folder_name }}</code>
</div>
<div class="detail-cell" v-if="row.folder_name">
<span class="detail-label">转存文件夹</span>
<code class="detail-code">{{ row.folder_name }}</code>
</div>
</div>
<!-- Row 3: 分享链接 + 分享密码 + 耗时 -->
<div class="detail-row">
<div class="detail-cell" v-if="row.share_url" style="flex:2">
<span class="detail-label">分享链接</span> <span class="detail-label">分享链接</span>
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a> <a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
</div> </div>
@@ -118,12 +133,12 @@
<span class="detail-label">分享密码</span> <span class="detail-label">分享密码</span>
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag> <el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
</div> </div>
<div class="detail-cell" v-if="row.folder_name"> <div class="detail-cell">
<span class="detail-label">转存文件夹</span> <span class="detail-label">耗时</span>
<code class="detail-code">{{ row.folder_name }}</code> <span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
</div> </div>
</div> </div>
<!-- Row 3: IP地址 + 归属地 --> <!-- Row 4: IP地址 + 归属地 + 创建时间 -->
<div class="detail-row" v-if="row.ip_address"> <div class="detail-row" v-if="row.ip_address">
<div class="detail-cell"> <div class="detail-cell">
<span class="detail-label">IP 地址</span> <span class="detail-label">IP 地址</span>
@@ -133,12 +148,22 @@
<span class="detail-label">归属地</span> <span class="detail-label">归属地</span>
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code> <code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
</div> </div>
<div class="detail-cell">
<span class="detail-label">时间</span>
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
</div>
</div> </div>
<!-- Row 4: 错误信息整行 --> <!-- Row 5: 错误信息整行 -->
<div class="detail-row" v-if="row.status === 'failed' && row.error_message"> <div class="detail-row" v-if="row.status === 'failed' && row.error_message">
<div class="detail-cell detail-full"> <div class="detail-cell detail-full">
<span class="detail-label">错误信息</span> <span class="detail-label">错误信息</span>
<pre class="detail-error">{{ row.error_message }}</pre> <pre class="detail-error">{{ row.error_message.includes(' | ') ? row.error_message.split(' | ')[1] : row.error_message.split(' | ')[0] }}</pre>
</div>
</div>
<div class="detail-row" v-if="row.status === 'failed' && row.error_message && row.error_message.includes(' | ')">
<div class="detail-cell detail-full">
<span class="detail-label">友好提示</span>
<pre class="detail-error">{{ row.error_message.split(' | ')[0] }}</pre>
</div> </div>
</div> </div>
</div> </div>
@@ -314,7 +339,7 @@ function formatTime(t: string): string {
const d = new Date(ts) const d = new Date(ts)
if (isNaN(d.getTime())) return t if (isNaN(d.getTime())) return t
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
} }
function formatDuration(ms: number): string { function formatDuration(ms: number): string {