| @ -0,0 +1,420 @@ | |||||
| // import MemoryStorage from './storage/MemoryStorage.js'; | |||||
| import LocalStorage from './storage/LocalStorage.js'; | |||||
| // import SessionStorage from './storage/SessionStorage.js'; | |||||
| import IndexedDBStorage from './storage/IndexedDBStorage.js'; | |||||
| import ServerStorage from './storage/ServerStorage.js'; | |||||
| /** | |||||
| * 自动保存管理器 | |||||
| * 协调各种存储方式,处理冲突合并 | |||||
| */ | |||||
| class AutoSaveManager { | |||||
| constructor(key, serverOptions = {}) { | |||||
| this.key = key; | |||||
| try { | |||||
| this.storages = { | |||||
| // memory: new MemoryStorage(key), | |||||
| localStorage: new LocalStorage(key), | |||||
| // sessionStorage: new SessionStorage(key), | |||||
| indexedDB: new IndexedDBStorage(key), | |||||
| server: new ServerStorage(key, serverOptions) | |||||
| }; | |||||
| } catch (error) { | |||||
| console.error('初始化存储失败:', error); | |||||
| // 如果某个存储初始化失败,只使用可用的存储 | |||||
| this.storages = { | |||||
| // memory: new MemoryStorage(key), | |||||
| localStorage: new LocalStorage(key), | |||||
| // sessionStorage: new SessionStorage(key) | |||||
| }; | |||||
| } | |||||
| this.states = {}; | |||||
| this.initStates(); | |||||
| this.isSaving = false; | |||||
| this.saveTimer = null; | |||||
| this.debounceDelay = 1000;// 防抖延迟 | |||||
| this.currentContent = ''; | |||||
| this.lastSavedContent = ''; | |||||
| // 异步初始化 | |||||
| this.initPromise = this.init(); | |||||
| } | |||||
| /** | |||||
| * 初始化状态对象 | |||||
| */ | |||||
| initStates() { | |||||
| for (const type in this.storages) { | |||||
| this.states[type] = { | |||||
| status: 'idle', | |||||
| lastSaved: null, | |||||
| error: null, | |||||
| initialized: false | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 等待初始化完成 | |||||
| */ | |||||
| async waitForInit() { | |||||
| await this.initPromise; | |||||
| } | |||||
| /** | |||||
| * 初始化管理器 | |||||
| */ | |||||
| async init() { | |||||
| try { | |||||
| // 加载所有存储的数据 | |||||
| await this.loadFromAllStorages(); | |||||
| // 设置页面关闭前保存 | |||||
| this.setupBeforeUnload(); | |||||
| // 设置页面崩溃检测 | |||||
| this.setupCrashDetection(); | |||||
| console.log(`AutoSaveManager 初始化完成,key: ${this.key}`); | |||||
| } catch (error) { | |||||
| console.error('AutoSaveManager 初始化失败:', error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从所有存储加载数据并合并 | |||||
| */ | |||||
| async loadFromAllStorages() { | |||||
| const results = {}; | |||||
| // 并行加载所有存储 | |||||
| const promises = Object.entries(this.storages).map(async ([type, storage]) => { | |||||
| try { | |||||
| // 确保存储已初始化 | |||||
| if (storage.waitForInit) { | |||||
| await storage.waitForInit(); | |||||
| } | |||||
| const result = await storage.load(); | |||||
| results[type] = result; | |||||
| this.updateState(type, 'loaded', { | |||||
| ...result, | |||||
| initialized: true | |||||
| }); | |||||
| return result; | |||||
| } catch (error) { | |||||
| console.error(`加载${type}存储失败:`, error); | |||||
| this.updateState(type, 'error', { | |||||
| error: error.message, | |||||
| initialized: false | |||||
| }); | |||||
| return null; | |||||
| } | |||||
| }); | |||||
| await Promise.allSettled(promises); | |||||
| // 选择最新版本的内容 | |||||
| // this.currentContent = this.resolveConflicts(results); | |||||
| this.lastSavedContent = this.currentContent; | |||||
| return this.currentContent; | |||||
| } | |||||
| /** | |||||
| * 解决冲突:选择最新版本的内容 | |||||
| * @param {object} results - 所有存储的加载结果 | |||||
| * @returns {string} 合并后的内容 | |||||
| */ | |||||
| resolveConflicts(results) { | |||||
| let latestContent = ''; | |||||
| let latestTimestamp = 0; | |||||
| Object.entries(results).forEach(([type, result]) => { | |||||
| if (result && result.success && result.data) { | |||||
| let data = result.data; | |||||
| let timestamp = 0; | |||||
| let content = ''; | |||||
| if (typeof data === 'object' && data !== null) { | |||||
| timestamp = data.timestamp || data.lastModified || 0; | |||||
| content = data.content || ''; | |||||
| } else if (typeof data === 'string') { | |||||
| content = data; | |||||
| timestamp = Date.now(); // 字符串数据给一个默认时间戳 | |||||
| } | |||||
| if (timestamp > latestTimestamp) { | |||||
| latestTimestamp = timestamp; | |||||
| latestContent = content; | |||||
| } | |||||
| } | |||||
| }); | |||||
| return latestContent || ''; | |||||
| } | |||||
| /** | |||||
| * 保存内容(防抖处理) | |||||
| * @param {string} content - 要保存的内容 | |||||
| */ | |||||
| save(content) { | |||||
| this.currentContent = content; | |||||
| // 如果内容没有变化,不保存 | |||||
| if (content === this.lastSavedContent) { | |||||
| return; | |||||
| } | |||||
| // 清除之前的定时器 | |||||
| if (this.saveTimer) { | |||||
| clearTimeout(this.saveTimer); | |||||
| } | |||||
| // 设置新的定时器 | |||||
| this.saveTimer = setTimeout(() => { | |||||
| this.doSave(content); | |||||
| }, this.debounceDelay); | |||||
| } | |||||
| /** | |||||
| * 执行保存操作 | |||||
| * @param {string} content - 要保存的内容 | |||||
| */ | |||||
| async doSave(content) { | |||||
| if (this.isSaving) { | |||||
| return; | |||||
| } | |||||
| this.isSaving = true; | |||||
| this.lastSavedContent = content; | |||||
| try { | |||||
| // 并行保存到所有存储 | |||||
| const promises = Object.entries(this.storages).map(async ([type, storage]) => { | |||||
| try { | |||||
| // 检查存储是否可用 | |||||
| if (!storage || this.states[type]?.error) { | |||||
| this.updateState(type, 'error', { | |||||
| error: '存储不可用', | |||||
| retry: true | |||||
| }); | |||||
| return null; | |||||
| } | |||||
| this.updateState(type, 'saving'); | |||||
| const result = await storage.save(content); | |||||
| if (result && result.success) { | |||||
| this.updateState(type, 'saved', result); | |||||
| } else { | |||||
| this.updateState(type, 'error', { | |||||
| error: result?.error || '保存失败', | |||||
| retry: true | |||||
| }); | |||||
| } | |||||
| return result; | |||||
| } catch (error) { | |||||
| console.error(`保存到${type}失败:`, error); | |||||
| this.updateState(type, 'error', { | |||||
| error: error.message, | |||||
| retry: true | |||||
| }); | |||||
| return null; | |||||
| } | |||||
| }); | |||||
| await Promise.allSettled(promises); | |||||
| } finally { | |||||
| this.isSaving = false; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 更新存储状态 | |||||
| * @param {string} type - 存储类型 | |||||
| * @param {string} status - 状态 | |||||
| * @param {object} data - 相关数据 | |||||
| */ | |||||
| updateState(type, status, data = {}) { | |||||
| this.states[type] = { | |||||
| ...this.states[type], | |||||
| status, | |||||
| ...data, | |||||
| lastUpdated: Date.now() | |||||
| }; | |||||
| if (status === 'saved' && data.timestamp) { | |||||
| this.states[type].lastSaved = data.timestamp; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取所有存储状态 | |||||
| * @returns {object} 状态对象 | |||||
| */ | |||||
| getAllStates() { | |||||
| return { ...this.states }; | |||||
| } | |||||
| /** | |||||
| * 获取指定存储的历史版本 | |||||
| * @param {string} storageType - 存储类型 | |||||
| * @returns {Promise<Array>} 历史版本列表 | |||||
| */ | |||||
| async getHistory(storageType) { | |||||
| if (this.storages[storageType]) { | |||||
| return await this.storages[storageType].getHistory(); | |||||
| } | |||||
| return { success: false, error: '存储类型不存在' }; | |||||
| } | |||||
| /** | |||||
| * 从历史版本恢复 | |||||
| * @param {string} storageType - 存储类型 | |||||
| * @param {object} versionData - 版本数据 | |||||
| */ | |||||
| async restoreFromHistory(storageType, versionData) { | |||||
| if (this.storages[storageType]) { | |||||
| const content = versionData.content || versionData; | |||||
| await this.doSave(content); | |||||
| this.currentContent = content; | |||||
| return { success: true, content }; | |||||
| } | |||||
| return { success: false, error: '存储类型不存在' }; | |||||
| } | |||||
| /** | |||||
| * 设置页面关闭前保存 | |||||
| */ | |||||
| setupBeforeUnload() { | |||||
| window.addEventListener('beforeunload', (event) => { | |||||
| if (this.currentContent !== this.lastSavedContent) { | |||||
| // 同步保存(不防抖) | |||||
| this.doSaveSync(this.currentContent); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 同步保存(用于页面关闭前) | |||||
| * @param {string} content - 要保存的内容 | |||||
| */ | |||||
| async doSaveSync(content) { | |||||
| // 只保存到本地存储,确保数据不丢失 | |||||
| const localStorages = ['memory', 'localStorage', 'sessionStorage']; | |||||
| localStorages.forEach(async (type) => { | |||||
| try { | |||||
| await this.storages[type].save(content); | |||||
| } catch (error) { | |||||
| console.error(`页面关闭前保存到${type}失败:`, error); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 设置页面崩溃检测 | |||||
| */ | |||||
| setupCrashDetection() { | |||||
| // 使用 Page Visibility API 检测页面状态 | |||||
| document.addEventListener('visibilitychange', () => { | |||||
| if (document.visibilityState === 'visible') { | |||||
| // 页面恢复显示,检查是否需要同步 | |||||
| this.checkAndSync(); | |||||
| } | |||||
| }); | |||||
| // 监听页面冻结和恢复 | |||||
| document.addEventListener('freeze', () => { | |||||
| localStorage.setItem(`pageFrozen_${this.key}`, Date.now()); | |||||
| }); | |||||
| document.addEventListener('resume', () => { | |||||
| const frozenTime = localStorage.getItem(`pageFrozen_${this.key}`); | |||||
| if (frozenTime && Date.now() - frozenTime > 5000) { | |||||
| // 页面被冻结超过5秒,可能是崩溃恢复 | |||||
| this.showCrashNotification(); | |||||
| } | |||||
| localStorage.removeItem(`pageFrozen_${this.key}`); | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 检查并同步数据 | |||||
| */ | |||||
| async checkAndSync() { | |||||
| // 重新加载所有数据并合并 | |||||
| await this.loadFromAllStorages(); | |||||
| // 如果有服务器存储,尝试同步离线队列 | |||||
| if (this.storages.server) { | |||||
| this.storages.server.processQueue(); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 显示崩溃通知 | |||||
| */ | |||||
| showCrashNotification() { | |||||
| const notification = document.createElement('div'); | |||||
| notification.style.cssText = ` | |||||
| position: fixed; | |||||
| top: 20px; | |||||
| right: 20px; | |||||
| background: #ff4444; | |||||
| color: white; | |||||
| padding: 15px; | |||||
| border-radius: 5px; | |||||
| z-index: 10000; | |||||
| box-shadow: 0 2px 10px rgba(0,0,0,0.2); | |||||
| `; | |||||
| notification.innerHTML = ` | |||||
| <strong>页面恢复</strong><br> | |||||
| 检测到页面可能发生了异常,已自动恢复您的数据。 | |||||
| `; | |||||
| document.body.appendChild(notification); | |||||
| setTimeout(() => { | |||||
| notification.remove(); | |||||
| }, 5000); | |||||
| } | |||||
| /** | |||||
| * 获取备份文件列表 | |||||
| * @returns {Promise<Array>} 备份文件列表 | |||||
| */ | |||||
| async getBackupFiles() { | |||||
| const backupPromises = Object.entries(this.storages).map(async ([type, storage]) => { | |||||
| if (typeof storage.getBackupFiles === 'function') { | |||||
| try { | |||||
| const result = await storage.getBackupFiles(); | |||||
| return { type, ...result }; | |||||
| } catch (error) { | |||||
| return { type, success: false, error: error.message }; | |||||
| } | |||||
| } | |||||
| return { type, success: false, error: '不支持备份管理' }; | |||||
| }); | |||||
| const results = await Promise.all(backupPromises); | |||||
| return results; | |||||
| } | |||||
| /** | |||||
| * 立即保存当前内容 | |||||
| */ | |||||
| async saveImmediately(currentContent) { | |||||
| if (this.saveTimer) { | |||||
| clearTimeout(this.saveTimer); | |||||
| } | |||||
| await this.doSave(currentContent); | |||||
| } | |||||
| } | |||||
| export default AutoSaveManager; | |||||
| @ -0,0 +1,703 @@ | |||||
| /** | |||||
| * IndexedDB存储策略 - 修复复合索引问题 | |||||
| */ | |||||
| class IndexedDBStorage { | |||||
| constructor(key) { | |||||
| this.key = key; | |||||
| this.dbName = 'hxhq'; | |||||
| this.storeName = 'textContents'; | |||||
| this.historyStoreName = 'textHistory'; | |||||
| this.db = null; | |||||
| this.isInitialized = false; | |||||
| this.initPromise = this.initDB(); | |||||
| } | |||||
| /** | |||||
| * 初始化IndexedDB数据库 | |||||
| */ | |||||
| async initDB() { | |||||
| return new Promise((resolve, reject) => { | |||||
| if (!window.indexedDB) { | |||||
| reject(new Error('浏览器不支持IndexedDB')); | |||||
| return; | |||||
| } | |||||
| const request = indexedDB.open(this.dbName, 5); // 升级版本到5 | |||||
| request.onerror = (event) => { | |||||
| console.error('IndexedDB打开失败:', event.target.error); | |||||
| reject(event.target.error); | |||||
| }; | |||||
| request.onsuccess = (event) => { | |||||
| this.db = event.target.result; | |||||
| this.isInitialized = true; | |||||
| console.log('IndexedDB初始化成功'); | |||||
| // 延迟检查以确保事务完成 | |||||
| setTimeout(() => { | |||||
| this.ensureStoresExist() | |||||
| .then(() => resolve()) | |||||
| .catch(error => { | |||||
| console.warn('确保存储存在失败,但继续初始化:', error); | |||||
| resolve(); // 即使失败也继续 | |||||
| }); | |||||
| }, 100); | |||||
| }; | |||||
| request.onupgradeneeded = (event) => { | |||||
| console.log('IndexedDB升级:', '旧版本:', event.oldVersion, '新版本:', event.newVersion); | |||||
| const db = event.target.result; | |||||
| const transaction = event.target.transaction; | |||||
| this.createOrUpgradeStores(db, transaction, event.oldVersion); | |||||
| }; | |||||
| request.onblocked = (event) => { | |||||
| console.warn('IndexedDB升级被阻塞'); | |||||
| }; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 创建或升级对象存储(修复版) | |||||
| */ | |||||
| createOrUpgradeStores(db, transaction, oldVersion) { | |||||
| // 创建主数据存储 | |||||
| if (!db.objectStoreNames.contains(this.storeName)) { | |||||
| const store = db.createObjectStore(this.storeName, { keyPath: 'key' }); | |||||
| store.createIndex('timestamp', 'timestamp', { unique: false }); | |||||
| console.log('创建主存储:', this.storeName); | |||||
| } | |||||
| // 创建历史数据存储 | |||||
| if (!db.objectStoreNames.contains(this.historyStoreName)) { | |||||
| const historyStore = db.createObjectStore(this.historyStoreName, { | |||||
| keyPath: 'id', | |||||
| autoIncrement: true | |||||
| }); | |||||
| historyStore.createIndex('timestamp', 'timestamp', { unique: false }); | |||||
| historyStore.createIndex('key', 'key', { unique: false }); | |||||
| // 不再创建唯一性复合索引,避免冲突 | |||||
| console.log('创建历史存储:', this.historyStoreName); | |||||
| } else if (oldVersion < 5) { | |||||
| // 版本5升级:删除可能存在的唯一性索引,避免冲突 | |||||
| const historyStore = transaction.objectStore(this.historyStoreName); | |||||
| // 删除旧的复合索引(如果存在) | |||||
| if (historyStore.indexNames.contains('key_version')) { | |||||
| try { | |||||
| historyStore.deleteIndex('key_version'); | |||||
| console.log('删除冲突的key_version索引'); | |||||
| } catch (error) { | |||||
| console.warn('删除索引失败:', error); | |||||
| } | |||||
| } | |||||
| // 添加非唯一的复合索引 | |||||
| if (!historyStore.indexNames.contains('key_version_composite')) { | |||||
| try { | |||||
| historyStore.createIndex('key_version_composite', ['key', 'version'], { unique: false }); | |||||
| console.log('创建非唯一的key_version_composite索引'); | |||||
| } catch (error) { | |||||
| console.warn('创建非唯一索引失败:', error); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 确保对象存储存在 | |||||
| */ | |||||
| async ensureStoresExist() { | |||||
| if (!this.db) { | |||||
| throw new Error('数据库未初始化'); | |||||
| } | |||||
| const missingStores = [this.storeName, this.historyStoreName] | |||||
| .filter(name => !this.db.objectStoreNames.contains(name)); | |||||
| if (missingStores.length > 0) { | |||||
| console.warn('缺少对象存储:', missingStores); | |||||
| throw new Error(`缺少对象存储: ${missingStores.join(', ')}`); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 等待数据库初始化完成 | |||||
| */ | |||||
| async waitForInit() { | |||||
| try { | |||||
| await this.initPromise; | |||||
| } catch (error) { | |||||
| console.error('数据库初始化失败:', error); | |||||
| throw new Error('IndexedDB初始化失败: ' + error.message); | |||||
| } | |||||
| if (!this.isInitialized || !this.db) { | |||||
| throw new Error('数据库未正确初始化'); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 保存数据到IndexedDB(修复版) | |||||
| */ | |||||
| async save(content) { | |||||
| try { | |||||
| await this.waitForInit(); | |||||
| const timestamp = Date.now(); | |||||
| const version = await this.getNextVersion(); | |||||
| const data = { | |||||
| key: this.key, | |||||
| content, | |||||
| timestamp, | |||||
| version, | |||||
| lastModified: timestamp | |||||
| }; | |||||
| // 保存当前版本 | |||||
| await this.putData(this.storeName, data); | |||||
| // 保存到历史记录 - 使用时间戳作为ID的一部分确保唯一性 | |||||
| const historyId = `history_${this.key}_${timestamp}_${Math.random().toString(36).substr(2, 9)}`; | |||||
| const historyData = { | |||||
| ...data, | |||||
| id: historyId, | |||||
| historyTimestamp: timestamp, | |||||
| uniqueId: historyId // 确保唯一性 | |||||
| }; | |||||
| await this.putData(this.historyStoreName, historyData); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'indexedDB', | |||||
| historyId | |||||
| }; | |||||
| } catch (error) { | |||||
| console.error('IndexedDB保存失败:', error); | |||||
| // 优雅降级到LocalStorage | |||||
| return await this.saveToFallback(content, error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 保存到后备存储(LocalStorage) | |||||
| */ | |||||
| async saveToFallback(content, originalError) { | |||||
| try { | |||||
| const timestamp = Date.now(); | |||||
| const version = 1; // 后备存储使用简单版本号 | |||||
| // 保存到LocalStorage | |||||
| const fallbackKey = `indexedDB_fallback_${this.key}`; | |||||
| localStorage.setItem(fallbackKey, JSON.stringify({ | |||||
| content, | |||||
| timestamp, | |||||
| version, | |||||
| originalError: originalError?.message | |||||
| })); | |||||
| // 保存历史 | |||||
| this.saveFallbackHistory(content, timestamp); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'indexedDB_fallback', | |||||
| warning: '数据已保存到LocalStorage作为后备', | |||||
| originalError: originalError?.message | |||||
| }; | |||||
| } catch (fallbackError) { | |||||
| console.error('后备存储也失败:', fallbackError); | |||||
| return { | |||||
| success: false, | |||||
| error: `主存储和后备存储都失败: ${originalError?.message}, ${fallbackError.message}`, | |||||
| storage: 'indexedDB' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 保存后备历史记录 | |||||
| */ | |||||
| saveFallbackHistory(content, timestamp) { | |||||
| try { | |||||
| const historyKey = `indexedDB_history_${this.key}`; | |||||
| const historyStr = localStorage.getItem(historyKey); | |||||
| let history = historyStr ? JSON.parse(historyStr) : []; | |||||
| history.unshift({ | |||||
| content, | |||||
| timestamp, | |||||
| version: history.length + 1, | |||||
| fromFallback: true | |||||
| }); | |||||
| // 限制历史记录数量 | |||||
| if (history.length > 10) { | |||||
| history = history.slice(0, 10); | |||||
| } | |||||
| localStorage.setItem(historyKey, JSON.stringify(history)); | |||||
| } catch (error) { | |||||
| console.error('保存后备历史失败:', error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 将数据存入指定存储 | |||||
| */ | |||||
| async putData(storeName, data) { | |||||
| return new Promise((resolve, reject) => { | |||||
| try { | |||||
| // 检查对象存储是否存在 | |||||
| if (!this.db.objectStoreNames.contains(storeName)) { | |||||
| reject(new Error(`对象存储 ${storeName} 不存在`)); | |||||
| return; | |||||
| } | |||||
| const transaction = this.db.transaction([storeName], 'readwrite'); | |||||
| const store = transaction.objectStore(storeName); | |||||
| // 添加错误处理 | |||||
| transaction.onerror = (event) => { | |||||
| console.error(`事务错误 [${storeName}]:`, event.target.error); | |||||
| reject(event.target.error); | |||||
| }; | |||||
| transaction.oncomplete = () => { | |||||
| resolve(); | |||||
| }; | |||||
| const request = store.put(data); | |||||
| request.onsuccess = () => { | |||||
| // 不要在这里resolve,等待事务完成 | |||||
| console.log(`数据已保存到 ${storeName}:`, data.key); | |||||
| }; | |||||
| request.onerror = (event) => { | |||||
| console.error(`保存请求失败 [${storeName}]:`, event.target.error); | |||||
| reject(event.target.error); | |||||
| }; | |||||
| } catch (error) { | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 从IndexedDB加载数据 | |||||
| */ | |||||
| async load() { | |||||
| try { | |||||
| await this.waitForInit(); | |||||
| // 首先尝试从IndexedDB加载 | |||||
| const data = await this.loadFromIndexedDB(); | |||||
| if (data) { | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'indexedDB' | |||||
| }; | |||||
| } | |||||
| // 如果没有数据,尝试从后备存储加载 | |||||
| return await this.loadFromFallback(); | |||||
| } catch (error) { | |||||
| console.error('加载数据失败:', error); | |||||
| return await this.loadFromFallbackWithError(error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从IndexedDB加载 | |||||
| */ | |||||
| async loadFromIndexedDB() { | |||||
| return new Promise((resolve, reject) => { | |||||
| try { | |||||
| if (!this.db.objectStoreNames.contains(this.storeName)) { | |||||
| resolve(null); | |||||
| return; | |||||
| } | |||||
| const transaction = this.db.transaction([this.storeName], 'readonly'); | |||||
| const store = transaction.objectStore(this.storeName); | |||||
| const request = store.get(this.key); | |||||
| request.onsuccess = () => { | |||||
| resolve(request.result || null); | |||||
| }; | |||||
| request.onerror = (event) => { | |||||
| console.error('从IndexedDB加载失败:', event.target.error); | |||||
| reject(event.target.error); | |||||
| }; | |||||
| } catch (error) { | |||||
| reject(error); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 从后备存储加载 | |||||
| */ | |||||
| async loadFromFallback() { | |||||
| try { | |||||
| const fallbackKey = `indexedDB_fallback_${this.key}`; | |||||
| const fallbackStr = localStorage.getItem(fallbackKey); | |||||
| if (fallbackStr) { | |||||
| const data = JSON.parse(fallbackStr); | |||||
| console.log('从后备存储加载数据'); | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'indexedDB_fallback', | |||||
| fromFallback: true | |||||
| }; | |||||
| } | |||||
| return { | |||||
| success: true, | |||||
| data: null, | |||||
| storage: 'indexedDB', | |||||
| message: '无数据' | |||||
| }; | |||||
| } catch (error) { | |||||
| console.error('从后备存储加载失败:', error); | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'indexedDB_fallback' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 带错误的从后备存储加载 | |||||
| */ | |||||
| async loadFromFallbackWithError(originalError) { | |||||
| try { | |||||
| const result = await this.loadFromFallback(); | |||||
| if (result.success && result.data) { | |||||
| return { | |||||
| ...result, | |||||
| originalError: originalError?.message | |||||
| }; | |||||
| } | |||||
| return { | |||||
| success: false, | |||||
| error: `加载失败: ${originalError?.message}`, | |||||
| storage: 'indexedDB' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: `主存储和后备存储都失败: ${originalError?.message}, ${error.message}`, | |||||
| storage: 'indexedDB' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取历史版本(修复版) | |||||
| */ | |||||
| async getHistory(limit = 100) { | |||||
| try { | |||||
| await this.waitForInit(); | |||||
| // 首先尝试从IndexedDB获取历史 | |||||
| const indexedDBHistory = await this.getHistoryFromIndexedDB(limit); | |||||
| if (indexedDBHistory.success && indexedDBHistory.data.length > 0) { | |||||
| return indexedDBHistory; | |||||
| } | |||||
| // 如果没有历史,尝试从后备存储获取 | |||||
| return await this.getHistoryFromFallback(limit); | |||||
| } catch (error) { | |||||
| console.error('获取历史失败:', error); | |||||
| return await this.getHistoryFromFallback(limit); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从IndexedDB获取历史 | |||||
| */ | |||||
| async getHistoryFromIndexedDB(limit) { | |||||
| return new Promise((resolve) => { | |||||
| try { | |||||
| if (!this.db.objectStoreNames.contains(this.historyStoreName)) { | |||||
| resolve({ | |||||
| success: true, | |||||
| data: [], | |||||
| storage: 'indexedDB', | |||||
| message: '历史存储不存在' | |||||
| }); | |||||
| return; | |||||
| } | |||||
| const transaction = this.db.transaction([this.historyStoreName], 'readonly'); | |||||
| const store = transaction.objectStore(this.historyStoreName); | |||||
| // 尝试使用key索引 | |||||
| let index; | |||||
| if (store.indexNames.contains('key')) { | |||||
| index = store.index('key'); | |||||
| } else { | |||||
| // 如果没有key索引,使用全表扫描 | |||||
| index = null; | |||||
| } | |||||
| const history = []; | |||||
| const request = index ? | |||||
| index.openCursor(IDBKeyRange.only(this.key), 'prev') : | |||||
| store.openCursor(null, 'prev'); | |||||
| request.onsuccess = (event) => { | |||||
| const cursor = event.target.result; | |||||
| if (cursor) { | |||||
| const item = cursor.value; | |||||
| // 过滤出当前key的历史记录 | |||||
| if (item.key === this.key && history.length < limit) { | |||||
| history.push(item); | |||||
| } | |||||
| cursor.continue(); | |||||
| } else { | |||||
| // 按时间戳排序 | |||||
| history.sort((a, b) => b.timestamp - a.timestamp); | |||||
| resolve({ | |||||
| success: true, | |||||
| data: history, | |||||
| storage: 'indexedDB' | |||||
| }); | |||||
| } | |||||
| }; | |||||
| request.onerror = (event) => { | |||||
| console.error('从IndexedDB获取历史失败:', event.target.error); | |||||
| resolve({ | |||||
| success: false, | |||||
| error: event.target.error?.message, | |||||
| storage: 'indexedDB' | |||||
| }); | |||||
| }; | |||||
| } catch (error) { | |||||
| resolve({ | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'indexedDB' | |||||
| }); | |||||
| } | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 从后备存储获取历史 | |||||
| */ | |||||
| async getHistoryFromFallback(limit) { | |||||
| try { | |||||
| const historyKey = `indexedDB_history_${this.key}`; | |||||
| const historyStr = localStorage.getItem(historyKey); | |||||
| const history = historyStr ? JSON.parse(historyStr) : []; | |||||
| return { | |||||
| success: true, | |||||
| data: history.slice(0, limit), | |||||
| storage: 'indexedDB_fallback', | |||||
| fromFallback: true | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'indexedDB_fallback' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取下一个版本号 | |||||
| */ | |||||
| async getNextVersion() { | |||||
| try { | |||||
| const result = await this.load(); | |||||
| if (result.success && result.data) { | |||||
| const data = result.data; | |||||
| return (data.version || 0) + 1; | |||||
| } | |||||
| return 1; | |||||
| } catch (error) { | |||||
| console.error('获取版本号失败:', error); | |||||
| return 1; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 清理重复的历史记录 | |||||
| */ | |||||
| async cleanupDuplicateHistory() { | |||||
| try { | |||||
| await this.waitForInit(); | |||||
| if (!this.db.objectStoreNames.contains(this.historyStoreName)) { | |||||
| return { success: true, cleaned: 0 }; | |||||
| } | |||||
| return new Promise((resolve) => { | |||||
| const transaction = this.db.transaction([this.historyStoreName], 'readwrite'); | |||||
| const store = transaction.objectStore(this.historyStoreName); | |||||
| // 找出当前key的所有历史记录 | |||||
| const index = store.index('key'); | |||||
| const keyRange = IDBKeyRange.only(this.key); | |||||
| const request = index.openCursor(keyRange); | |||||
| const seenVersions = new Set(); | |||||
| const duplicates = []; | |||||
| const toDelete = []; | |||||
| request.onsuccess = (event) => { | |||||
| const cursor = event.target.result; | |||||
| if (cursor) { | |||||
| const item = cursor.value; | |||||
| const versionKey = `${item.key}_${item.version}`; | |||||
| if (seenVersions.has(versionKey)) { | |||||
| duplicates.push(item); | |||||
| toDelete.push(item.id); | |||||
| } else { | |||||
| seenVersions.add(versionKey); | |||||
| } | |||||
| cursor.continue(); | |||||
| } else { | |||||
| // 删除重复项 | |||||
| if (toDelete.length === 0) { | |||||
| resolve({ success: true, cleaned: 0, duplicates: [] }); | |||||
| return; | |||||
| } | |||||
| this.deleteHistoryItems(toDelete) | |||||
| .then(() => { | |||||
| console.log(`清理了 ${toDelete.length} 条重复历史记录`); | |||||
| resolve({ | |||||
| success: true, | |||||
| cleaned: toDelete.length, | |||||
| duplicates: duplicates.map(d => ({ id: d.id, version: d.version })) | |||||
| }); | |||||
| }) | |||||
| .catch(error => { | |||||
| resolve({ | |||||
| success: false, | |||||
| error: error.message, | |||||
| duplicatesFound: duplicates.length | |||||
| }); | |||||
| }); | |||||
| } | |||||
| }; | |||||
| request.onerror = (event) => { | |||||
| resolve({ | |||||
| success: false, | |||||
| error: event.target.error?.message | |||||
| }); | |||||
| }; | |||||
| }); | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 删除历史记录项 | |||||
| */ | |||||
| async deleteHistoryItems(ids) { | |||||
| return new Promise((resolve, reject) => { | |||||
| const transaction = this.db.transaction([this.historyStoreName], 'readwrite'); | |||||
| const store = transaction.objectStore(this.historyStoreName); | |||||
| let completed = 0; | |||||
| let errors = []; | |||||
| ids.forEach(id => { | |||||
| const request = store.delete(id); | |||||
| request.onerror = (event) => { | |||||
| errors.push({ id, error: event.target.error }); | |||||
| }; | |||||
| }); | |||||
| transaction.oncomplete = () => { | |||||
| if (errors.length > 0) { | |||||
| reject(new Error(`删除失败: ${errors.map(e => e.error.message).join(', ')}`)); | |||||
| } else { | |||||
| resolve(); | |||||
| } | |||||
| }; | |||||
| transaction.onerror = (event) => { | |||||
| reject(event.target.error); | |||||
| }; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 重置数据库(用于修复) | |||||
| */ | |||||
| async resetDatabase() { | |||||
| try { | |||||
| // 备份现有数据 | |||||
| const backup = await this.load(); | |||||
| // 关闭当前连接 | |||||
| if (this.db) { | |||||
| this.db.close(); | |||||
| } | |||||
| // 删除数据库 | |||||
| await new Promise((resolve, reject) => { | |||||
| const deleteRequest = indexedDB.deleteDatabase(this.dbName); | |||||
| deleteRequest.onsuccess = () => resolve(); | |||||
| deleteRequest.onerror = (event) => reject(event.target.error); | |||||
| }); | |||||
| console.log('数据库已重置'); | |||||
| // 重新初始化 | |||||
| this.db = null; | |||||
| this.isInitialized = false; | |||||
| this.initPromise = this.initDB(); | |||||
| await this.waitForInit(); | |||||
| // 恢复数据(如果有) | |||||
| if (backup.success && backup.data) { | |||||
| await this.save(backup.data.content); | |||||
| } | |||||
| return { | |||||
| success: true, | |||||
| message: '数据库重置成功', | |||||
| dataRestored: backup.success && !!backup.data | |||||
| }; | |||||
| } catch (error) { | |||||
| console.error('重置数据库失败:', error); | |||||
| return { | |||||
| success: false, | |||||
| error: error.message | |||||
| }; | |||||
| } | |||||
| } | |||||
| } | |||||
| export default IndexedDBStorage; | |||||
| @ -0,0 +1,163 @@ | |||||
| /** | |||||
| * LocalStorage存储策略 | |||||
| * 将数据保存在浏览器的localStorage中,持久化存储 | |||||
| */ | |||||
| class LocalStorage { | |||||
| constructor(key) { | |||||
| this.key = `hxhq_local_${key}`; | |||||
| this.historyKey = `hxhq_local_history_${key}`; | |||||
| } | |||||
| /** | |||||
| * 保存数据到localStorage | |||||
| * @param {string} content - 要保存的内容 | |||||
| * @returns {Promise<object>} 保存结果 | |||||
| */ | |||||
| async save(content) { | |||||
| try { | |||||
| const timestamp = Date.now(); | |||||
| const version = this.getNextVersion(); | |||||
| const data = { | |||||
| content, | |||||
| timestamp, | |||||
| version | |||||
| }; | |||||
| // 保存当前版本 | |||||
| localStorage.setItem(this.key, JSON.stringify(data)); | |||||
| // 保存到历史记录 | |||||
| this.saveToHistory(data); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从localStorage加载数据 | |||||
| * @returns {Promise<object>} 加载的数据 | |||||
| */ | |||||
| async load() { | |||||
| try { | |||||
| const dataStr = localStorage.getItem(this.key); | |||||
| const data = dataStr ? JSON.parse(dataStr) : null; | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取历史版本 | |||||
| * @returns {Promise<Array>} 历史版本列表 | |||||
| */ | |||||
| async getHistory() { | |||||
| try { | |||||
| const historyStr = localStorage.getItem(this.historyKey); | |||||
| const history = historyStr ? JSON.parse(historyStr) : []; | |||||
| return { | |||||
| success: true, | |||||
| data: history, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'localStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 保存到历史记录 | |||||
| * @param {object} data - 要保存的数据 | |||||
| */ | |||||
| saveToHistory(data) { | |||||
| try { | |||||
| const historyStr = localStorage.getItem(this.historyKey); | |||||
| let history = historyStr ? JSON.parse(historyStr) : []; | |||||
| // 添加到历史记录开头 | |||||
| history.unshift({...data}); | |||||
| // 限制历史记录数量 | |||||
| if (history.length > 100) { | |||||
| history = history.slice(0, 100); | |||||
| } | |||||
| localStorage.setItem(this.historyKey, JSON.stringify(history)); | |||||
| } catch (error) { | |||||
| console.error('保存历史记录失败:', error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取下一个版本号 | |||||
| * @returns {number} 版本号 | |||||
| */ | |||||
| getNextVersion() { | |||||
| try { | |||||
| const dataStr = localStorage.getItem(this.key); | |||||
| if (!dataStr) return 1; | |||||
| const data = JSON.parse(dataStr); | |||||
| return data.version + 1; | |||||
| } catch (error) { | |||||
| return 1; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取备份文件列表 | |||||
| * @returns {Promise<Array>} 备份文件列表 | |||||
| */ | |||||
| async getBackupFiles() { | |||||
| try { | |||||
| const files = []; | |||||
| for (let i = 0; i < localStorage.length; i++) { | |||||
| const key = localStorage.key(i); | |||||
| if (key.startsWith('backup_')) { | |||||
| const data = JSON.parse(localStorage.getItem(key)); | |||||
| files.push({ | |||||
| key, | |||||
| timestamp: data.timestamp, | |||||
| version: data.version, | |||||
| content: data.content.substring(0, 100) + '...' | |||||
| }); | |||||
| } | |||||
| } | |||||
| return { | |||||
| success: true, | |||||
| data: files | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message | |||||
| }; | |||||
| } | |||||
| } | |||||
| } | |||||
| export default LocalStorage; | |||||
| @ -0,0 +1,106 @@ | |||||
| /** | |||||
| * 内存存储策略 | |||||
| * 将数据保存在内存中,页面刷新后数据丢失 | |||||
| */ | |||||
| class MemoryStorage { | |||||
| constructor(key) { | |||||
| this.key = `hxhq_memory_${key}`; | |||||
| this.storage = new Map(); // 使用Map存储多个key的数据 | |||||
| this.history = new Map(); // 存储历史版本 | |||||
| } | |||||
| /** | |||||
| * 保存数据到内存 | |||||
| * @param {string} content - 要保存的内容 | |||||
| * @returns {Promise<object>} 保存结果 | |||||
| */ | |||||
| async save(content) { | |||||
| try { | |||||
| const timestamp = Date.now(); | |||||
| const data = { | |||||
| content, | |||||
| timestamp, | |||||
| version: this.getNextVersion() | |||||
| }; | |||||
| // 保存当前版本 | |||||
| this.storage.set(this.key, data); | |||||
| // 保存到历史记录(限制最多保存20个历史版本) | |||||
| if (!this.history.has(this.key)) { | |||||
| this.history.set(this.key, []); | |||||
| } | |||||
| const history = this.history.get(this.key); | |||||
| history.unshift({...data}); | |||||
| if (history.length > 20) { | |||||
| history.pop(); | |||||
| } | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version: data.version, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从内存加载数据 | |||||
| * @returns {Promise<object>} 加载的数据 | |||||
| */ | |||||
| async load() { | |||||
| try { | |||||
| const data = this.storage.get(this.key); | |||||
| return { | |||||
| success: true, | |||||
| data: data || null, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取历史版本 | |||||
| * @returns {Promise<Array>} 历史版本列表 | |||||
| */ | |||||
| async getHistory() { | |||||
| try { | |||||
| const history = this.history.get(this.key) || []; | |||||
| return { | |||||
| success: true, | |||||
| data: history, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'memory' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取下一个版本号 | |||||
| * @returns {number} 版本号 | |||||
| */ | |||||
| getNextVersion() { | |||||
| const current = this.storage.get(this.key); | |||||
| return current ? current.version + 1 : 1; | |||||
| } | |||||
| } | |||||
| export default MemoryStorage; | |||||
| @ -0,0 +1,286 @@ | |||||
| /** | |||||
| * 服务器存储策略 | |||||
| * 支持离线队列,网络恢复后自动重传 | |||||
| */ | |||||
| class ServerStorage { | |||||
| constructor(key, options = {}) { | |||||
| this.key = key; | |||||
| this.options = { | |||||
| endpoint: options.endpoint || '/api/save-text', | |||||
| syncEndpoint: options.syncEndpoint || '/api/sync-text', | |||||
| historyEndpoint: options.historyEndpoint || '/api/text-history', | |||||
| retryCount: options.retryCount || 3, //最多重试次数 | |||||
| retryDelay: options.retryDelay || 1000,//重试等待 毫秒 | |||||
| ...options | |||||
| }; | |||||
| this.queue = []; | |||||
| this.isOnline = navigator.onLine; | |||||
| this.isSyncing = false; | |||||
| this.initOfflineQueue(); | |||||
| this.setupNetworkListeners(); | |||||
| } | |||||
| /** | |||||
| * 初始化离线队列 | |||||
| */ | |||||
| initOfflineQueue() { | |||||
| const queueStr = localStorage.getItem(`hxhq_serverQueue_${this.key}`); | |||||
| if (queueStr) { | |||||
| try { | |||||
| this.queue = JSON.parse(queueStr); | |||||
| } catch (error) { | |||||
| this.queue = []; | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 设置网络状态监听 | |||||
| */ | |||||
| setupNetworkListeners() { | |||||
| window.addEventListener('online', () => { | |||||
| this.isOnline = true; | |||||
| this.processQueue(); | |||||
| }); | |||||
| window.addEventListener('offline', () => { | |||||
| this.isOnline = false; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 保存队列到本地存储 | |||||
| */ | |||||
| saveQueue() { | |||||
| localStorage.setItem(`hxhq_serverQueue_${this.key}`, JSON.stringify(this.queue)); | |||||
| } | |||||
| /** | |||||
| * 处理离线队列 | |||||
| */ | |||||
| async processQueue() { | |||||
| if (this.isSyncing || !this.isOnline || this.queue.length === 0) { | |||||
| return; | |||||
| } | |||||
| this.isSyncing = true; | |||||
| while (this.queue.length > 0 && this.isOnline) { | |||||
| const item = this.queue[0]; | |||||
| try { | |||||
| await this.sendToServer(item.content, item.timestamp, item.version); | |||||
| this.queue.shift(); // 发送成功,从队列移除 | |||||
| this.saveQueue(); | |||||
| } catch (error) { | |||||
| console.error('同步失败:', error); | |||||
| item.retryCount = (item.retryCount || 0) + 1; | |||||
| if (item.retryCount >= this.options.retryCount) { | |||||
| this.queue.shift(); // 超过重试次数,移除 | |||||
| this.saveQueue(); | |||||
| } else { | |||||
| // 重试等待 | |||||
| await new Promise(resolve => | |||||
| setTimeout(resolve, this.options.retryDelay * item.retryCount) | |||||
| ); | |||||
| } | |||||
| } | |||||
| } | |||||
| this.isSyncing = false; | |||||
| } | |||||
| /** | |||||
| * 发送数据到服务器 | |||||
| * @param {string} content - 内容 | |||||
| * @param {number} timestamp - 时间戳 | |||||
| * @param {number} version - 版本号 | |||||
| */ | |||||
| async sendToServer(content, timestamp, version) { | |||||
| return new Promise((resolve, reject) => { | |||||
| const xhr = new XMLHttpRequest(); | |||||
| xhr.open('POST', this.options.endpoint); | |||||
| xhr.setRequestHeader('Content-Type', 'application/json'); | |||||
| xhr.onload = () => { | |||||
| if (xhr.status === 200 || xhr.status === 201) { | |||||
| resolve(JSON.parse(xhr.responseText)); | |||||
| } else { | |||||
| reject(new Error(`服务器错误: ${xhr.status}`)); | |||||
| } | |||||
| }; | |||||
| xhr.onerror = () => reject(new Error('网络请求失败')); | |||||
| xhr.ontimeout = () => reject(new Error('请求超时')); | |||||
| xhr.send(JSON.stringify({ | |||||
| key: this.key, | |||||
| content, | |||||
| timestamp, | |||||
| version, | |||||
| syncedAt: Date.now() | |||||
| })); | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 保存数据(优先尝试服务器,失败则加入离线队列) | |||||
| * @param {string} content - 要保存的内容 | |||||
| * @returns {Promise<object>} 保存结果 | |||||
| */ | |||||
| async save(content) { | |||||
| try { | |||||
| const timestamp = Date.now(); | |||||
| const version = await this.getNextVersion(); | |||||
| if (this.isOnline) { | |||||
| try { | |||||
| const result = await this.sendToServer(content, timestamp, version); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'server', | |||||
| synced: true | |||||
| }; | |||||
| } catch (error) { | |||||
| // 服务器保存失败,加入离线队列 | |||||
| console.warn('服务器保存失败,加入离线队列:', error); | |||||
| } | |||||
| } | |||||
| // 离线或服务器失败,加入队列 | |||||
| this.queue.push({ | |||||
| content, | |||||
| timestamp, | |||||
| version, | |||||
| addedAt: Date.now() | |||||
| }); | |||||
| this.saveQueue(); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'server', | |||||
| synced: false, | |||||
| queued: true | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'server' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从服务器加载数据(失败则返回离线数据) | |||||
| * @returns {Promise<object>} 加载的数据 | |||||
| */ | |||||
| async load() { | |||||
| try { | |||||
| if (this.isOnline) { | |||||
| try { | |||||
| const response = await fetch(`${this.options.syncEndpoint}?key=${this.key}`); | |||||
| if (response.ok) { | |||||
| const data = await response.json(); | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'server', | |||||
| fromServer: true | |||||
| }; | |||||
| } | |||||
| } catch (error) { | |||||
| console.warn('从服务器加载失败:', error); | |||||
| } | |||||
| } | |||||
| // 返回队列中的最新数据 | |||||
| const latestQueued = this.queue.length > 0 | |||||
| ? this.queue[this.queue.length - 1] | |||||
| : null; | |||||
| return { | |||||
| success: true, | |||||
| data: latestQueued, | |||||
| storage: 'server', | |||||
| fromServer: false | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'server' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取服务器历史版本 | |||||
| * @returns {Promise<Array>} 历史版本列表 | |||||
| */ | |||||
| async getHistory() { | |||||
| try { | |||||
| if (!this.isOnline) { | |||||
| return { | |||||
| success: false, | |||||
| error: '网络未连接', | |||||
| storage: 'server' | |||||
| }; | |||||
| } | |||||
| const response = await fetch(`${this.options.historyEndpoint}?key=${this.key}`); | |||||
| if (!response.ok) { | |||||
| throw new Error(`服务器错误: ${response.status}`); | |||||
| } | |||||
| const data = await response.json(); | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'server' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'server' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取下一个版本号 | |||||
| * @returns {Promise<number>} 版本号 | |||||
| */ | |||||
| async getNextVersion() { | |||||
| try { | |||||
| const result = await this.load(); | |||||
| if (result.success && result.data) { | |||||
| return result.data.version + 1; | |||||
| } | |||||
| return 1; | |||||
| } catch (error) { | |||||
| return 1; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取队列状态 | |||||
| * @returns {object} 队列状态 | |||||
| */ | |||||
| getQueueStatus() { | |||||
| return { | |||||
| queuedCount: this.queue.length, | |||||
| isOnline: this.isOnline, | |||||
| isSyncing: this.isSyncing, | |||||
| nextRetry: this.queue.length > 0 ? this.queue[0].retryCount || 0 : 0 | |||||
| }; | |||||
| } | |||||
| } | |||||
| export default ServerStorage; | |||||
| @ -0,0 +1,128 @@ | |||||
| /** | |||||
| * SessionStorage存储策略 | |||||
| * 将数据保存在浏览器的sessionStorage中,标签页关闭后数据丢失 | |||||
| */ | |||||
| class SessionStorage { | |||||
| constructor(key) { | |||||
| this.key = `hxhq_session_${key}`; | |||||
| this.historyKey = `hxhq_session_history_${key}`; | |||||
| } | |||||
| /** | |||||
| * 保存数据到sessionStorage | |||||
| * @param {string} content - 要保存的内容 | |||||
| * @returns {Promise<object>} 保存结果 | |||||
| */ | |||||
| async save(content) { | |||||
| try { | |||||
| const timestamp = Date.now(); | |||||
| const version = this.getNextVersion(); | |||||
| const data = { | |||||
| content, | |||||
| timestamp, | |||||
| version | |||||
| }; | |||||
| sessionStorage.setItem(this.key, JSON.stringify(data)); | |||||
| this.saveToHistory(data); | |||||
| return { | |||||
| success: true, | |||||
| timestamp, | |||||
| version, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 从sessionStorage加载数据 | |||||
| * @returns {Promise<object>} 加载的数据 | |||||
| */ | |||||
| async load() { | |||||
| try { | |||||
| const dataStr = sessionStorage.getItem(this.key); | |||||
| const data = dataStr ? JSON.parse(dataStr) : null; | |||||
| return { | |||||
| success: true, | |||||
| data, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取历史版本 | |||||
| * @returns {Promise<Array>} 历史版本列表 | |||||
| */ | |||||
| async getHistory() { | |||||
| try { | |||||
| const historyStr = sessionStorage.getItem(this.historyKey); | |||||
| const history = historyStr ? JSON.parse(historyStr) : []; | |||||
| return { | |||||
| success: true, | |||||
| data: history, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } catch (error) { | |||||
| return { | |||||
| success: false, | |||||
| error: error.message, | |||||
| storage: 'sessionStorage' | |||||
| }; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 保存到历史记录 | |||||
| * @param {object} data - 要保存的数据 | |||||
| */ | |||||
| saveToHistory(data) { | |||||
| try { | |||||
| const historyStr = sessionStorage.getItem(this.historyKey); | |||||
| let history = historyStr ? JSON.parse(historyStr) : []; | |||||
| history.unshift({...data}); | |||||
| if (history.length > 100) { | |||||
| history = history.slice(0, 100); | |||||
| } | |||||
| sessionStorage.setItem(this.historyKey, JSON.stringify(history)); | |||||
| } catch (error) { | |||||
| console.error('保存历史记录失败:', error); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 获取下一个版本号 | |||||
| * @returns {number} 版本号 | |||||
| */ | |||||
| getNextVersion() { | |||||
| try { | |||||
| const dataStr = sessionStorage.getItem(this.key); | |||||
| if (!dataStr) return 1; | |||||
| const data = JSON.parse(dataStr); | |||||
| return data.version + 1; | |||||
| } catch (error) { | |||||
| return 1; | |||||
| } | |||||
| } | |||||
| } | |||||
| export default SessionStorage; | |||||
| @ -0,0 +1,252 @@ | |||||
| <template> | |||||
| <div class="app-container"> | |||||
| <div class="editor-grid"> | |||||
| <div class="editor-section"> | |||||
| <div style="display: flex;flex-wrap: wrap;"> | |||||
| <div style="line-height:40px; line-height: 40px;">步骤1</div> | |||||
| <AutoSaveTextarea v-model="content1" data-key="note1" data-name="步骤1" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="200px" /> | |||||
| <div style="line-height:40px; line-height: 40px;">选择</div> | |||||
| <AutoSaveTextarea v-model="content2" data-key="note2" data-name="涡旋仪" data-type="select" | |||||
| :data-select="dataSelect" placeholder="点击选择" :server-config="serverConfig" | |||||
| @content-restored="handleContentRestored" dataWidth="200px" /> | |||||
| <div style="line-height:40px; line-height: 40px;">涡旋仪,温度理论值</div> | |||||
| <AutoSaveTextarea v-model="content3" data-key="note3" data-name="温度理论值" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <div style="line-height:40px; line-height: 40px;">℃,实际值:</div> | |||||
| <AutoSaveTextarea v-model="content4" data-key="note4" data-name="温度实际值" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <div style="line-height:40px; line-height: 40px;">℃,涡旋混匀理论值:</div> | |||||
| <AutoSaveTextarea v-model="content5" data-key="note5" data-name="涡旋混匀理论值" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <AutoSaveTextarea v-model="content6" data-key="note6" data-name="涡旋混匀理论值单位" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <div style="line-height:40px; line-height: 40px;">实际值:</div> | |||||
| <AutoSaveTextarea v-model="content7" data-key="note7" data-name="涡旋混匀实际值" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <AutoSaveTextarea v-model="content8" data-key="note8" data-name="涡旋混匀实际值单位" data-type="input" placeholder="" | |||||
| :server-config="serverConfig" @content-restored="handleContentRestored" dataWidth="100px" /> | |||||
| <AutoSaveTextarea v-model="content2" data-key="note9" data-name="总结" data-type="textarea" | |||||
| :data-select="dataSelect" placeholder="请输入总结" :server-config="serverConfig" | |||||
| @content-restored="handleContentRestored" dataWidth="100%" :textarea-row="5"/> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| <h3 style="color: red;">通过多层存储,确保在各种异常情况下(网络中断、浏览器崩溃、存储限制等)用户内容都能得到妥善保存。</h3> | |||||
| <h2>分层存储(数据流向:用户输入 → 本地持久化 → 云端)</h2> | |||||
| <p> 1.服务器存储:云端同步,跨设备访问</p> | |||||
| <p> 2.IndexedDB存储:浏览器内置 NoSQL 数据库,所有pc浏览器都支持,移动端浏览器也支持(存储数据可达50%的磁盘容量,并且支持事务,索引)(关闭标签页/窗口 → 数据保留;刷新页面 → 数据保留)</p> | |||||
| <p> 3.LocalStorage:本地存储, 持久化,跨会话保存(关闭标签页/窗口 → 数据保留;刷新页面 → 数据保留)</p> | |||||
| <h2>实时保存</h2> | |||||
| <p>3.输入结束,失去焦点后触发多存储并行保存</p> | |||||
| <p>4.页面关闭前强制同步保存</p> | |||||
| <p>5.状态监控:实时跟踪各存储状态</p> | |||||
| <h2>云端同步</h2> | |||||
| <p>1.失败操作加入离线队列</p> | |||||
| <p>2.网络恢复时自动重试</p> | |||||
| <p>3.失败重试策略(可配置重试等待延迟,以及重试次数,避免太多请求,造成服务器压力过大)</p> | |||||
| <!-- 网络状态提示 --> | |||||
| <!-- <div class="network-status" :class="networkClass"> | |||||
| <i :class="networkIcon"></i> | |||||
| 网络状态:{{ networkText }} | |||||
| </div> --> | |||||
| </div> | |||||
| </template> | |||||
| <script> | |||||
| import AutoSaveTextarea from './components/AutoSaveTextarea.vue'; | |||||
| export default { | |||||
| name: 'App', | |||||
| components: { | |||||
| AutoSaveTextarea | |||||
| }, | |||||
| data() { | |||||
| return { | |||||
| content1: '漩涡混匀', | |||||
| content2: '', | |||||
| content3: '30', | |||||
| content4: '', | |||||
| content5: '10', | |||||
| content6: 'min', | |||||
| content7: '', | |||||
| content8: '', | |||||
| dataSelect: [{ value: '迈瑞', label: '迈瑞' },{ value: '大疆', label: '大疆' }], | |||||
| serverConfig: { | |||||
| endpoint: '/api/save-text', | |||||
| syncEndpoint: '/api/sync-text', | |||||
| historyEndpoint: '/api/text-history', | |||||
| retryCount: 3, | |||||
| retryDelay: 1000 | |||||
| }, | |||||
| isOnline: navigator.onLine, | |||||
| editors: [] // 存储编辑器实例 | |||||
| }; | |||||
| }, | |||||
| computed: { | |||||
| networkClass() { | |||||
| return this.isOnline ? 'online' : 'offline'; | |||||
| }, | |||||
| networkIcon() { | |||||
| return this.isOnline ? 'el-icon-success' : 'el-icon-warning'; | |||||
| }, | |||||
| networkText() { | |||||
| return this.isOnline ? '在线' : '离线'; | |||||
| } | |||||
| }, | |||||
| mounted() { | |||||
| // 监听网络状态 | |||||
| window.addEventListener('online', this.updateNetworkStatus); | |||||
| window.addEventListener('offline', this.updateNetworkStatus); | |||||
| // 监听自定义事件来收集编辑器实例 | |||||
| this.$root.$on('editor-registered', this.registerEditor); | |||||
| }, | |||||
| beforeDestroy() { | |||||
| window.removeEventListener('online', this.updateNetworkStatus); | |||||
| window.removeEventListener('offline', this.updateNetworkStatus); | |||||
| this.$root.$off('editor-registered', this.registerEditor); | |||||
| }, | |||||
| methods: { | |||||
| handleContentRestored(content) { | |||||
| console.log('内容已恢复:', content); | |||||
| this.$message.success('内容恢复成功'); | |||||
| }, | |||||
| updateNetworkStatus() { | |||||
| this.isOnline = navigator.onLine; | |||||
| }, | |||||
| registerEditor(editor) { | |||||
| this.editors.push(editor); | |||||
| }, | |||||
| } | |||||
| }; | |||||
| </script> | |||||
| <style scoped> | |||||
| .app-container { | |||||
| max-width: 1200px; | |||||
| margin: 0 auto; | |||||
| padding: 20px; | |||||
| } | |||||
| h1 { | |||||
| text-align: center; | |||||
| margin-bottom: 40px; | |||||
| color: #303133; | |||||
| } | |||||
| .editor-grid { | |||||
| display: grid; | |||||
| grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); | |||||
| gap: 30px; | |||||
| margin-bottom: 40px; | |||||
| } | |||||
| .editor-section { | |||||
| background: white; | |||||
| border-radius: 8px; | |||||
| padding: 20px; | |||||
| box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); | |||||
| } | |||||
| .editor-section h2 { | |||||
| margin: 0 0 20px 0; | |||||
| font-size: 18px; | |||||
| color: #409eff; | |||||
| border-bottom: 2px solid #409eff; | |||||
| padding-bottom: 10px; | |||||
| } | |||||
| .network-status { | |||||
| position: fixed; | |||||
| top: 20px; | |||||
| right: 20px; | |||||
| padding: 8px 16px; | |||||
| border-radius: 20px; | |||||
| font-size: 14px; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 8px; | |||||
| z-index: 1000; | |||||
| } | |||||
| .network-status.online { | |||||
| background: #f0f9eb; | |||||
| color: #67c23a; | |||||
| border: 1px solid #e1f3d8; | |||||
| } | |||||
| .network-status.offline { | |||||
| background: #fef0f0; | |||||
| color: #f56c6c; | |||||
| border: 1px solid #fde2e2; | |||||
| } | |||||
| .batch-actions { | |||||
| display: flex; | |||||
| justify-content: center; | |||||
| gap: 20px; | |||||
| margin-top: 30px; | |||||
| padding-top: 30px; | |||||
| border-top: 1px solid #ebeef5; | |||||
| } | |||||
| .btn { | |||||
| padding: 10px 20px; | |||||
| border: none; | |||||
| border-radius: 4px; | |||||
| font-size: 14px; | |||||
| cursor: pointer; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 8px; | |||||
| transition: all 0.3s ease; | |||||
| } | |||||
| .btn-primary { | |||||
| background: #409eff; | |||||
| color: white; | |||||
| } | |||||
| .btn-primary:hover { | |||||
| background: #66b1ff; | |||||
| } | |||||
| .btn-secondary { | |||||
| background: #f5f7fa; | |||||
| color: #606266; | |||||
| border: 1px solid #dcdfe6; | |||||
| } | |||||
| .btn-secondary:hover { | |||||
| background: #e4e7ed; | |||||
| } | |||||
| @media (max-width: 768px) { | |||||
| .editor-grid { | |||||
| grid-template-columns: 1fr; | |||||
| } | |||||
| .editor-section { | |||||
| padding: 15px; | |||||
| } | |||||
| .batch-actions { | |||||
| flex-direction: column; | |||||
| } | |||||
| .btn { | |||||
| width: 100%; | |||||
| justify-content: center; | |||||
| } | |||||
| } | |||||
| </style> | |||||