Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
268 lines
7.2 KiB
JavaScript
268 lines
7.2 KiB
JavaScript
// Pinia plugin for persisted state with performance optimizations
|
|
|
|
import { debounce } from '@/utils/performance'
|
|
|
|
const STORAGE_KEY_PREFIX = 'marketing_agent_'
|
|
|
|
/**
|
|
* Create persisted state plugin for Pinia
|
|
*/
|
|
export function createPersistedState(options = {}) {
|
|
const {
|
|
storage = localStorage,
|
|
paths = [],
|
|
debounceTime = 1000,
|
|
serializer = JSON,
|
|
filter = () => true
|
|
} = options
|
|
|
|
return (context) => {
|
|
const { store } = context
|
|
const storeId = store.$id
|
|
const storageKey = STORAGE_KEY_PREFIX + storeId
|
|
|
|
// Load initial state
|
|
try {
|
|
const savedState = storage.getItem(storageKey)
|
|
if (savedState) {
|
|
const parsed = serializer.parse(savedState)
|
|
// Only restore specified paths or all if paths is empty
|
|
if (paths.length === 0) {
|
|
store.$patch(parsed)
|
|
} else {
|
|
const filtered = {}
|
|
paths.forEach(path => {
|
|
const keys = path.split('.')
|
|
let source = parsed
|
|
let target = filtered
|
|
|
|
keys.forEach((key, index) => {
|
|
if (index === keys.length - 1) {
|
|
if (source && key in source) {
|
|
target[key] = source[key]
|
|
}
|
|
} else {
|
|
if (!target[key]) target[key] = {}
|
|
target = target[key]
|
|
source = source ? source[key] : undefined
|
|
}
|
|
})
|
|
})
|
|
store.$patch(filtered)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to load persisted state for ${storeId}:`, error)
|
|
}
|
|
|
|
// Save state changes with debouncing
|
|
const saveState = debounce(() => {
|
|
try {
|
|
const state = store.$state
|
|
let toSave = state
|
|
|
|
// Filter state if paths are specified
|
|
if (paths.length > 0) {
|
|
toSave = {}
|
|
paths.forEach(path => {
|
|
const keys = path.split('.')
|
|
let source = state
|
|
let target = toSave
|
|
|
|
keys.forEach((key, index) => {
|
|
if (index === keys.length - 1) {
|
|
if (source && key in source) {
|
|
target[key] = source[key]
|
|
}
|
|
} else {
|
|
if (!target[key]) target[key] = {}
|
|
target = target[key]
|
|
source = source ? source[key] : undefined
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Apply custom filter
|
|
if (filter(toSave)) {
|
|
storage.setItem(storageKey, serializer.stringify(toSave))
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to save state for ${storeId}:`, error)
|
|
}
|
|
}, debounceTime)
|
|
|
|
// Subscribe to state changes
|
|
store.$subscribe((mutation, state) => {
|
|
saveState()
|
|
})
|
|
|
|
// Subscribe to actions for immediate save on specific actions
|
|
store.$onAction(({ name, after }) => {
|
|
// Save immediately after logout or critical actions
|
|
if (['logout', 'clearData', 'resetStore'].includes(name)) {
|
|
after(() => {
|
|
saveState.cancel()
|
|
try {
|
|
if (name === 'logout' || name === 'clearData') {
|
|
storage.removeItem(storageKey)
|
|
} else {
|
|
storage.setItem(storageKey, serializer.stringify(store.$state))
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to handle action ${name}:`, error)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Secure storage wrapper with encryption
|
|
*/
|
|
export class SecureStorage {
|
|
constructor(secretKey) {
|
|
this.secretKey = secretKey
|
|
}
|
|
|
|
async encrypt(data) {
|
|
const encoder = new TextEncoder()
|
|
const dataBuffer = encoder.encode(JSON.stringify(data))
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(this.secretKey),
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt']
|
|
)
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
dataBuffer
|
|
)
|
|
|
|
const combined = new Uint8Array(iv.length + encrypted.byteLength)
|
|
combined.set(iv)
|
|
combined.set(new Uint8Array(encrypted), iv.length)
|
|
|
|
return btoa(String.fromCharCode(...combined))
|
|
}
|
|
|
|
async decrypt(encryptedData) {
|
|
const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0))
|
|
const iv = combined.slice(0, 12)
|
|
const encrypted = combined.slice(12)
|
|
|
|
const encoder = new TextEncoder()
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
encoder.encode(this.secretKey),
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['decrypt']
|
|
)
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
encrypted
|
|
)
|
|
|
|
const decoder = new TextDecoder()
|
|
return JSON.parse(decoder.decode(decrypted))
|
|
}
|
|
|
|
setItem(key, value) {
|
|
return this.encrypt(value).then(encrypted => {
|
|
localStorage.setItem(key, encrypted)
|
|
})
|
|
}
|
|
|
|
getItem(key) {
|
|
const encrypted = localStorage.getItem(key)
|
|
if (!encrypted) return Promise.resolve(null)
|
|
|
|
return this.decrypt(encrypted).catch(error => {
|
|
console.error('Decryption failed:', error)
|
|
localStorage.removeItem(key)
|
|
return null
|
|
})
|
|
}
|
|
|
|
removeItem(key) {
|
|
localStorage.removeItem(key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* IndexedDB storage for large data
|
|
*/
|
|
export class IndexedDBStorage {
|
|
constructor(dbName = 'marketing_agent_store', storeName = 'state') {
|
|
this.dbName = dbName
|
|
this.storeName = storeName
|
|
this.db = null
|
|
}
|
|
|
|
async init() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(this.dbName, 1)
|
|
|
|
request.onerror = () => reject(request.error)
|
|
request.onsuccess = () => {
|
|
this.db = request.result
|
|
resolve()
|
|
}
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
db.createObjectStore(this.storeName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async setItem(key, value) {
|
|
if (!this.db) await this.init()
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite')
|
|
const store = transaction.objectStore(this.storeName)
|
|
const request = store.put(value, key)
|
|
|
|
request.onsuccess = () => resolve()
|
|
request.onerror = () => reject(request.error)
|
|
})
|
|
}
|
|
|
|
async getItem(key) {
|
|
if (!this.db) await this.init()
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readonly')
|
|
const store = transaction.objectStore(this.storeName)
|
|
const request = store.get(key)
|
|
|
|
request.onsuccess = () => resolve(request.result)
|
|
request.onerror = () => reject(request.error)
|
|
})
|
|
}
|
|
|
|
async removeItem(key) {
|
|
if (!this.db) await this.init()
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = this.db.transaction([this.storeName], 'readwrite')
|
|
const store = transaction.objectStore(this.storeName)
|
|
const request = store.delete(key)
|
|
|
|
request.onsuccess = () => resolve()
|
|
request.onerror = () => reject(request.error)
|
|
})
|
|
}
|
|
} |