8 Commits

Author SHA1 Message Date
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
58caaae37a feat: 网盘设置加「默认账号」列, 同类型最多2个主账号
- 数据库 migration: cloud_configs 加 is_primary 字段
- 后端: togglePrimary API (PUT /admin/cloud-configs/:id/primary)
- 后端: getAndValidateCredential 优先选 is_primary 账号
- 前端: CloudConfig.vue 转存启用后加「默认账号」开关列
- 前端: api/index.ts 加 setPrimary 方法
2026-05-15 06:39:08 +08:00
abd0cb26f5 feat: IP-based daily account priority for save
- Added ip_daily_save_counts table to track per-IP daily usage

 - getAndValidateCredential now accepts ipAddress parameter

 - First 2 saves by same IP use primary account; 3rd+ round-robins to other accounts

 - Counter increments on each successful credential acquisition

 - All timestamps in Asia/Shanghai timezone
2026-05-15 06:19:04 +08:00
10 changed files with 269 additions and 73 deletions

View File

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

View File

@@ -70,14 +70,14 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
if (alreadySaved && recentRecord.share_url) {
console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`);
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
cloudType, sourceTitle || null, shareUrl, cloudType,
recentRecord.share_url, recentRecord.share_pwd || null,
null, 0, 0, 0, 'reused', null,
recentRecord.folder_name || null, recentRecord.original_folder_name || null,
ipAddress || null, ipLocation, localTimestamp(),
ipAddress || null, ipLocation, localTimestamp(), null,
);
return {
success: true,
@@ -141,7 +141,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
}
// ── Unified credential validation ──
const credential = await getAndValidateCredential(cloudType);
const credential = await getAndValidateCredential(cloudType, ipAddress);
if (!credential.valid || !credential.config) {
return { success: false, message: credential.message };
}
@@ -155,7 +155,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
const startTime = Date.now();
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) {
case 'quark': {
@@ -189,16 +189,16 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
}
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType,
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',
driverResult.success ? null : driverResult.message,
driverResult.folderName || null, driverResult.originalFolderName || null,
ipAddress || null, ipLocation, localTimestamp(),
ipAddress || null, ipLocation, localTimestamp(), config.id
);
return {
@@ -221,9 +221,9 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
).run(config.id);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).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 };
}
@@ -270,15 +270,19 @@ export function getSaveRecords(page: number = 1, pageSize: number = 20, startDat
summaryConditions.push('source_type = ?'); summaryParams.push(sourceType);
}
if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); }
const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count;
const srWhere = conditions.length > 0 ? 'WHERE sr.' + conditions.join(' AND sr.') : '';
const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${srWhere.replace(/sr\./g, '')}`).get(...params) as any).count;
const records = db.prepare(
`SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`
).all(...params, pageSize, offset) as SaveRecord[];
`SELECT sr.*, cc.nickname as config_nickname
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(
`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 }[];
let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0;
for (const r of summaryRows) {

View File

@@ -33,6 +33,7 @@ export interface CloudConfig {
is_active: number;
promotion_account?: string;
is_transfer_enabled: number;
is_primary: number;
storage_used?: string;
storage_total?: string;
checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped'
@@ -70,7 +71,7 @@ function extractQuarkUid(cookie: string): string | null {
export function getCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
@@ -81,7 +82,7 @@ export function getCloudConfigs(): CloudConfig[] {
export function getAvailableClouds(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
@@ -93,7 +94,7 @@ export function getAvailableClouds(): CloudConfig[] {
export function getCloudConfigByType(cloudType: string): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
@@ -105,7 +106,7 @@ export function getCloudConfigByType(cloudType: string): CloudConfig | undefined
export function getCloudConfigById(id: number): CloudConfig | undefined {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at, verification_status
@@ -117,7 +118,7 @@ export function getCloudConfigById(id: number): CloudConfig | undefined {
export function getActiveCloudConfigs(): CloudConfig[] {
const db = getDb();
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
@@ -126,6 +127,31 @@ export function getActiveCloudConfigs(): CloudConfig[] {
).all() as CloudConfig[];
}
/**
* Toggle the is_primary flag for a cloud config.
* Enforces max 2 primary accounts per cloud type.
*/
export function togglePrimary(id: number, setPrimary: boolean): CloudConfig {
const db = getDb();
const config = getCloudConfigById(id);
if (!config) throw new Error(`Cloud config ${id} not found`);
if (setPrimary) {
// Check how many primary accounts already exist for this cloud type
const primaryCount = db.prepare(
`SELECT COUNT(*) as c FROM cloud_configs WHERE cloud_type = ? AND is_primary = 1 AND id != ?`
).get(config.cloud_type, id) as { c: number };
if (primaryCount.c >= 2) {
throw new Error(`同类型网盘最多只能设置 2 个默认账号(已存在 ${primaryCount.c} 个)`);
}
}
db.prepare(`UPDATE cloud_configs SET is_primary = ?, updated_at = datetime('now', 'localtime') WHERE id = ?`)
.run(setPrimary ? 1 : 0, id);
return getCloudConfigById(id)!;
}
export function saveCloudConfig(data: {
id?: number;
cloud_type: string;
@@ -199,7 +225,7 @@ export function saveCloudConfig(data: {
// Re-read savedId for return
const savedId = existing.id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
@@ -215,7 +241,7 @@ export function saveCloudConfig(data: {
const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id;
return db.prepare(
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total,
`SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, is_primary, storage_used, storage_total,
cloud_type_uid,
checkin_status, last_checkin_at, checkin_message, consecutive_failures,
last_used_at, total_saves, created_at, updated_at
@@ -397,16 +423,88 @@ export interface CredentialValidationResult {
*
* Reference: search-ucmao get_and_validate_credential() pattern.
*/
export async function getAndValidateCredential(cloudType: string): Promise<CredentialValidationResult> {
export async function getAndValidateCredential(cloudType: string, ipAddress?: string): Promise<CredentialValidationResult> {
const db = getDb();
const config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
let config: CloudConfig | undefined;
if (!ipAddress) {
// No IP info — fallback to simple LUR
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// Get today's date in Shanghai time
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
// Count how many times this IP has saved today for this cloud type
const ipCountRow = db.prepare(
`SELECT COALESCE(SUM(save_count), 0) as total
FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?`
).get(ipAddress, today, cloudType) as { total: number };
const ipTodayCount = ipCountRow?.total || 0;
// How many primary accounts does this cloud type have?
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(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
} else {
// After primary threshold — exclude accounts this IP has already used today,
// fall back to other available accounts round-robin
const usedConfigIds = db.prepare(
`SELECT DISTINCT config_id FROM ip_daily_save_counts
WHERE ip_address = ? AND date = ? AND cloud_type = ?
ORDER BY config_id`
).all(ipAddress, today, cloudType) as { config_id: number }[];
const usedIds = usedConfigIds.map(r => r.config_id);
const placeholders = usedIds.length > 0 ? usedIds.map(() => '?').join(',') : '-1';
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
AND id NOT IN (${placeholders})
ORDER BY last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType, ...usedIds) as CloudConfig | undefined;
// If all accounts have been used by this IP, fall back to primary
if (!config) {
config = db.prepare(
`SELECT * FROM cloud_configs
WHERE cloud_type = ? AND is_active = 1
AND consecutive_failures < 5
ORDER BY is_primary DESC, last_used_at ASC NULLS FIRST
LIMIT 1`
).get(cloudType) as CloudConfig | undefined;
}
}
}
if (!config) {
return {
@@ -457,6 +555,21 @@ export async function getAndValidateCredential(cloudType: string): Promise<Crede
};
}
// Track IP daily usage count (if ipAddress provided)
if (ipAddress && config) {
const today = (() => {
const now = new Date();
const shanghai = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' }));
return shanghai.toISOString().slice(0, 10);
})();
db.prepare(
`INSERT INTO ip_daily_save_counts (ip_address, date, cloud_type, config_id, save_count)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(ip_address, date, cloud_type, config_id)
DO UPDATE SET save_count = save_count + 1`
).run(ipAddress, today, cloudType, config.id);
}
return {
valid: true,
config: { ...config, cookie: decryptedCookie },

View File

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

View File

@@ -28,6 +28,7 @@ export async function saveFromShare(
renamed?: string[];
fileCount?: number;
folderCount?: number;
fileSize?: number;
originalFolderName?: string;
}> {
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
? `,已重命名 ${renamed.length} 个文件`
: '';
@@ -218,6 +223,7 @@ export async function saveFromShare(
renamed: renamed.map(r => `${r.original}${r.renamed}`),
fileCount,
folderCount,
fileSize,
originalFolderName,
};
} catch (err: any) {

View File

@@ -119,6 +119,16 @@ function runMigrations(db: Database.Database): void {
source TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS ip_daily_save_counts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
date TEXT NOT NULL,
cloud_type TEXT NOT NULL,
config_id INTEGER NOT NULL,
save_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(ip_address, date, cloud_type, config_id)
);
`);
seedSystemConfigs(db);
migrateSaveRecords(db);
@@ -128,27 +138,6 @@ function runMigrations(db: Database.Database): void {
}
/** 迁移: 给已有 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 列 */
function migrateContentCache(db: Database.Database): void {
@@ -244,6 +233,21 @@ function migrateCloudConfigs(db: Database.Database): void {
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1");
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
}
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_primary%'").get();
if (!row6) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
console.log('[DB] cloud_configs migration: is_primary column added');
}
}
/** 迁移: 给 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 {
@@ -257,6 +261,12 @@ function seedAdmin(db: Database.Database): void {
'INSERT INTO admins (username, password_hash) VALUES (?, ?)'
).run(config.adminUsername, hash);
// Migration 6: Add is_primary column (default account, max 2 per cloud type)
const row6 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE %'is_primary%'").get();
if (!row6) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN is_primary INTEGER DEFAULT 0");
console.log("[DB] cloud_configs migration: is_primary column added");
}
console.log(`[DB] Admin user "${config.adminUsername}" created`);
}

View File

@@ -4,7 +4,7 @@ import fs from "fs";
import { execSync } from 'child_process';
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
import { getSaveRecords } from '../cloud/cloud.service';
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service';
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service';
// Note: check-in routes were removed (sign-in feature removed)
import { getAllCloudTypes } from '../cloud/cloud-types.service';
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
@@ -199,6 +199,20 @@ router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => {
}
});
/**
* PUT /api/admin/cloud-configs/:id/primary — toggle primary status (max 2 per type)
*/
router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const { primary } = req.body;
const config = togglePrimary(id, !!primary);
res.json(config);
} catch (err: any) {
res.status(400).json({ error: err.message || 'Failed to toggle primary status' });
}
});
/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */
router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => {
try {

View File

@@ -267,6 +267,14 @@ export async function deleteCloudConfig(
await api.delete(`/admin/cloud-configs/${id}`)
}
export async function setPrimary(
id: number,
primary: boolean
): Promise<any> {
const { data } = await api.put(`/admin/cloud-configs/${id}/primary`, { primary })
return data
}
export async function getStats(days?: number): Promise<StatsData> {
const params: Record<string, number> = {}
if (days) params.days = days

View File

@@ -99,6 +99,16 @@
/>
</template>
</el-table-column>
<el-table-column label="默认账号" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.is_primary === 1"
:disabled="!row.is_transfer_enabled"
size="small"
@change="(val: boolean) => handleTogglePrimary(row, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="390" align="center">
<template #default="{ row }">
<el-button text type="primary" @click="openDialog(row)">编辑</el-button>
@@ -171,7 +181,7 @@ import { Loading } from '@element-plus/icons-vue'
import { CLOUD_LABELS } from '../../types'
import type { CloudType, CloudConfig } from '../../types'
import { ElMessage } from 'element-plus'
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType } from '../../api'
import { getCloudConfigs, saveCloudConfig, updateCloudConfig, deleteCloudConfig, testCloudConnection, getCloudTypes, toggleCloudType, setPrimary } from '../../api'
import CloudBadge from '../../components/CloudBadge.vue'
import type { ElForm } from 'element-plus'
@@ -351,6 +361,16 @@ async function handleToggleTransfer(row: CloudConfig, enabled: boolean) {
}
}
async function handleTogglePrimary(row: CloudConfig, enabled: boolean) {
try {
await setPrimary(row.id!, enabled)
row.is_primary = enabled ? 1 : 0
ElMessage.success(enabled ? `已将「${row.nickname || row.cloud_type}」设为默认账号` : '已取消默认账号')
} catch (e: any) {
ElMessage.error(e.response?.data?.error || e.message || '操作失败')
}
}
async function autoVerifyAll() {
for (const cfg of configs.value) {
if (cfg.cookie_preview || cfg.nickname) {

View File

@@ -85,15 +85,15 @@
<el-table-column type="expand" width="36">
<template #default="{ row }">
<div class="expand-detail">
<!-- Row 1: 原始链接 + 文件夹数量 + 文件数 -->
<!-- Row 1: 原始链接 + 文件大小 + 文件+文件 -->
<div class="detail-row">
<div class="detail-cell">
<div class="detail-cell" style="flex:2">
<span class="detail-label">原始链接</span>
<a :href="row.source_url" target="_blank" class="detail-link">{{ row.source_url }}</a>
</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 class="detail-cell" v-if="row.file_size">
<span class="detail-label">文件大小</span>
<code class="detail-code">{{ formatFileSize(row.file_size) }}</code>
</div>
<div class="detail-cell" v-if="row.status !== 'reused' && (row.folder_count > 0 || row.file_count > 0)">
<span class="detail-label">文件夹</span>
@@ -108,9 +108,24 @@
<span class="reuse-msg"> 直接使用已有分享链接无需实际转存</span>
</div>
</div>
<!-- Row 2: 分享链接 + 分享密码 + 转存文件夹 -->
<!-- Row 2: 使用账号 + 原始文件夹 -->
<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>
<a :href="row.share_url" target="_blank" class="detail-link">{{ row.share_url }}</a>
</div>
@@ -118,12 +133,12 @@
<span class="detail-label">分享密码</span>
<el-tag size="small" type="warning">{{ row.share_pwd }}</el-tag>
</div>
<div class="detail-cell" v-if="row.folder_name">
<span class="detail-label">转存文件夹</span>
<code class="detail-code">{{ row.folder_name }}</code>
<div class="detail-cell">
<span class="detail-label">耗时</span>
<span :class="['detail-duration', durationClass(row.duration_ms)]">{{ formatDuration(row.duration_ms) }}</span>
</div>
</div>
<!-- Row 3: IP地址 + 归属地 -->
<!-- Row 4: IP地址 + 归属地 + 创建时间 -->
<div class="detail-row" v-if="row.ip_address">
<div class="detail-cell">
<span class="detail-label">IP 地址</span>
@@ -133,8 +148,12 @@
<span class="detail-label">归属地</span>
<code class="detail-code">{{ formatLocation(row.ip_location) }}</code>
</div>
<div class="detail-cell">
<span class="detail-label">时间</span>
<code class="detail-code">{{ formatTime(row.created_at) }}</code>
</div>
</div>
<!-- Row 4: 错误信息整行 -->
<!-- Row 5: 错误信息整行 -->
<div class="detail-row" v-if="row.status === 'failed' && row.error_message">
<div class="detail-cell detail-full">
<span class="detail-label">错误信息</span>
@@ -314,7 +333,7 @@ function formatTime(t: string): string {
const d = new Date(ts)
if (isNaN(d.getTime())) return t
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 {