From 301bb63ef01b3351d0f55469693b29424fcb04ea Mon Sep 17 00:00:00 2001 From: root Date: Fri, 15 May 2026 23:08:33 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20v0.1.6=20UI=E4=BC=98=E5=8C=96=20-=20?= =?UTF-8?q?=E4=B8=A4=E5=88=97=E7=BD=91=E6=A0=BC=E5=B8=83=E5=B1=80=E3=80=81?= =?UTF-8?q?=E6=9A=97=E8=89=B2=E9=80=82=E9=85=8D=E3=80=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=B5=AE=E7=AA=97=E4=BF=9D=E5=AD=98=E3=80=81?= =?UTF-8?q?=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95=E7=BB=9F=E4=B8=80=E5=88=B0?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/package-lock.json | 77 +- packages/backend/package.json | 2 +- packages/backend/src/cloud/baidu.driver.ts | 1191 +++++++++++++++++ packages/backend/src/cloud/cleanup.service.ts | 22 +- packages/backend/src/cloud/cloud.service.ts | 91 +- packages/backend/src/cloud/database.ts | 5 + .../src/cloud/drivers/quark-cleanup.ts | 31 +- packages/backend/src/main.ts | 18 +- packages/backend/src/version.ts | 2 +- packages/frontend/package-lock.json | 4 +- packages/frontend/package.json | 4 +- packages/frontend/src/App.vue | 149 ++- .../src/pages/admin/AdminDashboard.vue | 826 +++++------- .../frontend/src/pages/admin/AdminLayout.vue | 282 +++- .../frontend/src/pages/admin/AdminLogin.vue | 99 +- packages/frontend/src/pages/admin/Cleanup.vue | 263 +++- .../frontend/src/pages/admin/CloudConfig.vue | 228 ++-- .../frontend/src/pages/admin/SaveRecords.vue | 33 +- .../frontend/src/pages/admin/SystemConfig.vue | 11 - 19 files changed, 2537 insertions(+), 801 deletions(-) create mode 100644 packages/backend/src/cloud/baidu.driver.ts diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json index 9082039..ca99dd7 100755 --- a/packages/backend/package-lock.json +++ b/packages/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloudsearch-backend", - "version": "2.0.9", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloudsearch-backend", - "version": "2.0.9", + "version": "0.1.3", "dependencies": { "bcryptjs": "^2.4.3", "better-sqlite3": "^11.0.0", @@ -22,6 +22,7 @@ "multer": "^1.4.5-lts.1", "playwright": "^1.52.0", "sharp": "^0.33.0", + "socks-proxy-agent": "^9.0.0", "uuid": "^10.0.0" }, "devDependencies": { @@ -2396,6 +2397,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3306,6 +3315,70 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-9.0.0.tgz", + "integrity": "sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==", + "dependencies": { + "agent-base": "8.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", + "integrity": "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/packages/backend/package.json b/packages/backend/package.json index 73a712c..2715f32 100755 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-backend", - "version": "0.0.3", + "version": "0.1.6", "private": true, "scripts": { "dev": "tsx watch src/main.ts", diff --git a/packages/backend/src/cloud/baidu.driver.ts b/packages/backend/src/cloud/baidu.driver.ts new file mode 100644 index 0000000..255dc88 --- /dev/null +++ b/packages/backend/src/cloud/baidu.driver.ts @@ -0,0 +1,1191 @@ +// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API) +// Uses full browser Cookie string for all operations (no OAuth access_token needed). +// Share operations use internal web API (/share/verify, /share/transfer, parse HTML). +// Reference: https://github.com/hxz393/BaiduPanFilesTransfers +// +// v4 changes from v3: +// - QR login via Playwright browser → captures full Cookie string (BDUSS + BAIDUID + STOKEN + ...) +// - getShareFiles uses Cookie HTTP: getbdstoken → verify password → GET share page → regex parse +// - transferFiles uses Cookie HTTP: POST /share/transfer +// - File list/create/delete use Cookie-based /api/* endpoints with bdstoken +// - Removed OAuth Device Code flow (no more access_token) + +import type { Browser, BrowserContext, Page } from 'playwright'; + +export interface BaiduConfig { + cookie?: string; // Full Cookie string: "BDUSS=xxx; BAIDUID=yyy; STOKEN=zzz; ..." + bdstoken?: string; // Cached bdstoken from /api/gettemplatevariable + cookieExpired?: boolean; // Flag set when cookie validation fails + nickname?: string; +} + +interface ShareFileInfo { + server_filename: string; + fs_id: string; + isdir: number; + size: number; + path: string; + category: number; +} + +interface ShareDetail { + files: ShareFileInfo[]; + childFiles: ShareFileInfo[] | null; +} + +// ═══════════════════════════════════ +// Constants +// ═══════════════════════════════════ +const API_HOST = "https://pan.baidu.com"; +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser"; +const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers + +// HTTP headers matching BaiduPanFilesTransfers +const WEB_HEADERS: Record = { + 'Host': 'pan.baidu.com', + 'Connection': 'keep-alive', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + 'Referer': 'https://pan.baidu.com', +}; + +function buildHeaders(cookie: string): Record { + if (cookie) { + return { ...WEB_HEADERS, 'Cookie': cookie }; + } + return { ...WEB_HEADERS }; +} + +// ═══════════════════════════════════ +// Playwright singleton for QR login +// ═══════════════════════════════════ +let _browser: Browser | null = null; + +async function getBrowserSingleton(): Promise { + const { chromium } = await import("playwright"); + if (!_browser || !_browser.isConnected()) { + _browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: [ + "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", + "--disable-gpu", "--disable-software-rasterizer", + "--disable-features=Vulkan", "--use-gl=swiftshader", + ], + timeout: 30000, + }); + } + return _browser; +} + +// ═══════════════════════════════════ +// QR login session store +// ═══════════════════════════════════ +const qrSessions = new Map(); + +// ═══════════════════════════════════ +// Helpers +// ═══════════════════════════════════ + +function dailyFolderName(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +async function humanDelay(): Promise { + await new Promise(r => setTimeout(r, 500 + Math.random() * 1000)); +} + +// Extract short_url from share link: /s/1XXXXX... → strip leading '1' +function extractShortUrl(shareUrl: string): { surl: string; pwd: string } | null { + try { + const url = new URL(shareUrl); + if (!url.hostname.includes('pan.baidu.com')) return null; + const pathMatch = url.pathname.match(/^\/s\/(1[a-zA-Z0-9_-]+)/); + if (!pathMatch) return null; + const surl = pathMatch[1].slice(1); + if (surl.length < 20) return null; + const pwd = url.searchParams.get('pwd') || ''; + return { surl, pwd }; + } catch { + return null; + } +} + +// Regex patterns for parsing share page HTML +const RE_SHAREID = /"shareid":(\d+?),"/; +const RE_SHARE_UK = /"share_uk":"(\d+?)","/; +const RE_FSID = /"fs_id":(\d+?),"/; +const RE_FILENAME = /"server_filename":"(.+?)","/; +const RE_ISDIR = /"isdir":(\d+?),"/; +const RE_SIZE = /"size":(\d+?),"/; +const RE_CATEGORY = /"category":(\d+?),"/; + +// ═══════════════════════════════════ +// BaiduDriver +// ═══════════════════════════════════ + +export class BaiduDriver { + private config: BaiduConfig; + + constructor(config: BaiduConfig = {}) { + this.config = { ...config }; + } + + private getCookie(): string { + return this.config.cookie || ''; + } + + private async getBdstoken(): Promise { + // Use cached if available + if (this.config.bdstoken) { + // Validate: bdstoken is typically ~32 chars hex + if (this.config.bdstoken.length > 10) return this.config.bdstoken; + } + + const cookie = this.getCookie(); + if (!cookie) return null; + + try { + const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`; + const res = await fetch(url, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + console.error('[Baidu] getBdstoken HTTP', res.status); + return null; + } + const data = await res.json() as any; + if (data.errno !== 0) { + console.error('[Baidu] getBdstoken errno:', data.errno); + // errno -6 = cookie expired / invalid + if (data.errno === -6) { + this.config.cookieExpired = true; + console.error('[Baidu] Cookie expired — user needs to re-scan QR code'); + } + return null; + } + const bdstoken = data.result?.bdstoken || ''; + if (bdstoken) { + this.config.bdstoken = bdstoken; + console.log('[Baidu] bdstoken obtained'); + return bdstoken; + } + return null; + } catch (err: any) { + console.error('[Baidu] getBdstoken error:', err.message); + return null; + } + } + + // ═══════════════════════════════════ + // QR Login — Playwright browser + // ═══════════════════════════════════ + + static async startQrLogin(): Promise<{ qrUrl?: string; sessionId: string }> { + const sessionId = "baidu_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8); + let browser: Browser | null = null; + let context: BrowserContext | null = null; + let page: Page | null = null; + + try { + browser = await getBrowserSingleton(); + context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "zh-CN", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ignoreHTTPSErrors: true, + }); + // Anti-detection + await context.addInitScript(`(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); })()`); + + page = await context.newPage(); + + // Navigate directly to passport QR login page (the actual login page with QR code) + console.log('[BaiduQR] Navigating to passport QR login...'); + await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 }); + await page.waitForTimeout(4000); + + // Check if we landed on the right page + const currentUrl = page.url(); + console.log('[BaiduQR] Current URL:', currentUrl); + + // Wait for QR code image on passport page + console.log('[BaiduQR] Waiting for QR code...'); + await page.waitForSelector('img[src*="passport.baidu.com/v2/api/qrcode"], img[src*="qrcode"]', { timeout: 20000 }); + await page.waitForTimeout(1500); + + // Extract QR code image URL + const qrImgSrc = await page.evaluate(`(() => { + const imgs = document.querySelectorAll('img'); + for (const img of imgs) { + const src = img.src || ''; + if (src.includes('passport.baidu.com/v2/api/qrcode') || (src.includes('qrcode') && img.width > 100)) { + return src; + } + } + return ''; + })()`) as string; + + if (!qrImgSrc) { + throw new Error('Could not find QR code image on page'); + } + + // Parse sign + logPage from QR image URL, construct wappass login URL + let qrContent = ''; + try { + const imgUrlObj = new URL(qrImgSrc); + const sign = imgUrlObj.searchParams.get('sign') || ''; + const logPage = imgUrlObj.searchParams.get('logPage') || ''; + const t = Math.floor(Date.now() / 1000); + qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`; + } catch { + qrContent = qrImgSrc; // fallback: raw image URL + } + + // Store session + qrSessions.set(sessionId, { + browser: browser, + context, + page, + startTime: Date.now(), + verifying: false, + }); + + console.log('[BaiduQR] Session stored:', sessionId); + return { sessionId, qrUrl: qrContent }; + + } catch (err: any) { + console.error('[BaiduQR] startQrLogin error:', err.message); + // Cleanup on error + if (page) try { await page.close(); } catch {} + if (context) try { await context.close(); } catch {} + throw new Error('启动百度扫码失败: ' + err.message); + } + // Note: browser/context/page NOT closed on success — need them for status polling + } + + static async getQrLoginStatus(sessionId: string): Promise<{ + status: string; + cookie?: string; + nickname?: string; + bdstoken?: string; + storage_used?: string; + storage_total?: string; + }> { + const session = qrSessions.get(sessionId); + if (!session) return { status: 'expired' }; + + const { page, context, browser } = session; + + // Prevent concurrent status checks (lock) + if (session.verifying) { + console.log('[BaiduQR] Status check already in progress, returning pending'); + return { status: 'pending' }; + } + session.verifying = true; + + // 300s expiry + if (Date.now() - session.startTime > 300000) { + try { await context.close(); } catch {} + session.verifying = false; + qrSessions.delete(sessionId); + return { status: 'expired' }; + } + + try { + // Check cookies for BDUSS (login indicator) + const cookies = await context.cookies(); + const hasBDUSS = cookies.some((c: any) => { + if (c.name === 'BDUSS' && c.value && c.value.length > 50) return true; + return false; + }); + + // Check page for login completion + const currentUrl = page.url(); + const bodyText = await page.evaluate(`(() => (document.body?.innerText || ''))()`) as string; + + // Login detection signals + const qrGone = !(await page.$('img[src*="qrcode"]')); + const loginSuccess = bodyText.includes('登录成功') || bodyText.includes('确认登录'); + const onPanPage = currentUrl.includes('pan.baidu.com/disk'); + + if (hasBDUSS && (qrGone || loginSuccess || onPanPage)) { + // Login detected! Wait for redirect to pan.baidu.com + console.log('[BaiduQR] Login detected, waiting for redirect...'); + + // Wait up to 15s for page to settle on pan.baidu.com + for (let i = 0; i < 15; i++) { + await page.waitForTimeout(1000); + const newUrl = page.url(); + if (newUrl.includes('pan.baidu.com') && !newUrl.includes('passport')) break; + } + + // Navigate to disk home to ensure cookies are fully set + try { + await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 }); + await page.waitForTimeout(2000); + } catch {} + + // Capture ALL cookies + const allCookies = await context.cookies(); + const cookieStr = allCookies + .map((c: any) => `${c.name}=${c.value}`) + .join('; '); + + console.log(`[BaiduQR] Login success! Got ${allCookies.length} cookies, BDUSS=${hasBDUSS}`); + + // Get nickname and bdstoken + let nickname = ''; + let bdstoken = ''; + try { + const bdres = await fetch( + `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`, + { headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) } + ); + if (bdres.ok) { + const bddata = await bdres.json() as any; + if (bddata.errno === 0) { + bdstoken = bddata.result?.bdstoken || ''; + } + } + } catch {} + + // Get storage info + let storage_used = ''; + let storage_total = ''; + if (bdstoken) { + try { + const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, { + headers: buildHeaders(cookieStr), + signal: AbortSignal.timeout(10000), + }); + if (qRes.ok) { + const qData = await qRes.json() as any; + if (qData.errno === 0) { + const fmt = (bytes: number) => { + if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB'; + if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB'; + if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB'; + return (bytes / 1024).toFixed(2) + ' KB'; + }; + storage_used = qData.used ? fmt(qData.used) : ''; + storage_total = qData.total ? fmt(qData.total) : ''; + } + } + } catch {} + } + + // Get nickname from Baidu REST API (baidu_name field) + if (bdstoken) { + try { + const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, { + headers: buildHeaders(cookieStr), + signal: AbortSignal.timeout(10000), + }); + if (uRes.ok) { + const uData = await uRes.json() as any; + if (uData.errno === 0 && uData.baidu_name) { + nickname = uData.baidu_name; + } + } + } catch {} + } + + // Cleanup + session.verifying = false; + try { await context.close(); } catch {} + qrSessions.delete(sessionId); + + return { + status: 'logged_in', + cookie: cookieStr, + nickname: nickname || '百度用户', + bdstoken, + storage_used, + storage_total, + }; + } + + // Still pending + session.verifying = false; + return { status: 'pending' }; + + } catch (err: any) { + console.error('[BaiduQR] Status check error:', err.message); + session.verifying = false; + return { status: 'pending' }; + } + } + + static cancelQrLogin(sessionId: string) { + const session = qrSessions.get(sessionId); + if (session) { + const { context } = session; + try { context.close(); } catch {} + qrSessions.delete(sessionId); + console.log('[BaiduQR] Cancelled:', sessionId); + } + } + + // ═══════════════════════════════════ + // Validate — check cookie validity + // ═══════════════════════════════════ + + async validate(): Promise { + const cookie = this.getCookie(); + if (!cookie || !cookie.includes('BDUSS')) return false; + const bdstoken = await this.getBdstoken(); + return bdstoken !== null; + } + + async getUserInfo(): Promise<{ nickname: string; usedBytes: number; totalBytes: number } | null> { + const cookie = this.getCookie(); + if (!cookie) return null; + + try { + let nickname = this.config.nickname || ''; + let usedBytes = 0; + let totalBytes = 0; + + // Try to get user info from /api/userinfo + const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (uRes.ok) { + const uData = await uRes.json() as any; + if (uData.errno === 0 && uData.records) { + nickname = uData.records[0]?.username || nickname; + } + } + + // Get quota + try { + const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (qRes.ok) { + const qData = await qRes.json() as any; + if (qData.errno === 0) { + usedBytes = qData.used || 0; + totalBytes = qData.total || 0; + } + } + } catch {} + + return { nickname, usedBytes, totalBytes }; + } catch { + return null; + } + } + + async getStorageInfo(): Promise<{ used: string; total: string }> { + const info = await this.getUserInfo(); + if (!info) return { used: '0 B', total: '0 B' }; + const fmt = (bytes: number) => { + if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB'; + if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB'; + if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB'; + return (bytes / 1024).toFixed(2) + ' KB'; + }; + return { used: fmt(info.usedBytes), total: fmt(info.totalBytes) }; + } + + // ═══════════════════════════════════ + // File list (Cookie-based) + // ═══════════════════════════════════ + + private async listRootDir(): Promise> { + const cookie = this.getCookie(); + if (!cookie) return []; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return []; + + try { + const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`; + const res = await fetch(url, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) return []; + const data = await res.json() as any; + if (data.errno === 0 && data.list) { + return data.list.map((f: any) => ({ + fid: String(f.fs_id), + file_name: f.server_filename, + dir: f.isdir === 1 || f.isdir === '1', + size: f.size || 0, + })); + } + console.error('[Baidu] listRootDir errno:', data.errno); + return []; + } catch (err: any) { + console.error('[Baidu] listRootDir error:', err.message); + return []; + } + } + + private async createDir(path: string): Promise { + const cookie = this.getCookie(); + if (!cookie) return false; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + try { + const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`; + const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' }); + const res = await fetch(url, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) return false; + const data = await res.json() as any; + if (data.errno === 0) return true; + if (data.errno === -8) return true; // already exists + console.error('[Baidu] createDir errno:', data.errno); + return false; + } catch (err: any) { + console.error('[Baidu] createDir error:', err.message); + return false; + } + } + + private async findOrCreateDir(dirName: string): Promise { + const rootItems = await this.listRootDir(); + const existing = rootItems.find(f => f.file_name === dirName && f.dir); + if (existing) return `/${dirName}`; + + const ok = await this.createDir(`/${dirName}`); + if (ok) { + console.log(`[Baidu] Created dir: ${dirName}`); + return `/${dirName}`; + } + return null; + } + + // ═══════════════════════════════════ + // Delete files + // ═══════════════════════════════════ + + private async deleteFiles(fsIds: string[]): Promise { + const cookie = this.getCookie(); + if (!cookie) return false; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + try { + const filelist = JSON.stringify(fsIds); + const body = new URLSearchParams({ async: '2', filelist }); + const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }); + if (!res.ok) return false; + const data = await res.json() as any; + return data.errno === 0; + } catch (err: any) { + console.error('[Baidu] deleteFiles error:', err.message); + return false; + } + } + + // ═══════════════════════════════════ + // Validate share link + // ═══════════════════════════════════ + + async validateShareLink(shareUrl: string): Promise<{ valid: boolean; message: string; fileCount?: number }> { + const parsed = extractShortUrl(shareUrl); + if (!parsed) return { valid: false, message: '无法解析百度网盘链接格式' }; + const { surl, pwd } = parsed; + + try { + // Try to get share file list + const shareInfo = await this.getShareFiles(surl, pwd); + if (shareInfo && shareInfo.files.length > 0) { + return { valid: true, message: `有效(${shareInfo.files.length} 个文件)`, fileCount: shareInfo.files.length }; + } + return { valid: false, message: '链接已过期或需要提取码' }; + } catch (err: any) { + return { valid: false, message: `验证失败: ${err.message}` }; + } + } + + // ═══════════════════════════════════ + // Get share file list — Cookie HTTP + // ═══════════════════════════════════ + // Flow: getbdstoken → verify password (get randsk) → update cookie with BDCLND → GET share page → regex parse + + private async getShareFiles(surl: string, pwd: string): Promise { + const cookie = this.getCookie(); + if (!cookie) { + console.log('[Baidu] No cookie available for share file listing'); + return null; + } + + const bdstoken = await this.getBdstoken(); + if (!bdstoken) { + console.log('[Baidu] No bdstoken available'); + return null; + } + + let workingCookie = cookie; + + try { + // Step 1: Verify password and get randsk (BDCLND) + if (pwd) { + console.log(`[Baidu:Share] Verifying password for surl=${surl}...`); + const t = String(Date.now()); + const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`; + const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' }); + + const vRes = await fetch(verifyUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: verifyBody.toString(), + signal: AbortSignal.timeout(10000), + }); + + if (!vRes.ok) { + console.log(`[Baidu:Share] Password verify HTTP ${vRes.status}`); + return null; + } + const vData = await vRes.json() as any; + + if (vData.errno !== 0) { + // Error codes: -9/-12 = wrong password, 105 = not found, -62 = blocked + const errMap: Record = { '-9': '提取码错误', '-12': '提取码错误', '105': '链接不存在', '-62': '访问次数过多', '2': '链接已过期', '-33': '转存失败' }; + console.log(`[Baidu:Share] Password verify failed: errno=${vData.errno} — ${errMap[vData.errno] || '未知错误'}`); + return null; + } + + const randsk = vData.randsk || ''; + if (randsk) { + // Update cookie with BDCLND=randsk (per BaiduPanFilesTransfers) + workingCookie = updateCookie(workingCookie, 'BDCLND', randsk); + console.log('[Baidu:Share] Password verified, BDCLND updated'); + } + } + + // Step 2: GET share page with updated cookie + const shareUrl = `https://pan.baidu.com/s/1${surl}`; + console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`); + const sRes = await fetch(shareUrl, { + headers: buildHeaders(workingCookie), + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + + if (!sRes.ok) { + console.log(`[Baidu:Share] Share page HTTP ${sRes.status}`); + return null; + } + + const html = await sRes.text(); + + // Check for error states + if (html.includes('页面不存在') || html.includes('你来晚了') || html.includes('链接已失效') || html.includes('分享已过期')) { + console.log('[Baidu:Share] Share link is dead/expired'); + return null; + } + + // Step 3: Parse HTML for file info using regex + const shareidMatch = html.match(RE_SHAREID); + const ukMatch = html.match(RE_SHARE_UK); + const fsIdMatches = [...html.matchAll(new RegExp(RE_FSID.source, 'g'))]; + const filenameMatches = [...html.matchAll(new RegExp(RE_FILENAME.source, 'g'))]; + const isdirMatches = [...html.matchAll(new RegExp(RE_ISDIR.source, 'g'))]; + const sizeMatches = [...html.matchAll(new RegExp(RE_SIZE.source, 'g'))]; + const categoryMatches = [...html.matchAll(new RegExp(RE_CATEGORY.source, 'g'))]; + + if (!shareidMatch || !ukMatch || fsIdMatches.length === 0) { + // Try alternative extraction from yunData in script tags + const yunMatch = html.match(/yunData\.setData\((\{[^]*?\})\);?/); + if (yunMatch) { + try { + const yunData = JSON.parse(yunMatch[1]); + if (yunData.filelist && yunData.filelist.length > 0) { + const files: ShareFileInfo[] = yunData.filelist.map((f: any) => ({ + server_filename: f.server_filename || '', + fs_id: String(f.fs_id), + isdir: f.isdir || 0, + size: f.size || 0, + path: f.path || '', + category: f.category || 0, + })); + console.log(`[Baidu:Share] Found ${files.length} file(s) via yunData`); + return { files, childFiles: null }; + } + } catch {} + } + + console.log('[Baidu:Share] Could not parse file list from page'); + return null; + } + + const count = fsIdMatches.length; + const files: ShareFileInfo[] = []; + for (let i = 0; i < count; i++) { + files.push({ + server_filename: filenameMatches[i] ? filenameMatches[i][1] : '', + fs_id: fsIdMatches[i][1], + isdir: isdirMatches[i] ? parseInt(isdirMatches[i][1]) : 0, + size: sizeMatches[i] ? parseInt(sizeMatches[i][1]) : 0, + path: '', + category: categoryMatches[i] ? parseInt(categoryMatches[i][1]) : 0, + }); + } + + console.log(`[Baidu:Share] Found ${files.length} file(s) via regex parse`); + return { files, childFiles: null }; + + } catch (err: any) { + console.error('[Baidu:Share] getShareFiles error:', err.message); + return null; + } + } + + // ═══════════════════════════════════ + // Transfer files — Cookie HTTP + // ═══════════════════════════════════ + + private async transferFiles( + surl: string, pwd: string, + fsIds: string[], destPath: string + ): Promise<{ success: boolean; taskId?: string; message: string }> { + const cookie = this.getCookie(); + if (!cookie) return { success: false, message: '未登录百度网盘' }; + + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return { success: false, message: '获取 bdstoken 失败,Cookie 可能已过期' }; + + let workingCookie = cookie; + + try { + // Step 1: Get share info from page (shareid + uk) + const shareUrl = `https://pan.baidu.com/s/1${surl}`; + + // Verify password first if needed + if (pwd) { + const t = String(Date.now()); + const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`; + const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' }); + + const vRes = await fetch(vUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: vBody.toString(), + signal: AbortSignal.timeout(10000), + }); + + if (vRes.ok) { + const vData = await vRes.json() as any; + if (vData.errno === 0 && vData.randsk) { + workingCookie = updateCookie(workingCookie, 'BDCLND', vData.randsk); + } else { + return { success: false, message: `密码验证失败 errno=${vData.errno}` }; + } + } + } + + // Get share page to extract shareid + uk + const sRes = await fetch(shareUrl, { + headers: buildHeaders(workingCookie), + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + if (!sRes.ok) return { success: false, message: `无法访问分享页面 HTTP ${sRes.status}` }; + + const html = await sRes.text(); + const shareidMatch = html.match(RE_SHAREID); + const ukMatch = html.match(RE_SHARE_UK); + + if (!shareidMatch || !ukMatch) { + return { success: false, message: '无法从页面提取分享信息' }; + } + + const shareid = shareidMatch[1]; + const uk = ukMatch[1]; + + // Step 2: Transfer + console.log(`[Baidu:Transfer] Transferring ${fsIds.length} file(s) to ${destPath}...`); + const fsidlist = `[${fsIds.join(',')}]`; + const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`; + + const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`; + const tBody = new URLSearchParams({ fsidlist, path }); + + // Retry up to 3 times for transient fetch failures + let tRes: any; + let lastErr: any; + for (let attempt = 0; attempt < 3; attempt++) { + try { + tRes = await fetch(tUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tBody.toString(), + signal: AbortSignal.timeout(30000), + }); + break; + } catch (err: any) { + lastErr = err; + if (attempt < 2) { + console.log(`[Baidu:Transfer] Attempt ${attempt + 1} failed: ${err.message}, retrying in 2s...`); + await new Promise(r => setTimeout(r, 2000)); + } + } + } + if (!tRes) return { success: false, message: `转存网络错误: ${lastErr?.message || 'fetch failed'}` }; + + if (!tRes.ok) return { success: false, message: `转存请求失败 HTTP ${tRes.status}` }; + + const tData = await tRes.json() as any; + + if (tData.errno === 0) { + console.log(`[Baidu:Transfer] Success!`); + return { success: true, taskId: `transfer_${Date.now()}`, message: 'ok' }; + } + + // Known error codes + const errMap: Record = { + 0: '转存成功', + 2: '目标目录不存在', + 4: '目录中存在同名文件', + 12: '转存文件数超过限制', + 20: '容量不足', + '-4': '登录失效,请重新登录', + '-6': 'Cookie 无效,请重新获取', + '-62': '访问次数过多,请稍后再试', + // -33 is a known error, mapped as generic + }; + + const errMsg = errMap[tData.errno] || `errno=${tData.errno}`; + console.error(`[Baidu:Transfer] Failed: ${errMsg}`); + return { success: false, message: errMsg }; + + } catch (err: any) { + console.error('[Baidu:Transfer] Error:', err.message); + return { success: false, message: `转存异常: ${err.message}` }; + } + } + + // ═══════════════════════════════════ + // Main saveFromShare — full pipeline + // ═══════════════════════════════════ + + async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{ + success: boolean; + message: string; + shareUrl?: string; + sharePwd?: string; + folderName?: string; + taskId?: string; + fileCount?: number; + folderCount?: number; + originalFolderName?: string; + }> { + const parsed = extractShortUrl(shareUrl); + if (!parsed) return { success: false, message: '无法解析百度网盘链接格式' }; + + const { surl, pwd } = parsed; + console.log(`[Baidu] saveFromShare: surl=${surl}, pwd=${pwd ? '***' : '(none)'}`); + + // Step 1: Get share file list + const shareInfo = await this.getShareFiles(surl, pwd); + if (!shareInfo || shareInfo.files.length === 0) { + if ((this.config as any).cookieExpired) { + return { + success: false, + message: '百度登录已过期,请重新扫码登录', + cookieExpired: true, + } as any; + } + return { success: false, message: '获取分享文件列表失败,链接可能已过期或需要提取码' }; + } + + const { files } = shareInfo; + const originalFolderName = files[0]?.server_filename || ''; + const fileCount = files.filter(f => !f.isdir).length; + const folderCount = files.filter(f => f.isdir).length; + + // Step 2: Create/find daily folder, then sub-folder with original name + await humanDelay(); + const saveDirName = dailyFolderName(); + console.log(`[Baidu] Creating/finding dir: ${saveDirName}`); + const saveDirPath = await this.findOrCreateDir(saveDirName); + let destPath = saveDirPath || '/'; + if (!saveDirPath) { + console.log(`[Baidu] WARNING: failed to create dir, saving to root`); + } + + // Create sub-folder with original name under date folder + let savedFolderName = saveDirName; + if (originalFolderName && saveDirPath) { + const subDirName = originalFolderName.replace(/[/\\:*?"<>|]/g, '_').substring(0, 100); + const subDirPath = `${saveDirPath}/${subDirName}`; + const subOk = await this.createDir(subDirPath); + if (subOk) { + destPath = subDirPath; + savedFolderName = `${saveDirName}/${subDirName}`; + console.log(`[Baidu] Created sub-folder: ${subDirName}`); + } else { + console.log(`[Baidu] Failed to create sub-folder, saving to ${destPath}`); + } + } + + // Step 3: Transfer files + await humanDelay(); + const fsIds = files.map(f => f.fs_id); + console.log(`[Baidu] Transferring ${fsIds.length} file(s) to ${destPath}`); + const transferResult = await this.transferFiles(surl, pwd, fsIds, destPath); + if (!transferResult.success) { + return { success: false, message: `转存失败: ${transferResult.message}`, fileCount, folderCount, originalFolderName }; + } + + console.log(`[Baidu] Save complete: ${fsIds.length} files -> ${destPath}`); + + // Step 4: Create share link from user's own drive + let ownShareUrl = ''; + let ownSharePwd = ''; + let shareMsg = ''; + try { + // Find the saved directory to get its fs_id for sharing + const savedDir = await this.findDirByPath(destPath); + if (savedDir) { + const shareResult = await this.createShareLink(savedDir); + if (shareResult.success && shareResult.shareUrl) { + ownShareUrl = shareResult.shareUrl; + ownSharePwd = shareResult.sharePwd || ''; + shareMsg = '(已创建分享链接)'; + } else if (shareResult.needVerify) { + // Account needs verification to create shares + shareMsg = '(你的百度账号需要实名/绑定手机才能创建分享链接,当前为源链接)'; + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } else { + shareMsg = `(分享创建失败:${shareResult.message},当前为源链接)`; + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + } else { + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + } catch { + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + + return { + success: true, + message: `✅ 转存成功${shareMsg}`, + shareUrl: ownShareUrl || undefined, + sharePwd: ownSharePwd || undefined, + folderName: savedFolderName, + taskId: transferResult.taskId, + fileCount, + folderCount, + originalFolderName, + }; + } + + // ═══════════════════════════════════ + // Share creation (user's own drive) + // ═══════════════════════════════════ + + /** Find a directory by path, return its fs_id */ + private async findDirByPath(dirPath: string): Promise { + const cookie = this.getCookie(); + if (!cookie) return null; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return null; + + const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')) || '/'; + const dirName = dirPath.substring(dirPath.lastIndexOf('/') + 1); + + try { + const res = await fetch( + `${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`, + { headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) } + ); + if (!res.ok) return null; + const data = await res.json() as any; + if (data.errno !== 0) return null; + const found = (data.list || []).find((f: any) => f.server_filename === dirName && f.isdir === 1); + return found ? String(found.fs_id) : null; + } catch { + return null; + } + } + + /** Create a share link from a file/directory in user's own drive */ + private async createShareLink(fsId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string; needVerify?: boolean }> { + const cookie = this.getCookie(); + if (!cookie) return { success: false, message: '未登录' }; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return { success: false, message: '获取 bdstoken 失败' }; + + try { + // Generate a random 4-char share password (required by Baidu share/set API) + const pwd = Math.random().toString(36).substring(2, 6); + const body = new URLSearchParams({ + fid_list: `[${fsId}]`, + schannel: '0', + channel_list: '[]', + period: '0', + pwd, + }); + + const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`; + const res = await fetch(url, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + + const data = await res.json() as any; + + if (data.errno === 0) { + // Success — extract shareid and link from response + const shareid = data.shareid; + if (shareid) { + const link = data.link || `https://pan.baidu.com/s/1${shareid}`; + return { success: true, shareUrl: link, sharePwd: pwd, message: 'ok' }; + } + return { success: false, message: '创建成功但未获取到分享链接' }; + } + + if (data.errno === 115) { + // Account genuinely restricted (should not happen with correct pwd param) + return { success: false, message: '账号异常,禁止分享', needVerify: true }; + } + + return { success: false, message: data.show_msg || `分享创建失败 errno=${data.errno}` }; + } catch (err: any) { + return { success: false, message: err.message || '网络错误' }; + } + } + + // ═══════════════════════════════════ + // Cleanup + // ═══════════════════════════════════ + + async emptyTrash(): Promise { + // Cookie-based approach: list recycle and delete + const cookie = this.getCookie(); + if (!cookie) return false; + + try { + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + // We don't have a dedicated trash API with Cookie, use /api/list with recycle parameter + // For now, skip if trash is empty + console.log('[Baidu] emptyTrash: not fully implemented for Cookie yet, skipping'); + return true; + } catch (err: any) { + console.error('[Baidu] emptyTrash error:', err.message); + return false; + } + } + + async cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + try { + const rootItems = await this.listRootDir(); + const oldFolders = rootItems.filter(item => { + if (!item.dir) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; + if (whitelistDirs?.includes(item.file_name)) return false; + return item.file_name < cutoffStr; + }); + + if (oldFolders.length === 0) return { trashed: 0, errors: [] }; + + const fsIds = oldFolders.map(f => f.fid); + console.log(`[Baidu] Deleting ${fsIds.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`); + const ok = await this.deleteFiles(fsIds); + if (ok) return { trashed: fsIds.length, errors: [] }; + return { trashed: 0, errors: [`删除 ${fsIds.length} 个文件夹失败`] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } + } + + async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + try { + const info = await this.getUserInfo(); + if (!info || info.totalBytes <= 0) return { trashed: 0, errors: [] }; + + const usagePercent = (info.usedBytes / info.totalBytes) * 100; + if (usagePercent < thresholdPercent) { + console.log(`[Baidu] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`); + return { trashed: 0, errors: [] }; + } + + const targetBytesToFree = Math.floor(info.totalBytes * Math.min(deletePercent, 100) / 100); + const rootItems = await this.listRootDir(); + const dateFolders = rootItems + .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) + .filter(item => !whitelistDirs?.includes(item.file_name)) + .sort((a, b) => a.file_name.localeCompare(b.file_name)); + + if (dateFolders.length === 0) return { trashed: 0, errors: [] }; + + const avgSize = info.usedBytes / dateFolders.length; + const estCount = Math.max(1, Math.ceil(targetBytesToFree / (avgSize || 1))); + const foldersToTrash = dateFolders.slice(0, Math.min(estCount, dateFolders.length)); + + const freedMB = (foldersToTrash.length * (avgSize || 0) / 1024 / 1024).toFixed(0); + const fsIdsToTrash = foldersToTrash.map(f => f.fid); + console.log(`[Baidu] Space threshold: deleting ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB)`); + + const ok = await this.deleteFiles(fsIdsToTrash); + if (ok) return { trashed: foldersToTrash.length, errors: [] }; + return { trashed: 0, errors: ['空间阈值清理失败'] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } + } +} + +// ═══════════════════════════════════ +// Utility: update Cookie string +// ═══════════════════════════════════ + +function updateCookie(cookieStr: string, key: string, value: string): string { + const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s); + let found = false; + const updated = pairs.map(p => { + const eq = p.indexOf('='); + if (eq > 0 && p.substring(0, eq) === key) { + found = true; + return `${key}=${value}`; + } + return p; + }); + if (!found) { + updated.push(`${key}=${value}`); + } + return updated.join('; '); +} diff --git a/packages/backend/src/cloud/cleanup.service.ts b/packages/backend/src/cloud/cleanup.service.ts index 1c836c0..e8be01d 100755 --- a/packages/backend/src/cloud/cleanup.service.ts +++ b/packages/backend/src/cloud/cleanup.service.ts @@ -19,7 +19,7 @@ interface CleanupOpResult { trashed: number; errors: string[] } interface CloudCleanupDriver { /** Trash date folders (YYYY-MM-DD) older than `days`. */ - cleanupOldDateFolders(days: number): Promise; + cleanupOldDateFolders(days: number, whitelistDirs?: string[]): Promise; /** * If used space exceeds thresholdPercent% of TOTAL capacity, * delete oldest date folders until totalBytes * deletePercent/100 @@ -27,7 +27,7 @@ interface CloudCleanupDriver { * @param thresholdPercent — trigger when usage >= this % of total * @param deletePercent — free this % of total capacity */ - cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise; + cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number, whitelistDirs?: string[]): Promise; /** Permanently empty the recycle bin. */ emptyTrash(): Promise; } @@ -62,6 +62,18 @@ interface CleanupStats { errors: string[]; } +/** Read whitelist dirs from system_configs (cleanup_whitelist_dirs). */ +export function getWhitelistDirs(): string[] { + const raw = getSystemConfig('cleanup_whitelist_dirs'); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((d: any) => typeof d === 'string') : []; + } catch { + return []; + } +} + /** Get all active cloud configs (any type). Used by the orchestrator. */ function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }> { const db = getDb(); @@ -77,6 +89,7 @@ function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cook */ async function cleanupCloudFiles(days: number): Promise { const configs = getActiveCleanupConfigs(); + const whitelistDirs = getWhitelistDirs(); const errors: string[] = []; let totalTrashed = 0; @@ -87,7 +100,7 @@ async function cleanupCloudFiles(days: number): Promise { continue; } try { - const result = await driver.cleanupOldDateFolders(days); + const result = await driver.cleanupOldDateFolders(days, whitelistDirs); totalTrashed += result.trashed; errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); } catch (err: any) { @@ -108,6 +121,7 @@ async function cleanupAllBySpaceThreshold( deletePercent: number, ): Promise { const configs = getActiveCleanupConfigs(); + const whitelistDirs = getWhitelistDirs(); const errors: string[] = []; let totalTrashed = 0; @@ -118,7 +132,7 @@ async function cleanupAllBySpaceThreshold( continue; } try { - const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent); + const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent, whitelistDirs); totalTrashed += result.trashed; errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); } catch (err: any) { diff --git a/packages/backend/src/cloud/cloud.service.ts b/packages/backend/src/cloud/cloud.service.ts index 802963f..356aec0 100644 --- a/packages/backend/src/cloud/cloud.service.ts +++ b/packages/backend/src/cloud/cloud.service.ts @@ -322,31 +322,86 @@ export function cleanupOldSaveRecords(): void { // ── Storage Refresh ─────────────────────────────────────────────── export async function refreshAllStorageInfo(): Promise { - const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie); + const configs = getActiveCloudConfigs().filter(c => c.cookie); if (configs.length === 0) return; + const verifyCookies = getSystemConfig('cleanup_verify_enabled') === 'true'; + for (const cfg of configs) { try { - const { QuarkDriver } = require('./drivers/quark.driver'); - const driver = new QuarkDriver({ cookie: decrypt(cfg.cookie!), nickname: cfg.nickname }); - const storage = await driver.getStorageInfo( - decrypt(cfg.cookie!), - (fullUsed: string, total: string) => { - const db = getDb(); - db.prepare( - `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` - ).run(fullUsed, total, cfg.id); - console.log(`[Storage] Background calibration done for quark#${cfg.id}: ${fullUsed} / ${total}`); + const db = getDb(); + const decryptedCookie = decrypt(cfg.cookie!); + + switch (cfg.cloud_type) { + case 'quark': { + const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: cfg.nickname }); + + // Get storage info (includes background calibration callback) + const storage = await driver.getStorageInfo( + (fullUsed: string, total: string) => { + const dbInner = getDb(); + dbInner.prepare( + `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` + ).run(fullUsed, total, cfg.id); + console.log(`[Storage] Background calibration done for quark#${cfg.id}: ${fullUsed} / ${total}`); + } + ); + if (storage.totalBytes > 0 || storage.usedBytes > 0) { + db.prepare( + `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` + ).run(storage.used, storage.total, cfg.id); + console.log(`[Storage] Updated quark#${cfg.id}: ${storage.used} / ${storage.total}`); + } + + // Cookie verification + if (verifyCookies) { + const valid = await driver.validate(); + db.prepare( + `UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?` + ).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id); + console.log(`[Storage] Verification for quark#${cfg.id}: ${valid ? 'valid' : 'invalid'}`); + } + break; } - ); - if (storage.totalBytes > 0 || storage.usedBytes > 0) { - const db = getDb(); - db.prepare( - `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` - ).run(storage.used, storage.total, cfg.id); + + case 'baidu': { + const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: cfg.nickname }); + + // Get storage info + const storage = await driver.getStorageInfo(); + if (storage.used !== '0 B' && storage.total !== '0 B') { + db.prepare( + `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` + ).run(storage.used, storage.total, cfg.id); + console.log(`[Storage] Updated baidu#${cfg.id}: ${storage.used} / ${storage.total}`); + } + + // Cookie verification + if (verifyCookies) { + const valid = await driver.validate(); + db.prepare( + `UPDATE cloud_configs SET verification_status = ?, updated_at = ? WHERE id = ?` + ).run(valid ? 'valid' : 'invalid', localTimestamp(), cfg.id); + console.log(`[Storage] Verification for baidu#${cfg.id}: ${valid ? 'valid' : 'invalid'}`); + } + break; + } + + default: + console.log(`[Storage] Skipping ${cfg.cloud_type}#${cfg.id} — unsupported cloud type for storage refresh`); + break; } } catch (err: any) { - console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message); + console.error(`[Storage] Failed to refresh ${cfg.cloud_type}#${cfg.id}:`, err.message); + // On error, mark as invalid if verification is enabled + if (verifyCookies) { + try { + const db = getDb(); + db.prepare( + `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` + ).run(localTimestamp(), cfg.id); + } catch {} + } } } } \ No newline at end of file diff --git a/packages/backend/src/cloud/database.ts b/packages/backend/src/cloud/database.ts index 829f073..460d6d0 100755 --- a/packages/backend/src/cloud/database.ts +++ b/packages/backend/src/cloud/database.ts @@ -306,6 +306,11 @@ function seedSystemConfigs(db: Database.Database): void { { key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' }, { key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' }, { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, + { key: 'cleanup_whitelist_dirs', value: '[]', description: '清理白名单目录名称列表(JSON数组),这些目录不会被自动清理' }, + { key: 'storage_refresh_interval', value: '60', description: '存储空间刷新间隔(分钟),0=不自动刷新' }, + { key: 'cleanup_auto_refresh_storage', value: 'true', description: '启用自动刷新存储空间信息' }, + { key: 'cleanup_verify_enabled', value: 'true', description: '定期验证网盘 Cookie 有效性(随存储刷新一起执行)' }, + { key: 'cleanup_verify_interval', value: '30', description: 'Cookie 有效性检测间隔(分钟)' }, { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, ]; diff --git a/packages/backend/src/cloud/drivers/quark-cleanup.ts b/packages/backend/src/cloud/drivers/quark-cleanup.ts index 0556588..d4ccddf 100644 --- a/packages/backend/src/cloud/drivers/quark-cleanup.ts +++ b/packages/backend/src/cloud/drivers/quark-cleanup.ts @@ -38,15 +38,33 @@ export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string } } - // Quick used-space estimate: sum root-level file sizes + subdir sizes + // Accurate used space via /member API (1 call, no full traversal needed) + // Ref: pan.quark.cn/1/clouddrive/member returns use_capacity + total_capacity let usedBytes = 0; try { - const rootFiles = await listRootDir(cookie); - for (const f of rootFiles) { - usedBytes += f.size || 0; + const memberParams = new URLSearchParams({ pr: 'ucpro', fr: 'pc', uc_param_str: '', __t: String(Date.now()), __dt: '1000' }); + const memberResp = await fetch(`https://pan.quark.cn/1/clouddrive/member?${memberParams.toString()}`, { + headers: getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (memberResp.ok) { + const memberData = await memberResp.json() as any; + if (memberData.status === 200 && memberData.data?.use_capacity != null) { + usedBytes = memberData.data.use_capacity; + } } } catch {} + // Fallback: sum root-level file sizes (夸克 folders return size=0) + if (usedBytes === 0) { + try { + const rootFiles = await listRootDir(cookie); + for (const f of rootFiles) { + usedBytes += f.size || 0; + } + } catch {} + } + // Cache the result (3h window) const currentHourBlock = Math.floor(new Date().getHours() / 3); storageCache.bytes = usedBytes; @@ -231,7 +249,7 @@ export async function emptyTrash(cookie: string): Promise { /** * Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`. */ -export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> { +export async function cleanupOldDateFolders(cookie: string, days: number, whitelistDirs?: string[]): Promise<{ trashed: number; errors: string[] }> { const errors: string[] = []; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); @@ -242,6 +260,7 @@ export async function cleanupOldDateFolders(cookie: string, days: number): Promi const oldFolders = rootItems.filter(item => { if (!item.dir) return false; if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; + if (whitelistDirs && whitelistDirs.includes(item.file_name)) return false; return item.file_name < cutoffStr; }); @@ -270,6 +289,7 @@ export async function cleanupBySpaceThreshold( cookie: string, thresholdPercent: number, deletePercent: number, + whitelistDirs?: string[], ): Promise<{ trashed: number; errors: string[] }> { const errors: string[] = []; @@ -288,6 +308,7 @@ export async function cleanupBySpaceThreshold( const rootItems = await listRootDir(cookie); const dateFolders = rootItems .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) + .filter(item => !whitelistDirs || !whitelistDirs.includes(item.file_name)) .sort((a, b) => a.file_name.localeCompare(b.file_name)); if (dateFolders.length === 0) return { trashed: 0, errors: [] }; diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 5ec0d7a..cb9d0ee 100755 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -177,10 +177,20 @@ async function start(): Promise { setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL); setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000); - // Storage info refresh scheduler — every 60 minutes - const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000; - setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL); - setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000); + // Storage info refresh scheduler — configurable via system_configs + const { getSystemConfig: getSysCfg } = require('./admin/system-config.service'); + const storageRefreshEnabled = getSysCfg('cleanup_auto_refresh_storage') !== 'false'; + const storageRefreshMin = parseInt(getSysCfg('storage_refresh_interval') || '60', 10) || 60; + const storageRefreshMs = storageRefreshMin * 60 * 1000; + const verifyCookies = getSysCfg('cleanup_verify_enabled') === 'true'; + + if (!storageRefreshEnabled || storageRefreshMin === 0) { + console.log(`[Storage] Auto-refresh disabled (enabled=${storageRefreshEnabled}, interval=${storageRefreshMin} min)`); + } else { + console.log(`[Storage] Auto-refresh every ${storageRefreshMin} minutes (verifyCookies=${verifyCookies})`); + setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, storageRefreshMs); + setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000); + } const server = app.listen(config.port, () => { console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`); diff --git a/packages/backend/src/version.ts b/packages/backend/src/version.ts index 7304fd1..696dac3 100644 --- a/packages/backend/src/version.ts +++ b/packages/backend/src/version.ts @@ -9,4 +9,4 @@ * 修改此文件的同时请同步更新后端 package.json 中的 version 字段。 */ -export const APP_VERSION = "0.1.1"; +export const APP_VERSION = "0.1.6"; diff --git a/packages/frontend/package-lock.json b/packages/frontend/package-lock.json index 1151d4e..cb15190 100755 --- a/packages/frontend/package-lock.json +++ b/packages/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloudsearch-frontend", - "version": "1.1.8", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloudsearch-frontend", - "version": "1.1.8", + "version": "0.1.1", "dependencies": { "@element-plus/icons-vue": "^2.3.1", "axios": "^1.7.0", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e12c49e..332da6d 100755 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cloudsearch-frontend", - "version": "0.1.1", + "version": "0.1.6", "private": true, "type": "module", "scripts": { @@ -26,4 +26,4 @@ "vite": "^5.4.0", "vue-tsc": "^2.1.0" } -} \ No newline at end of file +} diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index 82b46a4..fa8b9eb 100755 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -26,43 +26,107 @@ onMounted(() => { diff --git a/packages/frontend/src/pages/admin/AdminDashboard.vue b/packages/frontend/src/pages/admin/AdminDashboard.vue index f4a9f68..91cd628 100644 --- a/packages/frontend/src/pages/admin/AdminDashboard.vue +++ b/packages/frontend/src/pages/admin/AdminDashboard.vue @@ -1,178 +1,182 @@ @@ -233,14 +237,11 @@ watch(() => stats.value.trendTrend, () => { watch(() => activeMenu.value, (val, oldVal) => { if (val === 'dashboard' && oldVal !== 'dashboard') { nextTick(() => { - // v-show keeps DOM alive; chart instance may need to be re-initialized - // after being hidden (ECharts can lose its canvas when container has display:none) const el = document.querySelector('.trend-chart-echarts') as HTMLElement | null if (el && el.childElementCount === 0) { renderTrendChart() initTrendChart() } else if (el) { - // Chart still exists, just resize initTrendChart() } }) @@ -384,7 +385,7 @@ const pageTitle = computed(() => { return '系统配置 — ' + (sysSectionTitles[activeMenu.value] || '') } if (activeMenu.value === 'cloud-configs-toggle' || activeMenu.value === 'cloud-configs-cleanup') { - return '网盘配置' + return '网盘管理' } return pageTitles[activeMenu.value] || '管理后台' }) @@ -481,218 +482,60 @@ async function handleCloudToggle(type: string, enabled: boolean) { diff --git a/packages/frontend/src/pages/admin/AdminLayout.vue b/packages/frontend/src/pages/admin/AdminLayout.vue index 4b1e263..1642a78 100644 --- a/packages/frontend/src/pages/admin/AdminLayout.vue +++ b/packages/frontend/src/pages/admin/AdminLayout.vue @@ -1,56 +1,75 @@ @@ -58,7 +77,7 @@ diff --git a/packages/frontend/src/pages/admin/AdminLogin.vue b/packages/frontend/src/pages/admin/AdminLogin.vue index f5c7ec9..f27d295 100755 --- a/packages/frontend/src/pages/admin/AdminLogin.vue +++ b/packages/frontend/src/pages/admin/AdminLogin.vue @@ -1,7 +1,12 @@ diff --git a/packages/frontend/src/pages/admin/Cleanup.vue b/packages/frontend/src/pages/admin/Cleanup.vue index 0c865ce..17d332c 100644 --- a/packages/frontend/src/pages/admin/Cleanup.vue +++ b/packages/frontend/src/pages/admin/Cleanup.vue @@ -2,53 +2,108 @@
- - - -
- 每天自动检查一次,将过期文件移入回收站、删除旧日志、清空回收站释放空间 -
-
- - -
超过此天数的日期文件夹将被移入回收站
-
- - -
超过此天数的转存记录将被删除
-
- - -
移入回收站后自动清空,永久删除文件以释放存储空间
-
- 空间阈值自动清理 - - -
已用空间超过阈值时,按比例删除最旧的转存文件(优先级高于保留天数)
-
- - -
已用空间超过此百分比时触发强制清理
-
- - -
触发清理时释放总空间的百分比(如 10% 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)
-
- 分享链接复用 - - -
相同原始链接不再重复转存,复用已有分享链接(会验证原链接有效性;60秒内重复请求直接返回已有链接)
-
-
- 手动操作 -
- 💾 保存清理配置 - {{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }} - {{ emptyTrashRunning ? '清空中...' : '🧹 立即清空回收站' }} +
+ +
+
⏱ 基础清理策略
+ + +
+ + 每天自动检查一次,删除过期日志、移入回收站文件 +
+
+ +
+ + 清理时一并清空各网盘回收站 +
+
+ +
+
+ {{ dir }} +
+
+ + 添加 +
+
+
+
+
+ + +
+
📦 保留设置
+ + +
+ + +
+
+ +
+ + +
+
+ +
+ + 分钟 +
+
+ +
+ + 分钟 +
+
+
+
+ + +
+
📊 空间阈值自动清理
+ + + + 已用空间超过阈值时,按比例删除最旧的转存文件 + + + + + + + + +
+ + +
+
🔗 分享链接复用
+ + + + 相同原始链接不再重复转存,复用已有分享链接(会自动验证原链接有效性;60秒内重复请求直接返回已有链接) + + +
-
- ⏰ 上次清理:{{ lastCleanupTime }} - 📊 {{ lastCleanupStats }} + + +
+
+ 💾 保存清理配置 + {{ cleanupRunning ? '清理中...' : '🗑️ 立即清理' }} + {{ emptyTrashRunning ? '清空中...' : '🧹 清空回收站' }} +
+
+ ⏰ 上次清理:{{ lastCleanupTime }} + 📊 {{ lastCleanupStats }} +
@@ -116,12 +171,51 @@ const saveReuseEnabled = computed({ set: (val: boolean) => { sysConfigs.save_reuse_enabled = val ? 'true' : 'false' }, }) +// 白名单 +const whitelistDirs = ref([]) +const newWhitelistDir = ref('') + +function loadWhitelistDirs() { + try { + const raw = String(sysConfigs.cleanup_whitelist_dirs || '[]') + whitelistDirs.value = JSON.parse(raw) + } catch { + whitelistDirs.value = [] + } +} + +function addWhitelistDir() { + const name = newWhitelistDir.value.trim() + if (!name) return + if (whitelistDirs.value.includes(name)) { + ElMessage.warning('该目录已在白名单中') + return + } + whitelistDirs.value.push(name) + newWhitelistDir.value = '' +} + +function removeWhitelistDir(index: number) { + whitelistDirs.value.splice(index, 1) +} + +// Cookie检测间隔 + 空间校准间隔 +const verifyIntervalMinutes = computed({ + get: () => Number(sysConfigs.cleanup_verify_interval ?? 30), + set: (val: number) => { sysConfigs.cleanup_verify_interval = val }, +}) +const storageRefreshIntervalMinutes = computed({ + get: () => Number(sysConfigs.storage_refresh_interval ?? 180), + set: (val: number) => { sysConfigs.storage_refresh_interval = val }, +}) + async function loadCleanupConfigs() { try { const raw = await getSystemConfigs() for (const cfg of raw) { sysConfigs[cfg.key] = cfg.value } + loadWhitelistDirs() } catch (e) { console.error('加载清理配置失败', e) } @@ -130,8 +224,15 @@ async function loadCleanupConfigs() { async function handleSaveCleanupConfigs() { cleanupSaving.value = true try { - const keys = ['cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days', 'cleanup_empty_trash', 'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent', 'save_reuse_enabled'] + const keys = [ + 'cleanup_enabled', 'cleanup_file_retention_days', 'cleanup_log_retention_days', + 'cleanup_empty_trash', + 'cleanup_space_threshold_enabled', 'cleanup_space_threshold_percent', 'cleanup_space_threshold_delete_percent', + 'save_reuse_enabled', + 'cleanup_verify_interval', 'storage_refresh_interval', + ] const entries = keys.map(key => ({ key, value: String(sysConfigs[key] ?? '') })) + entries.push({ key: 'cleanup_whitelist_dirs', value: JSON.stringify(whitelistDirs.value) }) await updateSystemConfigs(entries) ElMessage.success('清理配置已保存') } catch (e: any) { @@ -181,14 +282,74 @@ onMounted(() => { diff --git a/packages/frontend/src/pages/admin/CloudConfig.vue b/packages/frontend/src/pages/admin/CloudConfig.vue index 1f14a1a..29f9bc5 100755 --- a/packages/frontend/src/pages/admin/CloudConfig.vue +++ b/packages/frontend/src/pages/admin/CloudConfig.vue @@ -29,11 +29,13 @@ 全部重新验证
- - - + + + + + + + + + + - - - {{ form._verifying ? '验证中...' : '🔍 自动获取(验证 Cookie 并回填信息)' }} - - -
- + + +
+ @@ -1097,16 +1096,6 @@ async function handleRemoveLogo() { background: #f0f0f0; object-fit: contain; } -.save-bar { - position: sticky; - bottom: 0; - background: var(--bg-white); - padding: 16px 0; - border-top: 1px solid var(--border-color); - margin-top: 24px; - display: flex; - justify-content: flex-end; padding-right: 24px; gap: 12px; -} /* ── 搜索策略 3列网格 ── */ .strategy-grid {