20 changed files with 1812 additions and 162 deletions
@ -0,0 +1,422 @@ |
|||
# Cache 模块 |
|||
|
|||
基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API |
|||
接口。 |
|||
|
|||
## 架构设计 |
|||
|
|||
```shell |
|||
┌───────────────────────────────────────────────┐ |
|||
│ StorageManager │ |
|||
│ ┌─────────────┐ ┌───────────────────────┐ │ |
|||
│ │ Prefix 隔离 │ │ TTL 过期管理 │ │ |
|||
│ └─────────────┘ └───────────────────────┘ │ |
|||
├───────────────────────────────────────────────┤ |
|||
│ IStorageDriver │ |
|||
├──────────┬─────────────────┬──────────────────┤ |
|||
│ Local │ IndexedDB │ Memory │ |
|||
│ Storage │ Driver │ Driver │ |
|||
│ Driver │ │ │ |
|||
└──────────┴─────────────────┴──────────────────┘ |
|||
``` |
|||
|
|||
**分层职责:** |
|||
|
|||
| 层级 | 职责 | |
|||
|------------------|----------------------------| |
|||
| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API | |
|||
| `IStorageDriver` | 纯粹的 KV 存取抽象接口 | |
|||
| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL | |
|||
|
|||
--- |
|||
|
|||
## 快速开始 |
|||
|
|||
### 基本使用(默认 localStorage) |
|||
|
|||
```typescript |
|||
import {StorageManager} from '@vben-core/shared/cache'; |
|||
|
|||
const cache = new StorageManager({prefix: 'myapp'}); |
|||
// 使用 IndexedDB |
|||
//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' }); |
|||
|
|||
// 使用 sessionStorage |
|||
//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' }); |
|||
|
|||
// 测试环境 |
|||
//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' }); |
|||
|
|||
// 存储数据 |
|||
await cache.setItem('user', {name: '张三', age: 28}); |
|||
|
|||
// 读取数据 |
|||
const user = await cache.getItem('user'); |
|||
// => { name: '张三', age: 28 } |
|||
|
|||
// 带默认值读取 |
|||
const settings = await cache.getItem('settings', {theme: 'light'}); |
|||
// 如果不存在,返回 { theme: 'light' } |
|||
|
|||
// 删除数据 |
|||
await cache.removeItem('user'); |
|||
|
|||
// 清除当前前缀下所有数据 |
|||
await cache.clear(); |
|||
``` |
|||
|
|||
### 带 TTL 过期 |
|||
|
|||
```typescript |
|||
const cache = new StorageManager({prefix: 'session'}); |
|||
|
|||
// 设置 5 分钟后过期(TTL 单位为毫秒) |
|||
await cache.setItem('token', 'abc123', 5 * 60 * 1000); |
|||
|
|||
// 5 分钟内可以正常读取 |
|||
const token = await cache.getItem('token'); |
|||
// => 'abc123' |
|||
|
|||
// 5 分钟后自动返回 null(惰性删除) |
|||
const expiredToken = await cache.getItem('token'); |
|||
// => null |
|||
|
|||
// 主动清理所有过期项 |
|||
await cache.clearExpiredItems(); |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 存储驱动 |
|||
|
|||
### LocalStorageDriver(默认) |
|||
|
|||
基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。 |
|||
|
|||
```typescript |
|||
import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache'; |
|||
|
|||
// 使用 localStorage(默认) |
|||
const cache = new StorageManager({ |
|||
driver: new LocalStorageDriver(), |
|||
prefix: 'app', |
|||
}); |
|||
|
|||
// 使用 sessionStorage |
|||
const sessionCache = new StorageManager({ |
|||
driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), |
|||
prefix: 'app', |
|||
}); |
|||
``` |
|||
|
|||
**特点:** |
|||
|
|||
- 同步 API 用 async 包装,保持接口统一 |
|||
- 自动处理 JSON 序列化/反序列化 |
|||
- 数据损坏时自动清除并返回 null |
|||
- 存储上限约 5-10MB(视浏览器而定) |
|||
|
|||
**适用场景:** 用户偏好设置、小型配置数据、Token 存储 |
|||
|
|||
--- |
|||
|
|||
### IndexedDBDriver |
|||
|
|||
基于浏览器 IndexedDB,支持大容量结构化数据存储。 |
|||
|
|||
```typescript |
|||
import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache'; |
|||
|
|||
const cache = new StorageManager({ |
|||
driver: new IndexedDBDriver({ |
|||
dbName: 'my-app-db', // 数据库名称,默认 'vben-storage' |
|||
dbVersion: 1, // 数据库版本,默认 1 |
|||
storeName: 'cache-store', // 对象存储名称,默认 'kv-store' |
|||
}), |
|||
prefix: 'data', |
|||
}); |
|||
|
|||
// 存储大量数据 |
|||
await cache.setItem('table-data', largeDataArray); |
|||
|
|||
// 存储二进制友好的结构(IndexedDB 原生支持) |
|||
await cache.setItem('config', { |
|||
columns: [...], |
|||
filters: [...], |
|||
pagination: {page: 1, size: 20}, |
|||
}); |
|||
``` |
|||
|
|||
**特点:** |
|||
|
|||
- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()` |
|||
- 存储容量大(通常数百 MB 到 GB 级别) |
|||
- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型) |
|||
- 天然异步,不阻塞主线程 |
|||
|
|||
**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据 |
|||
|
|||
--- |
|||
|
|||
### MemoryStorageDriver |
|||
|
|||
基于内存 Map,数据不持久化,页面刷新即丢失。 |
|||
|
|||
```typescript |
|||
import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache'; |
|||
|
|||
const cache = new StorageManager({ |
|||
driver: new MemoryStorageDriver(), |
|||
prefix: 'test', |
|||
}); |
|||
``` |
|||
|
|||
**特点:** |
|||
|
|||
- 读写速度最快 |
|||
- 无浏览器 API 依赖 |
|||
- 数据随页面生命周期销毁 |
|||
|
|||
**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存 |
|||
|
|||
--- |
|||
|
|||
## API 参考 |
|||
|
|||
### StorageManager |
|||
|
|||
#### 构造函数 |
|||
|
|||
```typescript |
|||
new StorageManager(options?: StorageManagerOptions) |
|||
``` |
|||
|
|||
| 参数 | 类型 | 默认值 | 说明 | |
|||
|----------|------------------|----------------------------|--------------| |
|||
| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 | |
|||
| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 | |
|||
|
|||
#### 方法 |
|||
|
|||
| 方法 | 签名 | 说明 | |
|||
|---------------------|-------------------------------------------------------------------------|-------------------| |
|||
| `getItem` | `getItem<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 | |
|||
| `setItem` | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>` | 设置存储项,可选 TTL(毫秒) | |
|||
| `removeItem` | `removeItem(key: string): Promise<void>` | 删除指定存储项 | |
|||
| `clear` | `clear(): Promise<void>` | 清除当前前缀下所有存储项 | |
|||
| `clearExpiredItems` | `clearExpiredItems(): Promise<void>` | 主动清理所有过期项 | |
|||
|
|||
--- |
|||
|
|||
### IStorageDriver 接口 |
|||
|
|||
自定义驱动需要实现此接口: |
|||
|
|||
```typescript |
|||
interface IStorageDriver { |
|||
clear(): Promise<void>; |
|||
|
|||
getItem<T>(key: string): Promise<null | T>; |
|||
|
|||
keys(): Promise<string[]>; |
|||
|
|||
removeItem(key: string): Promise<void>; |
|||
|
|||
setItem<T>(key: string, value: T): Promise<void>; |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 高级用法 |
|||
|
|||
### 自定义 Driver |
|||
|
|||
```typescript |
|||
import type {IStorageDriver} from '@vben-core/shared/cache'; |
|||
|
|||
class CookieStorageDriver implements IStorageDriver { |
|||
async getItem<T>(key: string): Promise<null | T> { |
|||
const value = getCookie(key); |
|||
return value ? JSON.parse(value) : null; |
|||
} |
|||
|
|||
async setItem<T>(key: string, value: T): Promise<void> { |
|||
setCookie(key, JSON.stringify(value)); |
|||
} |
|||
|
|||
async removeItem(key: string): Promise<void> { |
|||
deleteCookie(key); |
|||
} |
|||
|
|||
async clear(): Promise<void> { |
|||
clearAllCookies(); |
|||
} |
|||
|
|||
async keys(): Promise<string[]> { |
|||
return getAllCookieNames(); |
|||
} |
|||
} |
|||
|
|||
// 使用自定义 Driver |
|||
const cache = new StorageManager({ |
|||
driver: new CookieStorageDriver(), |
|||
prefix: 'ck', |
|||
}); |
|||
``` |
|||
|
|||
### 根据环境动态选择 Driver |
|||
|
|||
```typescript |
|||
import { |
|||
IndexedDBDriver, |
|||
LocalStorageDriver, |
|||
MemoryStorageDriver, |
|||
StorageManager, |
|||
} from '@vben-core/shared/cache'; |
|||
|
|||
function createStorageManager(prefix: string) { |
|||
// SSR 环境使用内存驱动 |
|||
if (typeof window === 'undefined') { |
|||
return new StorageManager({ |
|||
driver: new MemoryStorageDriver(), |
|||
prefix, |
|||
}); |
|||
} |
|||
|
|||
// 大数据场景使用 IndexedDB |
|||
if (needsLargeStorage()) { |
|||
return new StorageManager({ |
|||
driver: new IndexedDBDriver({ dbName: `${prefix}-db` }), |
|||
prefix, |
|||
}); |
|||
} |
|||
|
|||
// 默认使用 localStorage |
|||
return new StorageManager({ prefix }); |
|||
} |
|||
``` |
|||
|
|||
### 命名空间隔离 |
|||
|
|||
```typescript |
|||
// 不同模块使用不同前缀,互不干扰 |
|||
const userCache = new StorageManager({prefix: 'user'}); |
|||
const configCache = new StorageManager({prefix: 'config'}); |
|||
|
|||
await userCache.setItem('profile', {name: '张三'}); |
|||
await configCache.setItem('profile', {theme: 'dark'}); |
|||
|
|||
// 各自独立 |
|||
await userCache.getItem('profile'); // => { name: '张三' } |
|||
await configCache.getItem('profile'); // => { theme: 'dark' } |
|||
|
|||
// 只清除 user 前缀的数据 |
|||
await userCache.clear(); |
|||
await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响) |
|||
``` |
|||
|
|||
### 定时清理过期数据 |
|||
|
|||
```typescript |
|||
const cache = new StorageManager({ prefix: 'app' }); |
|||
|
|||
// 应用启动时清理一次 |
|||
await cache.clearExpiredItems(); |
|||
|
|||
// 或者定时清理(每 10 分钟) |
|||
setInterval( |
|||
async () => { |
|||
await cache.clearExpiredItems(); |
|||
}, |
|||
10 * 60 * 1000, |
|||
); |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 数据存储格式 |
|||
|
|||
`StorageManager` 在 Driver 层存储的数据结构为: |
|||
|
|||
```typescript |
|||
interface StorageItem<T> { |
|||
expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期 |
|||
value: T; // 实际业务数据 |
|||
} |
|||
``` |
|||
|
|||
实际存储的 key 格式为:`{prefix}-{key}` |
|||
|
|||
例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。 |
|||
|
|||
--- |
|||
|
|||
## 过期策略 |
|||
|
|||
采用**惰性删除 + 主动清理**双重策略: |
|||
|
|||
| 策略 | 触发时机 | 说明 | |
|||
|------|--------------------------|---------------------| |
|||
| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 | |
|||
| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 | |
|||
|
|||
--- |
|||
|
|||
## 各 Driver 对比 |
|||
|
|||
| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver | |
|||
|-------|--------------------|-----------------|---------------------| |
|||
| 持久化 | ✅ | ✅ | ❌ | |
|||
| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 | |
|||
| 速度 | 快(同步) | 中等(异步 I/O) | 最快 | |
|||
| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 | |
|||
| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 | |
|||
| 阻塞主线程 | 是 | 否 | 否 | |
|||
| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR | |
|||
|
|||
--- |
|||
|
|||
## 在项目中的使用 |
|||
|
|||
本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置: |
|||
|
|||
```typescript |
|||
// packages/@core/preferences/src/preferences.ts |
|||
class PreferenceManager { |
|||
private cache: StorageManager; |
|||
|
|||
constructor() { |
|||
this.cache = new StorageManager(); |
|||
this.state = reactive<Preferences>({...defaultPreferences}); |
|||
} |
|||
|
|||
initPreferences = async ({namespace}) => { |
|||
// 用应用命名空间重新初始化 |
|||
this.cache = new StorageManager({prefix: namespace}); |
|||
|
|||
// 从缓存加载偏好设置 |
|||
const cached = await this.cache.getItem<Preferences>('preferences'); |
|||
// ... |
|||
}; |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 注意事项 |
|||
|
|||
1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver |
|||
时无需改动调用方。 |
|||
|
|||
2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。 |
|||
|
|||
3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。 |
|||
|
|||
4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。 |
|||
|
|||
5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; |
|||
`PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。 |
|||
|
|||
6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 |
|||
`upgradeneeded` 事件中自动创建 objectStore。 |
|||
@ -1 +1,5 @@ |
|||
export * from './indexeddb-driver'; |
|||
export * from './local-storage-driver'; |
|||
export * from './memory-storage-driver'; |
|||
export * from './storage-manager'; |
|||
export type * from './types'; |
|||
|
|||
@ -0,0 +1,137 @@ |
|||
import type {IStorageDriver} from './types'; |
|||
|
|||
interface IndexedDBDriverOptions { |
|||
/** 数据库名称 */ |
|||
dbName?: string; |
|||
/** 数据库版本 */ |
|||
dbVersion?: number; |
|||
/** 对象存储名称 */ |
|||
storeName?: string; |
|||
} |
|||
|
|||
/** |
|||
* IndexedDB 驱动 |
|||
* 采用懒初始化模式,首次操作时自动打开数据库 |
|||
*/ |
|||
class IndexedDBDriver implements IStorageDriver { |
|||
private dbName: string; |
|||
private dbPromise: null | Promise<IDBDatabase> = null; |
|||
private dbVersion: number; |
|||
private storeName: string; |
|||
|
|||
constructor({ |
|||
dbName = 'vben-storage', |
|||
dbVersion = 1, |
|||
storeName = 'kv-store', |
|||
}: IndexedDBDriverOptions = {}) { |
|||
this.dbName = dbName; |
|||
this.dbVersion = dbVersion; |
|||
this.storeName = storeName; |
|||
} |
|||
|
|||
async clear(): Promise<void> { |
|||
const db = await this.getDB(); |
|||
return new Promise((resolve, reject) => { |
|||
const tx = db.transaction(this.storeName, 'readwrite'); |
|||
const store = tx.objectStore(this.storeName); |
|||
store.clear(); |
|||
|
|||
tx.addEventListener('complete', () => resolve()); |
|||
tx.addEventListener('error', () => reject(tx.error)); |
|||
tx.addEventListener('abort', () => |
|||
reject(tx.error ?? new Error('Transaction aborted')), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
async getItem<T>(key: string): Promise<null | T> { |
|||
const db = await this.getDB(); |
|||
return new Promise((resolve, reject) => { |
|||
const tx = db.transaction(this.storeName, 'readonly'); |
|||
const store = tx.objectStore(this.storeName); |
|||
const request = store.get(key); |
|||
|
|||
request.addEventListener('success', () => |
|||
resolve(request.result ?? null), |
|||
); |
|||
request.addEventListener('error', () => reject(request.error)); |
|||
}); |
|||
} |
|||
|
|||
async keys(): Promise<string[]> { |
|||
const db = await this.getDB(); |
|||
return new Promise((resolve, reject) => { |
|||
const tx = db.transaction(this.storeName, 'readonly'); |
|||
const store = tx.objectStore(this.storeName); |
|||
const request = store.getAllKeys(); |
|||
|
|||
request.addEventListener('success', () => |
|||
resolve(request.result.map(String)), |
|||
); |
|||
request.addEventListener('error', () => reject(request.error)); |
|||
}); |
|||
} |
|||
|
|||
async removeItem(key: string): Promise<void> { |
|||
const db = await this.getDB(); |
|||
return new Promise((resolve, reject) => { |
|||
const tx = db.transaction(this.storeName, 'readwrite'); |
|||
const store = tx.objectStore(this.storeName); |
|||
store.delete(key); |
|||
|
|||
tx.addEventListener('complete', () => resolve()); |
|||
tx.addEventListener('error', () => reject(tx.error)); |
|||
tx.addEventListener('abort', () => |
|||
reject(tx.error ?? new Error('Transaction aborted')), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
async setItem<T>(key: string, value: T): Promise<void> { |
|||
const db = await this.getDB(); |
|||
return new Promise((resolve, reject) => { |
|||
const tx = db.transaction(this.storeName, 'readwrite'); |
|||
const store = tx.objectStore(this.storeName); |
|||
store.put(value, key); |
|||
|
|||
tx.addEventListener('complete', () => resolve()); |
|||
tx.addEventListener('error', () => reject(tx.error)); |
|||
tx.addEventListener('abort', () => |
|||
reject(tx.error ?? new Error('Transaction aborted')), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 懒初始化:首次调用时打开数据库,后续复用同一个 Promise |
|||
*/ |
|||
private getDB(): Promise<IDBDatabase> { |
|||
if (!this.dbPromise) { |
|||
this.dbPromise = this.openDB().catch((error) => { |
|||
// allow retry on next call
|
|||
this.dbPromise = null; |
|||
throw error; |
|||
}); |
|||
} |
|||
return this.dbPromise; |
|||
} |
|||
|
|||
private openDB(): Promise<IDBDatabase> { |
|||
return new Promise((resolve, reject) => { |
|||
const request = indexedDB.open(this.dbName, this.dbVersion); |
|||
|
|||
request.addEventListener('upgradeneeded', () => { |
|||
const db = request.result; |
|||
if (!db.objectStoreNames.contains(this.storeName)) { |
|||
db.createObjectStore(this.storeName); |
|||
} |
|||
}); |
|||
|
|||
request.addEventListener('success', () => resolve(request.result)); |
|||
request.addEventListener('error', () => reject(request.error)); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
export {IndexedDBDriver}; |
|||
export type {IndexedDBDriverOptions}; |
|||
@ -0,0 +1,71 @@ |
|||
import type {IStorageDriver} from './types'; |
|||
|
|||
type StorageType = 'localStorage' | 'sessionStorage'; |
|||
|
|||
interface LocalStorageDriverOptions { |
|||
/** 使用 localStorage 还是 sessionStorage */ |
|||
storageType?: StorageType; |
|||
} |
|||
|
|||
/** |
|||
* LocalStorage / SessionStorage 驱动 |
|||
* 用 async 包装同步 API,保持接口统一 |
|||
*/ |
|||
class LocalStorageDriver implements IStorageDriver { |
|||
private storage: Storage; |
|||
|
|||
constructor({ |
|||
storageType = 'localStorage', |
|||
}: LocalStorageDriverOptions = {}) { |
|||
if (typeof window === 'undefined') { |
|||
// eslint-disable-next-line unicorn/prefer-type-error -- not a type check, it's an environment check
|
|||
throw new Error( |
|||
'LocalStorageDriver is not available in non-browser environments. Use MemoryStorageDriver instead.', |
|||
); |
|||
} |
|||
this.storage = |
|||
storageType === 'localStorage' |
|||
? window.localStorage |
|||
: window.sessionStorage; |
|||
} |
|||
|
|||
async clear(): Promise<void> { |
|||
this.storage.clear(); |
|||
} |
|||
|
|||
async getItem<T>(key: string): Promise<null | T> { |
|||
const raw = this.storage.getItem(key); |
|||
if (raw === null) { |
|||
return null; |
|||
} |
|||
try { |
|||
return JSON.parse(raw) as T; |
|||
} catch { |
|||
// 数据损坏,清除并返回 null
|
|||
this.storage.removeItem(key); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
async keys(): Promise<string[]> { |
|||
const result: string[] = []; |
|||
for (let i = 0; i < this.storage.length; i++) { |
|||
const key = this.storage.key(i); |
|||
if (key !== null) { |
|||
result.push(key); |
|||
} |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
async removeItem(key: string): Promise<void> { |
|||
this.storage.removeItem(key); |
|||
} |
|||
|
|||
async setItem<T>(key: string, value: T): Promise<void> { |
|||
this.storage.setItem(key, JSON.stringify(value)); |
|||
} |
|||
} |
|||
|
|||
export {LocalStorageDriver}; |
|||
export type {LocalStorageDriverOptions}; |
|||
@ -0,0 +1,32 @@ |
|||
import type {IStorageDriver} from './types'; |
|||
|
|||
/** |
|||
* 内存存储驱动 |
|||
* 适用于测试环境和 SSR 场景,数据不持久化 |
|||
*/ |
|||
class MemoryStorageDriver implements IStorageDriver { |
|||
private store = new Map<string, unknown>(); |
|||
|
|||
async clear(): Promise<void> { |
|||
this.store.clear(); |
|||
} |
|||
|
|||
async getItem<T>(key: string): Promise<null | T> { |
|||
const value = this.store.get(key); |
|||
return (value as T) ?? null; |
|||
} |
|||
|
|||
async keys(): Promise<string[]> { |
|||
return [...this.store.keys()]; |
|||
} |
|||
|
|||
async removeItem(key: string): Promise<void> { |
|||
this.store.delete(key); |
|||
} |
|||
|
|||
async setItem<T>(key: string, value: T): Promise<void> { |
|||
this.store.set(key, value); |
|||
} |
|||
} |
|||
|
|||
export {MemoryStorageDriver}; |
|||
@ -1,17 +1,39 @@ |
|||
type StorageType = 'localStorage' | 'sessionStorage'; |
|||
/** |
|||
* 存储驱动接口(策略模式核心抽象) |
|||
* 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口 |
|||
* Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀 |
|||
*/ |
|||
interface IStorageDriver { |
|||
/** 清除所有存储项 */ |
|||
clear(): Promise<void>; |
|||
|
|||
interface StorageValue<T> { |
|||
data: T; |
|||
expiry: null | number; |
|||
/** 获取存储项 */ |
|||
getItem<T>(key: string): Promise<null | T>; |
|||
|
|||
/** 获取所有 key */ |
|||
keys(): Promise<string[]>; |
|||
|
|||
/** 移除存储项 */ |
|||
removeItem(key: string): Promise<void>; |
|||
|
|||
/** 设置存储项 */ |
|||
setItem<T>(key: string, value: T): Promise<void>; |
|||
} |
|||
|
|||
/** |
|||
* 带 TTL 的存储项包装结构 |
|||
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知 |
|||
*/ |
|||
interface StorageItem<T> { |
|||
expiry?: number; |
|||
value: T; |
|||
} |
|||
|
|||
interface IStorageCache { |
|||
clear(): void; |
|||
getItem<T>(key: string): null | T; |
|||
key(index: number): null | string; |
|||
length(): number; |
|||
removeItem(key: string): void; |
|||
setItem<T>(key: string, value: T, expiryInMinutes?: number): void; |
|||
interface StorageManagerOptions { |
|||
/** 存储驱动实例 */ |
|||
driver?: IStorageDriver; |
|||
/** 键前缀,用于命名空间隔离 */ |
|||
prefix?: string; |
|||
} |
|||
|
|||
export type { IStorageCache, StorageType, StorageValue }; |
|||
export type {IStorageDriver, StorageItem, StorageManagerOptions}; |
|||
|
|||
@ -0,0 +1,536 @@ |
|||
import type {VxeGridProps as VxeTableGridProps} from 'vxe-table'; |
|||
|
|||
import type { |
|||
ViewedRowOptions, |
|||
ViewedRowPersistOptions, |
|||
ViewedRowStorageAdapter, |
|||
} from './types'; |
|||
|
|||
import {isRef, shallowRef, toRaw, triggerRef, watch} from 'vue'; |
|||
|
|||
import {isBoolean, isFunction} from '@vben/utils'; |
|||
|
|||
import { |
|||
IndexedDBDriver, |
|||
LocalStorageDriver, |
|||
StorageManager, |
|||
} from '@vben-core/shared/cache'; |
|||
|
|||
import {useDebounceFn} from '@vueuse/core'; |
|||
|
|||
const DEFAULT_VIEWED_CLASS = 'vxe-row--viewed'; |
|||
|
|||
// ========== 持久化策略 ==========
|
|||
|
|||
/** |
|||
* localStorage / sessionStorage 适配器 |
|||
* 整体存储:key → [1, 2, 3] |
|||
*/ |
|||
function createWebStorageAdapter( |
|||
storageType: 'localStorage' | 'sessionStorage', |
|||
key: string, |
|||
ttl?: number, |
|||
): ViewedRowStorageAdapter { |
|||
const manager = new StorageManager({ |
|||
driver: new LocalStorageDriver({storageType}), |
|||
}); |
|||
|
|||
return { |
|||
async getKeys() { |
|||
const stored = await manager.getItem<Array<number | string>>(key); |
|||
return stored ?? []; |
|||
}, |
|||
async removeKeys() { |
|||
await manager.removeItem(key); |
|||
}, |
|||
async setKeys(keys) { |
|||
await manager.setItem(key, keys, ttl); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* IndexedDB 适配器 |
|||
* 单条存储:prefix:1 → { expiry, value: 1 } |
|||
*/ |
|||
function createIndexedDBAdapter( |
|||
opts: Extract<ViewedRowPersistOptions, { type: 'indexedDB' }>, |
|||
): ViewedRowStorageAdapter { |
|||
const prefix = opts.key; |
|||
const manager = new StorageManager({ |
|||
driver: new IndexedDBDriver({ |
|||
dbName: opts.dbName || 'viewed-table-db', |
|||
dbVersion: opts.dbVersion || 1, |
|||
storeName: opts.storeName || 'viewed-table-row', |
|||
}), |
|||
prefix, |
|||
}); |
|||
|
|||
return { |
|||
async getKeys() { |
|||
try { |
|||
// 通过 StorageManager 获取当前前缀下所有 key,再逐条读取(自动过滤过期)
|
|||
const shortKeys = await manager.keys(); |
|||
|
|||
const results: Array<number | string> = []; |
|||
for (const shortKey of shortKeys) { |
|||
const value = await manager.getItem<number | string>(shortKey); |
|||
if (value !== null) { |
|||
results.push(value); |
|||
} |
|||
} |
|||
return results; |
|||
} catch (error) { |
|||
console.error('[viewedRow] indexedDB restore failed:', error); |
|||
return []; |
|||
} |
|||
}, |
|||
async removeKeys() { |
|||
try { |
|||
await manager.clear(); |
|||
} catch (error) { |
|||
console.error('[viewedRow] indexedDB clear failed:', error); |
|||
} |
|||
}, |
|||
async setKeys(keys) { |
|||
try { |
|||
const newKeySet = new Set(keys.map(String)); |
|||
// 获取已存在的 key,避免重复写入刷新过期时间
|
|||
const existingKeys = await manager.keys(); |
|||
const existingKeySet = new Set(existingKeys); |
|||
|
|||
// 只写入新增的 key,不覆盖已有记录的过期时间
|
|||
const toAdd = keys.filter((key) => !existingKeySet.has(String(key))); |
|||
if (toAdd.length > 0) { |
|||
await Promise.all( |
|||
toAdd.map((key) => manager.setItem(String(key), key, opts.ttl)), |
|||
); |
|||
} |
|||
|
|||
// 清理不在新集合中的旧 key
|
|||
const toRemove = existingKeys.filter((k) => !newKeySet.has(k)); |
|||
if (toRemove.length > 0) { |
|||
await Promise.all(toRemove.map((k) => manager.removeItem(k))); |
|||
} |
|||
} catch (error) { |
|||
console.error('[viewedRow] indexedDB persist failed:', error); |
|||
} |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 根据 persist 配置创建存储适配器 |
|||
*/ |
|||
function createStorageAdapter( |
|||
persist?: string | ViewedRowPersistOptions, |
|||
): null | ViewedRowStorageAdapter { |
|||
if (!persist) return null; |
|||
|
|||
// 简写模式:string → localStorage
|
|||
if (typeof persist === 'string') { |
|||
return createWebStorageAdapter('localStorage', persist); |
|||
} |
|||
|
|||
switch (persist.type) { |
|||
case 'custom': { |
|||
// 用户自定义适配器,解除 Vue 响应式代理
|
|||
return toRaw(persist.storage); |
|||
} |
|||
case 'indexedDB': { |
|||
return createIndexedDBAdapter(persist); |
|||
} |
|||
case 'localStorage': { |
|||
return createWebStorageAdapter('localStorage', persist.key, persist.ttl); |
|||
} |
|||
case 'memory': { |
|||
return null; |
|||
} |
|||
case 'sessionStorage': { |
|||
return createWebStorageAdapter( |
|||
'sessionStorage', |
|||
persist.key, |
|||
persist.ttl, |
|||
); |
|||
} |
|||
default: { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ========== maxSize 淘汰 ==========
|
|||
|
|||
/** |
|||
* 强制执行 maxSize 限制,超出时淘汰最早插入的 key(FIFO) |
|||
*/ |
|||
function enforceMaxSize(set: Set<number | string>, maxSize: number): void { |
|||
if (maxSize > 0 && set.size > maxSize) { |
|||
const iterator = set.values(); |
|||
while (set.size > maxSize) { |
|||
const oldest = iterator.next().value; |
|||
if (oldest !== undefined) { |
|||
set.delete(oldest); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ========== 核心 composable ==========
|
|||
|
|||
export function useViewedRow<T = any>( |
|||
options: ViewedRowOptions<T> & { keyField: string }, |
|||
) { |
|||
// ========== 解析持久化配置 ==========
|
|||
const persistOpts: null | ViewedRowPersistOptions = options.persist |
|||
? (typeof options.persist === 'string' |
|||
? {key: options.persist, type: 'localStorage'} |
|||
: options.persist) |
|||
: null; |
|||
|
|||
const adapter = createStorageAdapter(options.persist); |
|||
const maxSize = persistOpts?.maxSize ?? 100; |
|||
|
|||
// ========== 初始化已读集合 ==========
|
|||
const viewedSet = shallowRef<Set<number | string>>(new Set()); |
|||
|
|||
// ========== 持久化(防抖) ==========
|
|||
function persistImmediate() { |
|||
if (!adapter) return; |
|||
adapter.setKeys([...viewedSet.value]).catch((error) => { |
|||
console.error('[viewedRow] persist failed:', error); |
|||
}); |
|||
} |
|||
|
|||
const persist = useDebounceFn(persistImmediate, 300); |
|||
|
|||
// ========== 从存储恢复 ==========
|
|||
async function restoreFromStorage(): Promise<void> { |
|||
if (!adapter) return; |
|||
|
|||
try { |
|||
const stored = await adapter.getKeys(); |
|||
if (stored && stored.length > 0) { |
|||
for (const key of stored) { |
|||
viewedSet.value.add(key); |
|||
} |
|||
if (maxSize > 0) { |
|||
enforceMaxSize(viewedSet.value, maxSize); |
|||
} |
|||
triggerRef(viewedSet); |
|||
} |
|||
} catch (error) { |
|||
console.error('[viewedRow] restore failed:', error); |
|||
} |
|||
} |
|||
|
|||
// 先恢复存储,再合并外部 viewedKeys,确保 viewedKeys 是最新插入的(最后被淘汰)
|
|||
restoreFromStorage().then(() => { |
|||
if (options.viewedKeys) { |
|||
const keys = isRef(options.viewedKeys) |
|||
? options.viewedKeys.value |
|||
: options.viewedKeys; |
|||
updateViewedSet((set) => { |
|||
let changed = false; |
|||
for (const key of keys) { |
|||
if (!set.has(key)) { |
|||
set.add(key); |
|||
changed = true; |
|||
} |
|||
} |
|||
return changed; |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
// ========== 更新 viewedSet 的统一入口 ==========
|
|||
function updateViewedSet(updater: (set: Set<number | string>) => boolean) { |
|||
const changed = updater(viewedSet.value); |
|||
|
|||
if (changed) { |
|||
if (maxSize > 0) { |
|||
enforceMaxSize(viewedSet.value, maxSize); |
|||
} |
|||
triggerRef(viewedSet); |
|||
persist(); |
|||
} |
|||
} |
|||
|
|||
// ========== 监听外部 viewedKeys 变化(如果是 Ref) ==========
|
|||
if (isRef(options.viewedKeys)) { |
|||
watch(options.viewedKeys, (newKeys) => { |
|||
updateViewedSet((set) => { |
|||
let changed = false; |
|||
for (const key of newKeys) { |
|||
if (!set.has(key)) { |
|||
set.add(key); |
|||
changed = true; |
|||
} |
|||
} |
|||
return changed; |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
// ========== 标记已读 ==========
|
|||
function markAsViewed(record: T) { |
|||
const key = (record as Record<string, any>)[options.keyField] as |
|||
| number |
|||
| string; |
|||
if (key === null || key === undefined) return; |
|||
|
|||
updateViewedSet((set) => { |
|||
if (set.has(key)) return false; |
|||
set.add(key); |
|||
return true; |
|||
}); |
|||
} |
|||
|
|||
function markKeysAsViewed(keys: Array<number | string>) { |
|||
updateViewedSet((set) => { |
|||
let changed = false; |
|||
for (const key of keys) { |
|||
if (!set.has(key)) { |
|||
set.add(key); |
|||
changed = true; |
|||
} |
|||
} |
|||
return changed; |
|||
}); |
|||
} |
|||
|
|||
// ========== 查询 ==========
|
|||
function isViewed(record: T): boolean { |
|||
const key = (record as Record<string, any>)[options.keyField] as |
|||
| number |
|||
| string; |
|||
return viewedSet.value.has(key); |
|||
} |
|||
|
|||
// ========== 清除 ==========
|
|||
function clearViewed() { |
|||
const hadData = viewedSet.value.size > 0; |
|||
viewedSet.value.clear(); |
|||
|
|||
if (hadData) { |
|||
triggerRef(viewedSet); |
|||
} |
|||
|
|||
if (adapter) { |
|||
adapter.removeKeys().catch((error) => { |
|||
console.error('[viewedRow] clear persist failed:', error); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// ========== 移除指定 keys ==========
|
|||
function removeKeys(keys: Array<number | string>) { |
|||
updateViewedSet((set) => { |
|||
let changed = false; |
|||
for (const key of keys) { |
|||
if (set.has(key)) { |
|||
set.delete(key); |
|||
changed = true; |
|||
} |
|||
} |
|||
return changed; |
|||
}); |
|||
} |
|||
|
|||
// ========== rowClassName 函数 ==========
|
|||
function getRowClassName(params: any): string { |
|||
if (!isViewed(params.row)) return ''; |
|||
|
|||
const {rowClassName} = options; |
|||
if (rowClassName === undefined || rowClassName === null) { |
|||
return DEFAULT_VIEWED_CLASS; |
|||
} |
|||
if (typeof rowClassName === 'string') { |
|||
return rowClassName; |
|||
} |
|||
if (isFunction(rowClassName)) { |
|||
return normalizeClassName(rowClassName(params)); |
|||
} |
|||
return DEFAULT_VIEWED_CLASS; |
|||
} |
|||
|
|||
// ========== rowStyle 函数 ==========
|
|||
function getRowStyle(params: any): any { |
|||
if (!isViewed(params.row)) return undefined; |
|||
|
|||
const {rowStyle} = options; |
|||
if (rowStyle === undefined || rowStyle === null) { |
|||
return undefined; |
|||
} |
|||
if (isFunction(rowStyle)) { |
|||
return rowStyle(params); |
|||
} |
|||
return rowStyle; |
|||
} |
|||
|
|||
return { |
|||
clearViewed, |
|||
getRowClassName, |
|||
getRowStyle, |
|||
isViewed, |
|||
markAsViewed, |
|||
markKeysAsViewed, |
|||
removeKeys, |
|||
viewedSet, |
|||
}; |
|||
} |
|||
|
|||
export type ViewedRowHelper<T = any> = ReturnType<typeof useViewedRow<T>>; |
|||
|
|||
// ========== 工具函数 ==========
|
|||
|
|||
function normalizeClassName(value: any): string { |
|||
if (!value) return ''; |
|||
if (typeof value === 'string') return value; |
|||
if (typeof value === 'object') { |
|||
return Object.entries(value) |
|||
.filter(([, v]) => v) |
|||
.map(([k]) => k) |
|||
.join(' '); |
|||
} |
|||
return ''; |
|||
} |
|||
|
|||
function mergeClassNames(...classNames: any[]): string { |
|||
return classNames |
|||
.map((c) => normalizeClassName(c)) |
|||
.filter(Boolean) |
|||
.join(' '); |
|||
} |
|||
|
|||
/** |
|||
* 包装 columns,拦截 CellOperation 的 onClick,根据 actionCodes 自动标记已读 |
|||
* 注意:columns 每次都是 cloneDeep 后的新对象,不存在重复包装问题 |
|||
*/ |
|||
function wrapColumnsForViewedRow( |
|||
columns: any[], |
|||
actionCodes: string[], |
|||
markAsViewed: (record: any) => void, |
|||
): any[] { |
|||
return columns.map((column) => { |
|||
if (!column || typeof column !== 'object') return column; |
|||
|
|||
const nextColumn = {...column}; |
|||
|
|||
if (nextColumn.cellRender?.name === 'CellOperation') { |
|||
const cellRender = {...nextColumn.cellRender}; |
|||
const attrs = {...cellRender.attrs}; |
|||
const originalOnClick = attrs.onClick; |
|||
|
|||
attrs.onClick = (params: { code: string; row: any }) => { |
|||
originalOnClick?.(params); |
|||
if (actionCodes.includes(params.code)) { |
|||
markAsViewed(params.row); |
|||
} |
|||
}; |
|||
|
|||
cellRender.attrs = attrs; |
|||
nextColumn.cellRender = cellRender; |
|||
} |
|||
|
|||
if (Array.isArray(nextColumn.children)) { |
|||
nextColumn.children = wrapColumnsForViewedRow( |
|||
nextColumn.children, |
|||
actionCodes, |
|||
markAsViewed, |
|||
); |
|||
} |
|||
|
|||
return nextColumn; |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 将 viewedRow 配置应用到 mergedOptions 上 |
|||
* 注入 rowClassName、rowStyle、columns 拦截 |
|||
*/ |
|||
export function applyViewedRowOptions( |
|||
mergedOptions: VxeTableGridProps, |
|||
viewedRowConfig: boolean | ViewedRowOptions, |
|||
helper: ReturnType<typeof useViewedRow>, |
|||
) { |
|||
// 从最新的配置中读取 rowClassName 和 rowStyle(支持运行时修改)
|
|||
const viewedRowClassName = isBoolean(viewedRowConfig) |
|||
? undefined |
|||
: viewedRowConfig.rowClassName; |
|||
const viewedRowStyle = isBoolean(viewedRowConfig) |
|||
? undefined |
|||
: viewedRowConfig.rowStyle; |
|||
|
|||
// 注入 rowClassName
|
|||
const originalRowClassName = mergedOptions.rowClassName; |
|||
mergedOptions.rowClassName = (params: any) => { |
|||
if (!helper.isViewed(params.row)) { |
|||
return normalizeClassName( |
|||
isFunction(originalRowClassName) |
|||
? originalRowClassName(params) |
|||
: originalRowClassName, |
|||
); |
|||
} |
|||
|
|||
let viewedClass: string; |
|||
if (viewedRowClassName === undefined || viewedRowClassName === null) { |
|||
viewedClass = DEFAULT_VIEWED_CLASS; |
|||
} else if (typeof viewedRowClassName === 'string') { |
|||
viewedClass = viewedRowClassName; |
|||
} else if (isFunction(viewedRowClassName)) { |
|||
viewedClass = normalizeClassName(viewedRowClassName(params)); |
|||
} else { |
|||
viewedClass = DEFAULT_VIEWED_CLASS; |
|||
} |
|||
|
|||
return mergeClassNames( |
|||
isFunction(originalRowClassName) |
|||
? originalRowClassName(params) |
|||
: originalRowClassName, |
|||
viewedClass, |
|||
); |
|||
}; |
|||
|
|||
// 注入 rowStyle
|
|||
const originalRowStyle = mergedOptions.rowStyle; |
|||
mergedOptions.rowStyle = (params: any) => { |
|||
const originalStyle = isFunction(originalRowStyle) |
|||
? originalRowStyle(params) |
|||
: originalRowStyle; |
|||
|
|||
if (!helper.isViewed(params.row)) { |
|||
return originalStyle || undefined; |
|||
} |
|||
|
|||
let viewedStyle: any; |
|||
if (viewedRowStyle === undefined || viewedRowStyle === null) { |
|||
viewedStyle = undefined; |
|||
} else if (isFunction(viewedRowStyle)) { |
|||
viewedStyle = viewedRowStyle(params); |
|||
} else { |
|||
viewedStyle = viewedRowStyle; |
|||
} |
|||
|
|||
if (!viewedStyle && !originalStyle) return undefined; |
|||
if (!originalStyle) return viewedStyle; |
|||
if (!viewedStyle) return originalStyle; |
|||
return {...originalStyle, ...viewedStyle}; |
|||
}; |
|||
|
|||
// 拦截 CellOperation columns
|
|||
const actionCodes = |
|||
!isBoolean(viewedRowConfig) && viewedRowConfig.actionCodes |
|||
? (Array.isArray(viewedRowConfig.actionCodes) |
|||
? viewedRowConfig.actionCodes |
|||
: [viewedRowConfig.actionCodes]) |
|||
: []; |
|||
|
|||
if (actionCodes.length > 0 && Array.isArray(mergedOptions.columns)) { |
|||
mergedOptions.columns = wrapColumnsForViewedRow( |
|||
mergedOptions.columns, |
|||
actionCodes, |
|||
helper.markAsViewed, |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,209 @@ |
|||
<script lang="ts" setup> |
|||
import type {OnActionClickParams, VxeGridProps} from '#/adapter/vxe-table'; |
|||
|
|||
import {ref} from 'vue'; |
|||
|
|||
import {Page, useVbenModal} from '@vben/common-ui'; |
|||
import {$t} from '@vben/locales'; |
|||
|
|||
import {Button, message} from 'ant-design-vue'; |
|||
|
|||
import {useVbenVxeGrid} from '#/adapter/vxe-table'; |
|||
import {getExampleTableApi} from '#/api'; |
|||
|
|||
interface RowType { |
|||
category: string; |
|||
color: string; |
|||
id: string; |
|||
price: string; |
|||
productName: string; |
|||
releaseDate: string; |
|||
} |
|||
|
|||
const gridOptions: VxeGridProps<RowType> = { |
|||
checkboxConfig: { |
|||
highlight: true, |
|||
labelField: 'category', |
|||
}, |
|||
columns: [ |
|||
{title: '序号', type: 'seq', width: 50}, |
|||
{field: 'category', sortable: true, title: 'Category'}, |
|||
{field: 'color', sortable: true, title: 'Color'}, |
|||
{field: 'productName', sortable: true, title: 'Product Name'}, |
|||
{field: 'price', sortable: true, title: 'Price'}, |
|||
{field: 'releaseDate', formatter: 'formatDateTime', title: 'DateTime'}, |
|||
{ |
|||
align: 'center', |
|||
cellRender: { |
|||
attrs: { |
|||
nameField: 'category', |
|||
onClick: onActionClick, |
|||
}, |
|||
name: 'CellOperation', |
|||
options: [ |
|||
{ |
|||
code: 'view', |
|||
text: '查看', |
|||
}, |
|||
'edit', |
|||
], |
|||
}, |
|||
field: 'operation', |
|||
fixed: 'right', |
|||
headerAlign: 'center', |
|||
showOverflow: false, |
|||
title: $t('system.menu.operation'), |
|||
width: 200, |
|||
}, |
|||
], |
|||
exportConfig: {}, |
|||
height: 'auto', |
|||
keepSource: true, |
|||
proxyConfig: { |
|||
ajax: { |
|||
query: async ({page, sort}) => { |
|||
return await getExampleTableApi({ |
|||
page: page.currentPage, |
|||
pageSize: page.pageSize, |
|||
sortBy: sort.field, |
|||
sortOrder: sort.order, |
|||
}); |
|||
}, |
|||
}, |
|||
sort: true, |
|||
}, |
|||
sortConfig: { |
|||
defaultSort: {field: 'category', order: 'desc'}, |
|||
remote: true, |
|||
}, |
|||
toolbarConfig: { |
|||
custom: true, |
|||
export: true, |
|||
// import: true, |
|||
refresh: true, |
|||
zoom: true, |
|||
}, |
|||
}; |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({ |
|||
gridOptions, |
|||
viewedRowOptions: { |
|||
// 触发已读的操作码 |
|||
actionCodes: ['view'], |
|||
// 行数据中的唯一标识字段 |
|||
keyField: 'id', |
|||
// 持久化配置(简写模式,使用内置 localStorage) |
|||
// persist: 'viewed_rows', |
|||
persist: { |
|||
key: 'viewed-rows', |
|||
type: 'indexedDB', |
|||
ttl: 7 * 24 * 60 * 60 * 1000, // 7天过期 |
|||
maxSize: 200, |
|||
}, |
|||
}, |
|||
}); |
|||
|
|||
function onActionClick({code, row}: OnActionClickParams<RowType>) { |
|||
switch (code) { |
|||
case 'edit': { |
|||
onEdit(row); |
|||
break; |
|||
} |
|||
case 'view': { |
|||
onView(row); |
|||
break; |
|||
} |
|||
default: { |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
const editRow = ref<RowType>(); |
|||
const [Modal, modalApi] = useVbenModal({ |
|||
draggable: true, |
|||
onConfirm: () => { |
|||
modalApi.setState({loading: true}); |
|||
editRow.value && gridApi.markRowAsViewed(editRow.value); |
|||
modalApi.setState({loading: false}); |
|||
modalApi.close(); |
|||
}, |
|||
}); |
|||
|
|||
function onEdit(row: RowType) { |
|||
editRow.value = row; |
|||
modalApi.open(); |
|||
} |
|||
|
|||
function onView(row: RowType) { |
|||
message.success({ |
|||
content: `查看${row.category}`, |
|||
key: 'action_process_msg_id', |
|||
}); |
|||
} |
|||
|
|||
const isStyle = ref(false); |
|||
|
|||
function onStyleSet() { |
|||
isStyle.value = !isStyle.value; |
|||
gridApi.setState({ |
|||
viewedRowOptions: { |
|||
rowStyle: () => { |
|||
return isStyle.value ? {backgroundColor: 'gray'} : ''; |
|||
}, |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
const isClassName = ref(false); |
|||
|
|||
function onClassNameSet() { |
|||
isClassName.value = !isClassName.value; |
|||
gridApi.setState({ |
|||
viewedRowOptions: { |
|||
rowClassName: () => { |
|||
return isClassName.value |
|||
? 'bg-red-100 vxe-row--viewed' |
|||
: 'vxe-row--viewed'; |
|||
}, |
|||
}, |
|||
}); |
|||
} |
|||
|
|||
function onCustomSet() { |
|||
const tableData = gridApi.grid.getData(); |
|||
const keys = tableData.slice(0, 2).map((row) => row.id); |
|||
gridApi.markKeysAsViewed(keys); |
|||
} |
|||
|
|||
function onClearViewed() { |
|||
gridApi.clearViewedRows(); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Page |
|||
auto-content-height |
|||
description="表格行标记支持存储类型 custom | indexedDB | localStorage | memory | sessionStorage 。 |
|||
默认使用memory存储,当设置custom时需要自己实现getKeys()/setKeys()/removeKeys()。 |
|||
具体属性查看packages/effects/plugins/src/vxe-table/types.ts。可通过gridApi调用 |
|||
clearViewedRows()/getViewedKeys()/isRowViewed()/markKeysAsViewed()/markRowAsViewed()/removeViewedKeys()" |
|||
title="表格行标记示例" |
|||
> |
|||
<Modal class="w-150" title="数据修改"> 数据修改完成后设置行标记</Modal> |
|||
<Grid table-title="已查看行标记" table-title-help="提示"> |
|||
<template #toolbar-tools> |
|||
<Button class="mr-2" type="primary" @click="onCustomSet"> |
|||
手动标记 |
|||
</Button> |
|||
<Button class="mr-2" type="primary" @click="onStyleSet"> |
|||
设置Style |
|||
</Button> |
|||
<Button class="mr-2" type="primary" @click="onClassNameSet"> |
|||
设置ClassName |
|||
</Button> |
|||
<Button type="primary" @click="onClearViewed"> 清空缓存</Button> |
|||
</template> |
|||
</Grid> |
|||
</Page> |
|||
</template> |
|||
Loading…
Reference in new issue