diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index fc812ea..c960ee1 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -49,6 +49,10 @@ export const SettingsPanel: React.FC = () => { setActiveWebDAVConfig, setLastBackup, setLanguage, + setRepositories, + setReleases, + addCustomCategory, + deleteCustomCategory, } = useAppStore(); const [showAIForm, setShowAIForm] = useState(false); @@ -297,12 +301,106 @@ export const SettingsPanel: React.FC = () => { if (backupContent) { const backupData = JSON.parse(backupContent); - - // Note: In a real implementation, you would restore the data here - // For now, we'll just show a success message + + // 1) 恢复仓库与发布 + if (Array.isArray(backupData.repositories)) { + setRepositories(backupData.repositories); + } + if (Array.isArray(backupData.releases)) { + setReleases(backupData.releases); + } + + // 2) 恢复自定义分类(全部替换) + try { + // 先清空现有自定义分类 + if (Array.isArray(customCategories)) { + for (const cat of customCategories) { + if (cat && cat.id) { + deleteCustomCategory(cat.id); + } + } + } + // 再添加备份中的自定义分类 + if (Array.isArray(backupData.customCategories)) { + for (const cat of backupData.customCategories) { + if (cat && cat.id && cat.name) { + addCustomCategory({ ...cat, isCustom: true }); + } + } + } + } catch (e) { + console.warn('恢复自定义分类时发生问题:', e); + } + + // 3) 合并 AI 配置(保留现有密钥;备份中密钥为***时不覆盖) + try { + if (Array.isArray(backupData.aiConfigs)) { + const currentMap = new Map(aiConfigs.map((c: AIConfig) => [c.id, c])); + for (const cfg of backupData.aiConfigs as AIConfig[]) { + if (!cfg || !cfg.id) continue; + const existing = currentMap.get(cfg.id); + const isMasked = cfg.apiKey === '***'; + if (existing) { + updateAIConfig(cfg.id, { + name: cfg.name, + baseUrl: cfg.baseUrl, + model: cfg.model, + customPrompt: cfg.customPrompt, + useCustomPrompt: cfg.useCustomPrompt, + concurrency: cfg.concurrency, + // 仅当备份未掩码时才覆盖 apiKey + apiKey: isMasked ? existing.apiKey : cfg.apiKey, + // 保留现有 isActive 状态 + isActive: existing.isActive, + }); + } else { + addAIConfig({ + ...cfg, + apiKey: isMasked ? '' : cfg.apiKey, + isActive: false, + }); + } + } + } + } catch (e) { + console.warn('恢复 AI 配置时发生问题:', e); + } + + // 4) 合并 WebDAV 配置(保留现有密码;备份中密码为***时不覆盖) + try { + if (Array.isArray(backupData.webdavConfigs)) { + const currentMap = new Map(webdavConfigs.map((c: WebDAVConfig) => [c.id, c])); + for (const cfg of backupData.webdavConfigs as WebDAVConfig[]) { + if (!cfg || !cfg.id) continue; + const existing = currentMap.get(cfg.id); + const isMasked = cfg.password === '***'; + if (existing) { + updateWebDAVConfig(cfg.id, { + name: cfg.name, + url: cfg.url, + username: cfg.username, + path: cfg.path, + // 仅当备份未掩码时才覆盖密码 + password: isMasked ? existing.password : cfg.password, + // 保留现有 isActive 状态 + isActive: existing.isActive, + }); + } else { + addWebDAVConfig({ + ...cfg, + password: isMasked ? '' : cfg.password, + isActive: false, + }); + } + } + } + } catch (e) { + console.warn('恢复 WebDAV 配置时发生问题:', e); + } + alert(t( - `找到备份文件: ${latestBackup}。请注意:完整的数据恢复功能需要在实际部署中实现。`, - `Found backup file: ${latestBackup}. Note: Full data restoration needs to be implemented in actual deployment.` + `已从备份恢复数据:仓库 ${backupData.repositories?.length ?? 0},发布 ${backupData.releases?.length ?? 0},自定义分类 ${backupData.customCategories?.length ?? 0}。`, + `Data restored from backup: repositories ${backupData.repositories?.length ?? 0}, releases ${backupData.releases?.length ?? 0}, custom categories ${backupData.customCategories?.length ?? 0}.` )); } } catch (error) { diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index f238ac0..387bc1b 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -7,6 +7,71 @@ export class WebDAVService { this.config = config; } + // 压缩JSON数据,减少传输大小 + private compressData(content: string): string { + try { + const data = JSON.parse(content); + return JSON.stringify(data); + } catch (e) { + console.warn('JSON压缩失败,使用原始内容:', e); + return content; + } + } + + // 检测文件是否过大,提供优化建议 + private analyzeFileSize(content: string): { sizeKB: number; isLarge: boolean; suggestions: string[] } { + const sizeKB = Math.round(content.length / 1024); + const isLarge = sizeKB > 1024; // 超过1MB认为是大文件 + const suggestions: string[] = []; + + if (isLarge) { + suggestions.push('考虑减少备份数据量'); + if (content.length > 5 * 1024 * 1024) { // 5MB + suggestions.push('文件过大,建议启用数据筛选或分片备份'); + } + } + + return { sizeKB, isLarge, suggestions }; + } + + // 重试机制 + private async retryUpload( + operation: () => Promise, + maxRetries: number = 3, + delay: number = 1000 + ): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxRetries) { + throw lastError; + } + + // 对特定错误进行重试 + const shouldRetry = + error.message.includes('超时') || + error.message.includes('timeout') || + error.message.includes('NetworkError') || + error.message.includes('fetch'); + + if (!shouldRetry) { + throw lastError; + } + + console.warn(`上传失败,第${attempt}次重试 (${delay}ms后):`, error.message); + await new Promise(resolve => setTimeout(resolve, delay)); + delay *= 2; // 指数退避 + } + } + + throw lastError!; + } + private getAuthHeader(): string { const credentials = btoa(`${this.config.username}:${this.config.password}`); return `Basic ${credentials}`; @@ -68,13 +133,16 @@ export class WebDAVService { throw new Error('WebDAV URL必须以 http:// 或 https:// 开头'); } - // 首先尝试OPTIONS请求检查CORS + // 构建用于测试的目录URL(优先测试配置中的 path) + const dirUrl = `${this.config.url}${this.config.path}`; + + // 先尝试 HEAD 请求检测基本可达性(某些服务器对 PROPFIND/OPTIONS 支持较差) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 try { - const optionsResponse = await fetch(this.config.url, { - method: 'OPTIONS', + const headResponse = await fetch(dirUrl, { + method: 'HEAD', headers: { 'Authorization': this.getAuthHeader(), }, @@ -83,13 +151,10 @@ export class WebDAVService { clearTimeout(timeoutId); - // 如果OPTIONS成功,说明CORS配置正确 - if (optionsResponse.ok) { - return true; - } + if (headResponse.ok) return true; - // 如果OPTIONS失败,尝试PROPFIND(某些服务器不支持OPTIONS) - const propfindResponse = await fetch(this.config.url, { + // HEAD 不可用时,尝试 PROPFIND(不少服务器返回 207 Multi-Status 表示成功) + const propfindResponse = await fetch(dirUrl, { method: 'PROPFIND', headers: { 'Authorization': this.getAuthHeader(), @@ -119,51 +184,70 @@ export class WebDAVService { throw new Error('WebDAV URL必须以 http:// 或 https:// 开头'); } + // 分析文件大小并压缩数据 + const fileAnalysis = this.analyzeFileSize(content); + const compressedContent = this.compressData(content); + + if (fileAnalysis.isLarge) { + console.warn(`大文件备份 (${fileAnalysis.sizeKB}KB):`, fileAnalysis.suggestions.join(', ')); + } + + console.log(`文件大小: ${fileAnalysis.sizeKB}KB,压缩后: ${Math.round(compressedContent.length / 1024)}KB`); + // 确保目录存在 await this.ensureDirectoryExists(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时 + // 动态计算超时时间:基于压缩后文件大小,最小60秒,最大300秒 + const finalSizeKB = Math.round(compressedContent.length / 1024); + const dynamicTimeout = Math.max(60000, Math.min(300000, finalSizeKB * 100)); // 每KB 100ms + console.log(`设置超时时间: ${dynamicTimeout}ms`); + + const uploadOperation = async (): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), dynamicTimeout); + + try { + const response = await fetch(this.getFullPath(filename), { + method: 'PUT', + headers: { + 'Authorization': this.getAuthHeader(), + 'Content-Type': 'application/json', + }, + body: compressedContent, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('身份验证失败。请检查用户名和密码。'); + } + if (response.status === 403) { + throw new Error('访问被拒绝。请检查指定路径的权限。'); + } + if (response.status === 404) { + throw new Error('路径未找到。请验证WebDAV URL和路径是否正确。'); + } + if (response.status === 507) { + throw new Error('服务器存储空间不足。'); + } + throw new Error(`上传失败,HTTP状态码 ${response.status}: ${response.statusText}`); + } - try { - const response = await fetch(this.getFullPath(filename), { - method: 'PUT', - headers: { - 'Authorization': this.getAuthHeader(), - 'Content-Type': 'application/json', - }, - body: content, - signal: controller.signal, - }); - - clearTimeout(timeoutId); + return true; + } catch (fetchError) { + clearTimeout(timeoutId); - if (!response.ok) { - if (response.status === 401) { - throw new Error('身份验证失败。请检查用户名和密码。'); - } - if (response.status === 403) { - throw new Error('访问被拒绝。请检查指定路径的权限。'); - } - if (response.status === 404) { - throw new Error('路径未找到。请验证WebDAV URL和路径是否正确。'); + if (fetchError.name === 'AbortError') { + throw new Error(`上传超时 (${finalSizeKB}KB文件,${dynamicTimeout/1000}秒限制)。建议检查网络连接或联系管理员优化服务器配置。`); } - if (response.status === 507) { - throw new Error('服务器存储空间不足。'); - } - throw new Error(`上传失败,HTTP状态码 ${response.status}: ${response.statusText}`); - } - - return true; - } catch (fetchError) { - clearTimeout(timeoutId); - - if (fetchError.name === 'AbortError') { - throw new Error('上传超时。文件可能太大或网络连接缓慢。'); + + throw fetchError; } - - throw fetchError; - } + }; + + return await this.retryUpload(uploadOperation); } catch (error) { if (error.message.includes('身份验证失败') || error.message.includes('访问被拒绝') || @@ -184,17 +268,32 @@ export class WebDAVService { return; // 根目录总是存在 } - const dirPath = this.config.url + this.config.path; - const response = await fetch(dirPath, { - method: 'MKCOL', - headers: { - 'Authorization': this.getAuthHeader(), - }, - }); - - // 201 = 已创建, 405 = 已存在, 都是正常的 - if (!response.ok && response.status !== 405) { - console.warn('无法创建目录,可能已存在或权限不足'); + // 逐级创建目录,避免服务器因中间目录不存在而返回 409/403 + const cleanedPath = this.config.path.replace(/\/+$/, ''); // 去掉末尾斜杠 + const segments = cleanedPath.split('/').filter(Boolean); // 去掉空段 + let currentPath = ''; + + for (const seg of segments) { + currentPath += `/${seg}`; + const full = `${this.config.url}${currentPath}`; + try { + const res = await fetch(full, { + method: 'MKCOL', + headers: { 'Authorization': this.getAuthHeader() }, + }); + + // 201 Created(新建)或 405 Method Not Allowed(已存在)都视为成功 + if (!res.ok && res.status !== 405) { + // 某些服务器对已存在目录返回 409 Conflict + if (res.status !== 409) { + console.warn(`无法创建目录 ${currentPath},状态码: ${res.status}`); + break; // 不再继续往下建 + } + } + } catch (e) { + console.warn(`创建目录 ${currentPath} 发生异常:`, e); + break; + } } } catch (error) { console.warn('目录创建检查失败:', error); @@ -279,7 +378,11 @@ export class WebDAVService { const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时 try { - const response = await fetch(this.config.url + this.config.path, { + // 确保目录URL以斜杠结尾,避免部分服务器对集合路径的歧义 + const basePath = this.config.path.endsWith('/') ? this.config.path : `${this.config.path}/`; + const collectionUrl = `${this.config.url}${basePath}`; + + const response = await fetch(collectionUrl, { method: 'PROPFIND', headers: { 'Authorization': this.getAuthHeader(), @@ -301,12 +404,59 @@ export class WebDAVService { if (response.ok || response.status === 207) { const xmlText = await response.text(); - // 简单的XML解析提取文件名 - const fileMatches = xmlText.match(/([^<]+)<\/D:displayname>/g); - if (fileMatches) { - return fileMatches - .map(match => match.replace(/<\/?D:displayname>/g, '')) - .filter(name => name.endsWith('.json')); + + // 优先用 DOMParser 解析(更可靠,兼容 displayname 缺失的服务端) + try { + const parser = new DOMParser(); + const xml = parser.parseFromString(xmlText, 'application/xml'); + const responses = Array.from(xml.getElementsByTagNameNS('DAV:', 'response')); + + const results: string[] = []; + + for (const res of responses) { + const hrefEl = res.getElementsByTagNameNS('DAV:', 'href')[0]; + if (!hrefEl || !hrefEl.textContent) continue; + let href = hrefEl.textContent; + + // 过滤掉集合自身(目录本身) + // 有的服务返回绝对URL,有的返回相对路径,统一去比较末尾路径 + const normalizedCollection = collectionUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '/'); + const normalizedHref = href.replace(/^https?:\/\//, ''); + if (normalizedHref.endsWith(normalizedCollection)) continue; + + // 提取文件名 + try { + // 去掉末尾斜杠(目录) + href = href.replace(/\/+$/, ''); + const parts = href.split('/').filter(Boolean); + if (parts.length === 0) continue; + const last = decodeURIComponent(parts[parts.length - 1]); + if (last.toLowerCase().endsWith('.json')) { + results.push(last.trim()); + } + } catch (_e) { + // 忽略单个条目解析失败 + } + } + + if (results.length > 0) return results; + } catch (_e) { + // DOMParser 失败时降级为正则提取 href/displayname + const namesFromDisplay = (xmlText.match(/([^<]+)<\/D:displayname>/gi) || []) + .map(m => m.replace(/<\/?D:displayname>/gi, '')) + .map(s => s.trim()) + .filter(name => name.toLowerCase().endsWith('.json')); + + if (namesFromDisplay.length > 0) return namesFromDisplay; + + const namesFromHref = (xmlText.match(/([^<]+)<\/D:href>/gi) || []) + .map(m => m.replace(/<\/?D:href>/gi, '')) + .map(s => s.replace(/\/+$/, '')) + .map(s => decodeURIComponent(s.split('/').filter(Boolean).pop() || '')) + .map(s => s.trim()) + .filter(name => name.toLowerCase().endsWith('.json')); + + if (namesFromHref.length > 0) return namesFromHref; } } else if (response.status === 401) { throw new Error('身份验证失败。请检查用户名和密码。'); @@ -381,4 +531,4 @@ export class WebDAVService { return {}; } -} \ No newline at end of file +}