import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import ModernSelect from '../components/ModernSelect.js'; import { useToast } from '../components/Toast.js'; import { tr } from '../i18n.js'; type BackupType = 'all' | 'accounts' | 'preferences'; type ParsedSummary = { valid: boolean; version: string; timestampLabel: string; hasAccounts: boolean; hasPreferences: boolean; hasLegacyData: boolean; isAllApiHubV2: boolean; sitesCount: number; accountsCount: number; bookmarksCount: number; profilesCount: number; tokensCount: number; routesCount: number; channelsCount: number; siteDisabledModelsCount: number; manualModelsCount: number; downstreamApiKeysCount: number; settingsCount: number; ignoredSections: string[]; }; type WebdavConfigForm = { enabled: boolean; fileUrl: string; username: string; password: string; exportType: BackupType; autoSyncEnabled: boolean; autoSyncCron: string; hasPassword: boolean; passwordMasked: string; }; type WebdavSyncState = { lastSyncAt: string | null; lastError: string | null; }; type WebdavConfigSnapshot = { enabled: boolean; fileUrl: string; username: string; exportType: BackupType; autoSyncEnabled: boolean; autoSyncCron: string; hasPassword: boolean; }; const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = { enabled: false, fileUrl: '', username: '', password: '', exportType: 'all', autoSyncEnabled: false, autoSyncCron: '0 */6 * * *', hasPassword: false, passwordMasked: '', }; const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = { enabled: false, fileUrl: '', username: '', exportType: 'all', autoSyncEnabled: false, autoSyncCron: '0 */6 * * *', hasPassword: false, }; const WEBDAV_EXPORT_TYPE_OPTIONS = [ { value: 'all', label: '全部' }, { value: 'accounts', label: '连接与路由策略' }, { value: 'preferences', label: '系统设置' }, ] as const; const formFieldLabelStyle: React.CSSProperties = { fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)', }; const settingsInputStyle: React.CSSProperties = { width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', background: 'var(--color-bg)', color: 'var(--color-text-primary)', boxSizing: 'border-box', transition: 'border-color 0.2s', }; const webdavToggleStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', }; function downloadJsonFile(data: unknown, filename: string) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } function parseImportSummary(raw: string): ParsedSummary | null { if (!raw.trim()) return null; const invalidSummary = (): ParsedSummary => ({ valid: false, version: '-', timestampLabel: '未知', hasAccounts: false, hasPreferences: false, hasLegacyData: false, isAllApiHubV2: false, sitesCount: 0, accountsCount: 0, bookmarksCount: 0, profilesCount: 0, tokensCount: 0, routesCount: 0, channelsCount: 0, siteDisabledModelsCount: 0, manualModelsCount: 0, downstreamApiKeysCount: 0, settingsCount: 0, ignoredSections: [], }); try { const data = JSON.parse(raw) as any; if (!data || typeof data !== 'object' || Array.isArray(data)) { return invalidSummary(); } const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts)) ? data.accounts : null; const preferencesSection = (data.preferences && typeof data.preferences === 'object' && !Array.isArray(data.preferences)) ? data.preferences : null; const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts)); const legacyPrefs = Boolean(data.data?.preferences); const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0; const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0; const isNativeBoosapiBackup = Boolean( accountsSection && Array.isArray(accountsSection.sites) && Array.isArray(accountsSection.accountTokens) && Array.isArray(accountsSection.tokenRoutes) && Array.isArray(accountsSection.routeChannels) ); const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts) && accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && ( 'site_url' in row || 'site_type' in row || 'account_info' in row || 'cookieAuth' in row || 'authType' in row || 'sub2apiAuth' in row )); const isAllApiHubV2 = Boolean( accountsSection && !isNativeBoosapiBackup && hasLegacyAccountRows && Array.isArray(accountsSection.accounts) && ( (typeof data.version === 'string' && data.version.startsWith('2')) || 'last_updated' in accountsSection || Array.isArray(accountsSection.bookmarks) || Array.isArray(accountsSection.pinnedAccountIds) || Array.isArray(accountsSection.orderedAccountIds) || profilesCount > 0 ) ); const hasAccounts = Boolean( data.type === 'accounts' || accountsSection || legacyAccounts, ); const hasPreferences = Boolean( data.type === 'preferences' || preferencesSection || legacyPrefs, ); const ignoredSections: string[] = []; if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks'); if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs'); if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore'); const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0); const ts = data.timestamp !== undefined && data.timestamp !== null ? new Date(data.timestamp) : null; const timestampLabel = ts && !Number.isNaN(ts.getTime()) ? ts.toLocaleString() : '未知'; return { valid: hasAccounts || hasPreferences, version: typeof data.version === 'string' ? data.version : '1.0', timestampLabel, hasAccounts, hasPreferences, hasLegacyData: legacyAccounts || legacyPrefs, isAllApiHubV2, sitesCount: toCount(accountsSection?.sites), accountsCount: toCount(accountsSection?.accounts), bookmarksCount, profilesCount, tokensCount: toCount(accountsSection?.accountTokens), routesCount: toCount(accountsSection?.tokenRoutes), channelsCount: toCount(accountsSection?.routeChannels), siteDisabledModelsCount: toCount(accountsSection?.siteDisabledModels), manualModelsCount: toCount(accountsSection?.manualModels), downstreamApiKeysCount: toCount(accountsSection?.downstreamApiKeys), settingsCount: toCount(preferencesSection?.settings), ignoredSections, }; } catch { return invalidSummary(); } } function buildImportSuccessMessage(result: any): string { const sections: string[] = []; if (result?.sections?.accounts) sections.push('连接与路由策略'); if (result?.sections?.preferences) sections.push('系统设置'); const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`]; if (result?.summary) { const summary = result.summary; parts.push( [ `站点 ${summary.importedSites ?? 0}`, `账号 ${summary.importedAccounts ?? 0}`, `API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`, `跳过 ${summary.skippedAccounts ?? 0}`, ].join(' / '), ); if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) { parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`); } } if (Array.isArray(result?.warnings) && result.warnings.length > 0) { const preview = result.warnings.slice(0, 2).join(';'); parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`); } return parts.join(';'); } export default function ImportExport() { const toast = useToast(); const [exportingType, setExportingType] = useState(''); const [importing, setImporting] = useState(false); const [importData, setImportData] = useState(''); const [selectedFileName, setSelectedFileName] = useState(''); const [dragOver, setDragOver] = useState(false); const [webdavConfig, setWebdavConfig] = useState(DEFAULT_WEBDAV_CONFIG); const [savedWebdavConfig, setSavedWebdavConfig] = useState(DEFAULT_WEBDAV_SNAPSHOT); const [webdavState, setWebdavState] = useState({ lastSyncAt: null, lastError: null }); const [webdavSaving, setWebdavSaving] = useState(false); const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>(''); const [clearWebdavPassword, setClearWebdavPassword] = useState(false); const fileInputRef = useRef(null); const summary = useMemo(() => parseImportSummary(importData), [importData]); const buildWebdavForm = (config: any): WebdavConfigForm => ({ enabled: config.enabled === true, fileUrl: String(config.fileUrl || ''), username: String(config.username || ''), password: '', exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all', autoSyncEnabled: config.autoSyncEnabled === true, autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron), hasPassword: config.hasPassword === true, passwordMasked: String(config.passwordMasked || ''), }); const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({ enabled: config.enabled === true, fileUrl: String(config.fileUrl || ''), username: String(config.username || ''), exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all', autoSyncEnabled: config.autoSyncEnabled === true, autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron), hasPassword: config.hasPassword === true, }); const applyWebdavResponse = (result: any) => { const config = result?.config; if (config) { setWebdavConfig(buildWebdavForm(config)); setSavedWebdavConfig(buildWebdavSnapshot(config)); setClearWebdavPassword(false); } const state = result?.state || result; setWebdavState((prev) => ({ lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt, lastError: typeof state?.lastError === 'string' ? state.lastError : (state?.lastError === null ? null : prev.lastError), })); }; const webdavConfigDirty = ( webdavConfig.enabled !== savedWebdavConfig.enabled || webdavConfig.fileUrl !== savedWebdavConfig.fileUrl || webdavConfig.username !== savedWebdavConfig.username || webdavConfig.exportType !== savedWebdavConfig.exportType || webdavConfig.autoSyncEnabled !== savedWebdavConfig.autoSyncEnabled || webdavConfig.autoSyncCron !== savedWebdavConfig.autoSyncCron || webdavConfig.hasPassword !== savedWebdavConfig.hasPassword || webdavConfig.password.trim().length > 0 || clearWebdavPassword ); useEffect(() => { let alive = true; void api.getBackupWebdavConfig() .then((result: any) => { if (!alive) return; applyWebdavResponse(result); }) .catch((err: any) => { if (!alive) return; toast.error(err?.message || '加载 WebDAV 配置失败'); }); return () => { alive = false; }; }, [toast]); const handleExport = async (type: BackupType) => { setExportingType(type); try { const data = await api.exportBackup(type); const date = new Date().toISOString().split('T')[0]; const fileName: Record = { all: `boosapi-backup-${date}.json`, accounts: `boosapi-accounts-${date}.json`, preferences: `boosapi-preferences-${date}.json`, }; downloadJsonFile(data, fileName[type]); toast.success('导出成功'); } catch (err: any) { toast.error(err?.message || '导出失败'); } finally { setExportingType(''); } }; const readFile = (file: File) => { if (!file.name.endsWith('.json') && file.type !== 'application/json') { toast.error('请选择 JSON 格式的备份文件'); return; } setSelectedFileName(file.name); const reader = new FileReader(); reader.onload = (e) => { setImportData(String(e.target?.result || '')); }; reader.onerror = () => toast.error('读取文件失败'); reader.readAsText(file); }; const handleImportFile = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; readFile(file); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); const file = e.dataTransfer.files?.[0]; if (file) readFile(file); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragOver(false); }; const handleImport = async () => { if (!importData.trim()) { toast.error('请先选择或粘贴 JSON 备份内容'); return; } if (!summary?.valid) { toast.error('当前 JSON 结构无法识别'); return; } const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' ? true : window.confirm('导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?'); if (!confirmed) { return; } setImporting(true); try { const parsed = JSON.parse(importData); const result = await api.importBackup(parsed); toast.success(buildImportSuccessMessage(result)); setImportData(''); setSelectedFileName(''); } catch (err: any) { toast.error(err?.message || '导入失败'); } finally { setImporting(false); } }; const handleSaveWebdavConfig = async () => { setWebdavSaving(true); try { const nextPassword = webdavConfig.password.trim(); const payload: Record = { enabled: webdavConfig.enabled, fileUrl: webdavConfig.fileUrl, username: webdavConfig.username, exportType: webdavConfig.exportType, autoSyncEnabled: webdavConfig.autoSyncEnabled, autoSyncCron: webdavConfig.autoSyncCron, }; if (nextPassword) { payload.password = webdavConfig.password; } else if (clearWebdavPassword) { payload.clearPassword = true; } const result = await api.saveBackupWebdavConfig(payload as any); applyWebdavResponse(result); toast.success('WebDAV 配置已保存'); } catch (err: any) { toast.error(err?.message || '保存 WebDAV 配置失败'); } finally { setWebdavSaving(false); } }; const handleExportToWebdav = async () => { setWebdavAction('export'); try { const result = await api.exportBackupToWebdav(webdavConfig.exportType); applyWebdavResponse(result); toast.success(`已导出到 WebDAV:${result?.fileUrl || webdavConfig.fileUrl}`); } catch (err: any) { toast.error(err?.message || '导出到 WebDAV 失败'); } finally { setWebdavAction(''); } }; const handleImportFromWebdav = async () => { const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function' ? true : window.confirm('从 WebDAV 导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?'); if (!confirmed) return; setWebdavAction('import'); try { const result = await api.importBackupFromWebdav(); applyWebdavResponse(result); toast.success(buildImportSuccessMessage(result)); } catch (err: any) { toast.error(err?.message || '从 WebDAV 导入失败'); } finally { setWebdavAction(''); } }; return (

{tr('导入 / 导出')}

支持配置型备份、分区备份与手动恢复。
Schema v2.1 敏感数据请离线保管
{/* ====== 导出区 ====== */}
导出数据
将连接、路由策略与设置导出为 JSON 文件进行备份
{/* ====== 导入区 ====== */}
导入数据
从备份文件恢复数据
{/* ---- 拖拽上传区 ---- */}
fileInputRef.current?.click()} style={{ border: `2px dashed ${dragOver ? 'var(--color-primary)' : 'var(--color-border)'}`, borderRadius: 'var(--radius-md)', padding: '28px 20px', textAlign: 'center', cursor: 'pointer', transition: 'all 0.2s ease', background: dragOver ? 'var(--color-primary-light)' : 'var(--color-bg)', }} > {selectedFileName ? (
{selectedFileName}
点击重新选择文件
) : (
{dragOver ? '松开以导入文件' : '拖拽 JSON 备份文件到此处'}
或 点击选择文件
)}
{/* ---- 数据预览 ---- */}
数据预览