Browse Source

feat:新增落笔留痕演示

master
15881625488@163.com 5 days ago
parent
commit
2d7b8476db
14 changed files with 3418 additions and 20 deletions
  1. +0
    -9
      src/api/business/public/public.js
  2. +2
    -3
      src/lang/en.js
  3. +1
    -3
      src/lang/zh.js
  4. +1
    -1
      src/permission.js
  5. +5
    -0
      src/router/index.js
  6. +2
    -4
      src/views/business/study/comp/enter.vue
  7. +420
    -0
      src/views/lblh/components/AutoSaveManager.js
  8. +1349
    -0
      src/views/lblh/components/AutoSaveTextarea.vue
  9. +703
    -0
      src/views/lblh/components/storage/IndexedDBStorage.js
  10. +163
    -0
      src/views/lblh/components/storage/LocalStorage.js
  11. +106
    -0
      src/views/lblh/components/storage/MemoryStorage.js
  12. +286
    -0
      src/views/lblh/components/storage/ServerStorage.js
  13. +128
    -0
      src/views/lblh/components/storage/SessionStorage.js
  14. +252
    -0
      src/views/lblh/index.vue

+ 0
- 9
src/api/business/public/public.js View File

@ -37,12 +37,3 @@ export function public_studyFormPreList(query) {
params: query
})
}
// 试验区域-饲养间列表
export function public_roomList(query) {
return request({
url: '/system/business/public/roomList',
method: 'get',
params: query
})
}

+ 2
- 3
src/lang/en.js View File

@ -27,8 +27,7 @@ import studyFormFill from './en/business/study/studyFormFill'
import studyFormPlan from './en/business/study/studyFormPlan'
//申请表单
import studyFormApply from './en/business/study/studyFormApply'
// 饲养间
import studyRoom from './en/business/study/studyRoom'
//表单
import form from './en/business/form/form'
@ -145,7 +144,7 @@ export default {
studyFormFill: studyFormFill,
studyFormPlan: studyFormPlan,
studyFormApply: studyFormApply,
studyRoom: studyRoom,
nonTrial: nonTrial,
drug: drug
},

+ 1
- 3
src/lang/zh.js View File

@ -27,8 +27,6 @@ import studyFormFill from './zh/business/study/studyFormFill'
import studyFormPlan from './zh/business/study/studyFormPlan'
//申请表单
import studyFormApply from './zh/business/study/studyFormApply'
//饲养间
import studyRoom from './zh/business/study/studyRoom'
//表单
import form from './zh/business/form/form'
@ -142,7 +140,7 @@ export default {
studyFormFill: studyFormFill,
studyFormPlan: studyFormPlan,
studyFormApply: studyFormApply,
studyRoom: studyRoom,
nonTrial: nonTrial,
drug: drug
},

+ 1
- 1
src/permission.js View File

@ -9,7 +9,7 @@ import { isRelogin } from '@/utils/request'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
const whiteList = ['/login', '/lblh']
const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path))

+ 5
- 0
src/router/index.js View File

@ -46,6 +46,11 @@ export const constantRoutes = [
component: () => import('@/views/login'),
hidden: true
},
{
path: '/lblh',
component: () => import('@/views/lblh/index'),
hidden: true
},
{
path: '/register',
component: () => import('@/views/register'),

+ 2
- 4
src/views/business/study/comp/enter.vue View File

@ -14,7 +14,6 @@
<syxx v-if="active === 'syxx'" :study="study" @showDetail="showDetailCallback"/>
<syff v-if="active === 'syff'" :study="study" @showDetail="showDetailCallback"/>
<wzlb v-if="active === 'wzlb'" :study="study" @showDetail="showDetailCallback"/>
<syj v-if="active === 'syj'" :study="study" @showDetail="showDetailCallback"/>
</div>
</div>
</div>
@ -26,11 +25,10 @@ import tbbd from './tbbd.vue'
import syxx from './syxx.vue'
import syff from './syff.vue'
import wzlb from './wzlb.vue'
import syj from './syj.vue'
export default {
name: 'StudyEnter',
props: {},
components: { ytbd, tbbd ,syxx,syff,wzlb, syj},
components: { ytbd, tbbd ,syxx,syff,wzlb},
computed: {},
filters: {},
data() {
@ -49,7 +47,7 @@ export default {
study: {},
}
},
created() {
created() {
// let x = this.$route.params
// let y = this.$route.query
// debugger

+ 420
- 0
src/views/lblh/components/AutoSaveManager.js View File

@ -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;

+ 1349
- 0
src/views/lblh/components/AutoSaveTextarea.vue
File diff suppressed because it is too large
View File


+ 703
- 0
src/views/lblh/components/storage/IndexedDBStorage.js View File

@ -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;

+ 163
- 0
src/views/lblh/components/storage/LocalStorage.js View File

@ -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;

+ 106
- 0
src/views/lblh/components/storage/MemoryStorage.js View File

@ -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;

+ 286
- 0
src/views/lblh/components/storage/ServerStorage.js View File

@ -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;

+ 128
- 0
src/views/lblh/components/storage/SessionStorage.js View File

@ -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;

+ 252
- 0
src/views/lblh/index.vue View File

@ -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>

Loading…
Cancel
Save