diff --git a/packages/@core/base/shared/src/cache/README.md b/packages/@core/base/shared/src/cache/README.md new file mode 100644 index 000000000..5b5dc7cf9 --- /dev/null +++ b/packages/@core/base/shared/src/cache/README.md @@ -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(key: string, defaultValue?: T \| null): Promise` | 获取存储项,过期或不存在返回默认值 | +| `setItem` | `setItem(key: string, value: T, ttl?: number): Promise` | 设置存储项,可选 TTL(毫秒) | +| `removeItem` | `removeItem(key: string): Promise` | 删除指定存储项 | +| `clear` | `clear(): Promise` | 清除当前前缀下所有存储项 | +| `clearExpiredItems` | `clearExpiredItems(): Promise` | 主动清理所有过期项 | + +--- + +### IStorageDriver 接口 + +自定义驱动需要实现此接口: + +```typescript +interface IStorageDriver { + clear(): Promise; + + getItem(key: string): Promise; + + keys(): Promise; + + removeItem(key: string): Promise; + + setItem(key: string, value: T): Promise; +} +``` + +--- + +## 高级用法 + +### 自定义 Driver + +```typescript +import type {IStorageDriver} from '@vben-core/shared/cache'; + +class CookieStorageDriver implements IStorageDriver { + async getItem(key: string): Promise { + const value = getCookie(key); + return value ? JSON.parse(value) : null; + } + + async setItem(key: string, value: T): Promise { + setCookie(key, JSON.stringify(value)); + } + + async removeItem(key: string): Promise { + deleteCookie(key); + } + + async clear(): Promise { + clearAllCookies(); + } + + async keys(): Promise { + 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 { + 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({...defaultPreferences}); + } + + initPreferences = async ({namespace}) => { + // 用应用命名空间重新初始化 + this.cache = new StorageManager({prefix: namespace}); + + // 从缓存加载偏好设置 + const cached = await this.cache.getItem('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。 diff --git a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts b/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts index a5abe5a71..6d58cedd2 100644 --- a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts +++ b/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import {MemoryStorageDriver} from '../memory-storage-driver'; import { StorageManager } from '../storage-manager'; describe('storageManager', () => { @@ -7,123 +8,115 @@ describe('storageManager', () => { beforeEach(() => { vi.useFakeTimers(); - localStorage.clear(); storageManager = new StorageManager({ + driver: new MemoryStorageDriver(), prefix: 'test_', }); }); - it('should set and get an item', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - const user = storageManager.getItem('user'); + it('should set and get an item', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should return default value if item does not exist', () => { - const user = storageManager.getItem('nonexistent', { + it('should return default value if item does not exist', async () => { + const user = await storageManager.getItem('nonexistent', { age: 0, name: 'Default User', }); expect(user).toEqual({ age: 0, name: 'Default User' }); }); - it('should remove an item', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - storageManager.removeItem('user'); - const user = storageManager.getItem('user'); + it('should remove an item', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + await storageManager.removeItem('user'); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should clear all items with the prefix', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }); - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); - storageManager.clear(); - expect(storageManager.getItem('user1')).toBeNull(); - expect(storageManager.getItem('user2')).toBeNull(); + it('should clear all items with the prefix', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}); + await storageManager.clear(); + expect(await storageManager.getItem('user1')).toBeNull(); + expect(await storageManager.getItem('user2')).toBeNull(); }); - it('should clear expired items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 + it('should clear expired items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should not clear non-expired items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期 + it('should not clear non-expired items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should handle JSON parse errors gracefully', () => { - localStorage.setItem('test_user', '{ invalid JSON }'); - const user = storageManager.getItem('user', { - age: 0, - name: 'Default User', - }); - expect(user).toEqual({ age: 0, name: 'Default User' }); - }); - it('should return null for non-existent items without default value', () => { - const user = storageManager.getItem('nonexistent'); + it('should return null for non-existent items without default value', async () => { + const user = await storageManager.getItem('nonexistent'); expect(user).toBeNull(); }); - it('should overwrite existing items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - storageManager.setItem('user', { age: 25, name: 'Jane Doe' }); - const user = storageManager.getItem('user'); + it('should overwrite existing items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user', {age: 25, name: 'Jane Doe'}); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 25, name: 'Jane Doe' }); }); - it('should handle items without expiry correctly', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); + it('should handle items without expiry correctly', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); vi.advanceTimersByTime(5000); - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should remove expired items when accessed', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 + it('should remove expired items when accessed', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should not remove non-expired items when accessed', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期 + it('should not remove non-expired items when accessed', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should handle multiple items with different expiry times', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期 + it('should handle multiple items with different expiry times', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}, 2000); // 2秒过期 vi.advanceTimersByTime(1500); // 快进时间 - storageManager.clearExpiredItems(); - const user1 = storageManager.getItem('user1'); - const user2 = storageManager.getItem('user2'); + await storageManager.clearExpiredItems(); + const user1 = await storageManager.getItem('user1'); + const user2 = await storageManager.getItem('user2'); expect(user1).toBeNull(); expect(user2).toEqual({ age: 25, name: 'Jane Doe' }); }); - it('should handle items with no expiry', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); + it('should handle items with no expiry', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); vi.advanceTimersByTime(10_000); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should clear all items correctly', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }); - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); - storageManager.clear(); - const user1 = storageManager.getItem('user1'); - const user2 = storageManager.getItem('user2'); + it('should clear all items correctly', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}); + await storageManager.clear(); + const user1 = await storageManager.getItem('user1'); + const user2 = await storageManager.getItem('user2'); expect(user1).toBeNull(); expect(user2).toBeNull(); }); diff --git a/packages/@core/base/shared/src/cache/index.ts b/packages/@core/base/shared/src/cache/index.ts index 8c44d7f16..e1d5c6e86 100644 --- a/packages/@core/base/shared/src/cache/index.ts +++ b/packages/@core/base/shared/src/cache/index.ts @@ -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'; diff --git a/packages/@core/base/shared/src/cache/indexeddb-driver.ts b/packages/@core/base/shared/src/cache/indexeddb-driver.ts new file mode 100644 index 000000000..9875d888a --- /dev/null +++ b/packages/@core/base/shared/src/cache/indexeddb-driver.ts @@ -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 = 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 { + 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(key: string): Promise { + 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 { + 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 { + 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(key: string, value: T): Promise { + 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 { + 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 { + 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}; diff --git a/packages/@core/base/shared/src/cache/local-storage-driver.ts b/packages/@core/base/shared/src/cache/local-storage-driver.ts new file mode 100644 index 000000000..6026246ea --- /dev/null +++ b/packages/@core/base/shared/src/cache/local-storage-driver.ts @@ -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 { + this.storage.clear(); + } + + async getItem(key: string): Promise { + 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 { + 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 { + this.storage.removeItem(key); + } + + async setItem(key: string, value: T): Promise { + this.storage.setItem(key, JSON.stringify(value)); + } +} + +export {LocalStorageDriver}; +export type {LocalStorageDriverOptions}; diff --git a/packages/@core/base/shared/src/cache/memory-storage-driver.ts b/packages/@core/base/shared/src/cache/memory-storage-driver.ts new file mode 100644 index 000000000..530c19ef5 --- /dev/null +++ b/packages/@core/base/shared/src/cache/memory-storage-driver.ts @@ -0,0 +1,32 @@ +import type {IStorageDriver} from './types'; + +/** + * 内存存储驱动 + * 适用于测试环境和 SSR 场景,数据不持久化 + */ +class MemoryStorageDriver implements IStorageDriver { + private store = new Map(); + + async clear(): Promise { + this.store.clear(); + } + + async getItem(key: string): Promise { + const value = this.store.get(key); + return (value as T) ?? null; + } + + async keys(): Promise { + return [...this.store.keys()]; + } + + async removeItem(key: string): Promise { + this.store.delete(key); + } + + async setItem(key: string, value: T): Promise { + this.store.set(key, value); + } +} + +export {MemoryStorageDriver}; diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 4360621e4..ef42aa7f4 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -1,53 +1,54 @@ -type StorageType = 'localStorage' | 'sessionStorage'; +import type { + IStorageDriver, + StorageItem, + StorageManagerOptions, +} from './types'; -interface StorageManagerOptions { - prefix?: string; - storageType?: StorageType; -} - -interface StorageItem { - expiry?: number; - value: T; -} +import {LocalStorageDriver} from './local-storage-driver'; +import {MemoryStorageDriver} from './memory-storage-driver'; +/** + * 存储管理器(策略模式) + * - prefix(命名空间隔离)在此层处理 + * - TTL(过期机制)在此层处理 + * - Driver 只负责纯粹的 KV 存取 + */ class StorageManager { + private driver: IStorageDriver; private prefix: string; - private storage: Storage; - constructor({ - prefix = '', - storageType = 'localStorage', - }: StorageManagerOptions = {}) { + constructor({driver, prefix = ''}: StorageManagerOptions = {}) { + this.driver = driver || this.createDefaultDriver(); this.prefix = prefix; - this.storage = - storageType === 'localStorage' - ? window.localStorage - : window.sessionStorage; + if (!this.prefix && this.driver instanceof LocalStorageDriver) { + console.warn( + '[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.', + ); + } } /** * 清除所有带前缀的存储项 */ - clear(): void { - const keysToRemove: string[] = []; - for (let i = 0; i < this.storage.length; i++) { - const key = this.storage.key(i); - if (key && key.startsWith(this.prefix)) { - keysToRemove.push(key); - } - } - keysToRemove.forEach((key) => this.storage.removeItem(key)); + async clear(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix)); + await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key))); } /** * 清除所有过期的存储项 */ - clearExpiredItems(): void { - for (let i = 0; i < this.storage.length; i++) { - const key = this.storage.key(i); - if (key && key.startsWith(this.prefix)) { - const shortKey = key.replace(this.prefix, ''); - this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项 + async clearExpiredItems(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix)); + + for (const fullKey of prefixedKeys) { + const raw = await this.driver.getItem>(fullKey); + if (raw && raw.expiry && Date.now() > raw.expiry) { + await this.driver.removeItem(fullKey); } } } @@ -56,36 +57,47 @@ class StorageManager { * 获取存储项 * @param key 键 * @param defaultValue 当项不存在或已过期时返回的默认值 - * @returns 值,如果项已过期或解析错误则返回默认值 + * @returns 值,如果项已过期则返回默认值 */ - getItem(key: string, defaultValue: null | T = null): null | T { + async getItem( + key: string, + defaultValue: null | T = null, + ): Promise { const fullKey = this.getFullKey(key); - const itemStr = this.storage.getItem(fullKey); - if (!itemStr) { + const raw = await this.driver.getItem>(fullKey); + + if (!raw) { return defaultValue; } - try { - const item: StorageItem = JSON.parse(itemStr); - if (item.expiry && Date.now() > item.expiry) { - this.storage.removeItem(fullKey); - return defaultValue; - } - return item.value; - } catch (error) { - console.error(`Error parsing item with key "${fullKey}":`, error); - this.storage.removeItem(fullKey); // 如果解析失败,删除该项 + // TTL 检查 + if (raw.expiry && Date.now() > raw.expiry) { + await this.driver.removeItem(fullKey); return defaultValue; } + + return raw.value; + } + + /** + * 获取当前前缀下的所有存储键(已去除前缀部分) + */ + async keys(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + if (!fullPrefix) return allKeys; + return allKeys + .filter((key) => key.startsWith(fullPrefix)) + .map((key) => key.slice(fullPrefix.length)); } /** * 移除存储项 * @param key 键 */ - removeItem(key: string): void { + async removeItem(key: string): Promise { const fullKey = this.getFullKey(key); - this.storage.removeItem(fullKey); + await this.driver.removeItem(fullKey); } /** @@ -94,24 +106,40 @@ class StorageManager { * @param value 值 * @param ttl 存活时间(毫秒) */ - setItem(key: string, value: T, ttl?: number): void { + async setItem(key: string, value: T, ttl?: number): Promise { const fullKey = this.getFullKey(key); const expiry = ttl ? Date.now() + ttl : undefined; const item: StorageItem = { expiry, value }; - try { - this.storage.setItem(fullKey, JSON.stringify(item)); - } catch (error) { - console.error(`Error setting item with key "${fullKey}":`, error); - } + await this.driver.setItem(fullKey, item); } /** - * 获取完整的存储键 + * 根据运行环境创建默认驱动: + * - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver + * - SSR / Node 环境 → MemoryStorageDriver + */ + private createDefaultDriver(): IStorageDriver { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return new LocalStorageDriver(); + } + } catch (error) { + // localStorage access denied (e.g. Safari private mode) + console.warn( + 'localStorage is not accessible, falling back to MemoryStorageDriver:', + error, + ); + } + return new MemoryStorageDriver(); + } + + /** + * 获取完整的存储键(带前缀) * @param key 原始键 * @returns 带前缀的完整键 */ private getFullKey(key: string): string { - return `${this.prefix}-${key}`; + return this.prefix ? `${this.prefix}-${key}` : key; } } diff --git a/packages/@core/base/shared/src/cache/types.ts b/packages/@core/base/shared/src/cache/types.ts index d8939208e..c967223a6 100644 --- a/packages/@core/base/shared/src/cache/types.ts +++ b/packages/@core/base/shared/src/cache/types.ts @@ -1,17 +1,39 @@ -type StorageType = 'localStorage' | 'sessionStorage'; +/** + * 存储驱动接口(策略模式核心抽象) + * 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口 + * Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀 + */ +interface IStorageDriver { + /** 清除所有存储项 */ + clear(): Promise; -interface StorageValue { - data: T; - expiry: null | number; + /** 获取存储项 */ + getItem(key: string): Promise; + + /** 获取所有 key */ + keys(): Promise; + + /** 移除存储项 */ + removeItem(key: string): Promise; + + /** 设置存储项 */ + setItem(key: string, value: T): Promise; } -interface IStorageCache { - clear(): void; - getItem(key: string): null | T; - key(index: number): null | string; - length(): number; - removeItem(key: string): void; - setItem(key: string, value: T, expiryInMinutes?: number): void; +/** + * 带 TTL 的存储项包装结构 + * TTL 逻辑由 StorageManager 统一管理,Driver 层不感知 + */ +interface StorageItem { + expiry?: number; + value: T; } -export type { IStorageCache, StorageType, StorageValue }; +interface StorageManagerOptions { + /** 存储驱动实例 */ + driver?: IStorageDriver; + /** 键前缀,用于命名空间隔离 */ + prefix?: string; +} + +export type {IStorageDriver, StorageItem, StorageManagerOptions}; diff --git a/packages/@core/preferences/__tests__/preferences.test.ts b/packages/@core/preferences/__tests__/preferences.test.ts index 84e9844e4..6afdb2182 100644 --- a/packages/@core/preferences/__tests__/preferences.test.ts +++ b/packages/@core/preferences/__tests__/preferences.test.ts @@ -105,7 +105,7 @@ describe('preferences', () => { expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true); }); - it('resets preferences to default', () => { + it('resets preferences to default', async () => { // 先更新一些偏好设置 preferenceManager.updatePreferences({ theme: { @@ -114,7 +114,7 @@ describe('preferences', () => { }); // 然后重置偏好设置 - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); }); @@ -174,7 +174,7 @@ describe('preferences', () => { ); }); - it('resets preferences to default correctly', () => { + it('resets preferences to default correctly', async () => { // 先更新一些偏好设置 preferenceManager.updatePreferences({ app: { locale: 'en-US' }, @@ -185,7 +185,7 @@ describe('preferences', () => { }); // 然后重置偏好设置 - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); }); @@ -377,7 +377,7 @@ describe('preferences', () => { reportTitle: '月报', }); - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getCustomPreferences()).toEqual({ pageSize: 20, diff --git a/packages/@core/preferences/src/preferences.ts b/packages/@core/preferences/src/preferences.ts index 7f82bb094..ce1cd17d4 100644 --- a/packages/@core/preferences/src/preferences.ts +++ b/packages/@core/preferences/src/preferences.ts @@ -41,17 +41,19 @@ class PreferenceManager { constructor() { this.cache = new StorageManager(); - this.state = reactive( - this.loadFromCache() || { ...defaultPreferences }, - ); + // 构造函数不再同步读取缓存,使用默认值初始化 + // 真正的缓存加载在 initPreferences 中完成(已经是 async) + this.state = reactive({...defaultPreferences}); this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150); } /** * 清除所有缓存的偏好设置 */ - clearCache = () => { - Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key)); + clearCache = async () => { + await Promise.all( + Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)), + ); }; /** @@ -130,7 +132,7 @@ class PreferenceManager { ); // 加载缓存的偏好设置并与初始配置合并 - const cachedPreferences = this.loadFromCache() || {}; + const cachedPreferences = (await this.loadFromCache()) || {}; const mergedPreference = merge( {}, cachedPreferences, @@ -139,14 +141,16 @@ class PreferenceManager { // 更新偏好设置 this.updatePreferences(mergedPreference); + + const cachedCustom = (await this.loadCustomFromCache()) || {}; this.replaceCustomPreferences( merge( {}, - this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}), + this.sanitizeCustomPreferences(cachedCustom), this.initialCustomPreferences, ), ); - this.saveToCache(); + await this.saveToCache(); // 设置监听器 this.setupWatcher(); @@ -160,13 +164,13 @@ class PreferenceManager { /** * 重置偏好设置到初始状态 */ - resetPreferences = () => { + resetPreferences = async () => { // 将状态重置为初始偏好设置 Object.assign(this.state, this.initialPreferences); this.replaceCustomPreferences(this.initialCustomPreferences); // 保存偏好设置至缓存 - this.saveToCache(); + await this.saveToCache(); // 直接触发 UI 更新 this.handleUpdates(this.state); @@ -211,7 +215,7 @@ class PreferenceManager { // 根据更新的值执行更新 this.handleUpdates(updates); - // 保存到缓存 + // 保存到缓存(fire-and-forget,通过 debounce 控制频率) this.debouncedSave(); }; @@ -320,7 +324,7 @@ class PreferenceManager { * 从缓存加载扩展偏好设置 * @returns 缓存的扩展偏好设置,如果不存在则返回 null */ - private loadCustomFromCache(): CustomPreferencesRecord | null { + private async loadCustomFromCache(): Promise { return this.cache.getItem(STORAGE_KEYS.CUSTOM); } @@ -328,7 +332,7 @@ class PreferenceManager { * 从缓存加载偏好设置 * @returns 缓存的偏好设置,如果不存在则返回 null */ - private loadFromCache(): null | Preferences { + private async loadFromCache(): Promise { return this.cache.getItem(STORAGE_KEYS.MAIN); } @@ -387,17 +391,23 @@ class PreferenceManager { /** * 保存偏好设置到缓存 */ - private saveToCache() { - this.cache.setItem(STORAGE_KEYS.MAIN, this.state); - this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale); - this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode); + private async saveToCache() { + try { + await this.cache.setItem(STORAGE_KEYS.MAIN, this.state); + await this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale); + await this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode); - if (this.customPreferencesExtension) { - this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState }); - return; + if (this.customPreferencesExtension) { + await this.cache.setItem(STORAGE_KEYS.CUSTOM, { + ...this.customState, + }); + return; + } + + await this.cache.removeItem(STORAGE_KEYS.CUSTOM); + } catch (error) { + console.error('Failed to save preferences to cache:', error); } - - this.cache.removeItem(STORAGE_KEYS.CUSTOM); } /** diff --git a/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue b/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue index 6e2e573c3..c3204166f 100644 --- a/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue +++ b/packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue @@ -283,8 +283,8 @@ async function handleCopy() { } async function handleClearCache() { - resetPreferences(); - clearCache(); + await resetPreferences(); + await clearCache(); emit('clearPreferencesAndLogout'); } @@ -292,7 +292,7 @@ async function handleReset() { if (!mergedDiffPreference.value) { return; } - resetPreferences(); + await resetPreferences(); await loadLocaleMessages(preferences.app.locale); } diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index a2a6870ed..076686ff3 100644 --- a/packages/effects/plugins/src/vxe-table/api.ts +++ b/packages/effects/plugins/src/vxe-table/api.ts @@ -6,6 +6,7 @@ import type { } from '@vben-core/form-ui'; import type { VxeGridProps } from './types'; +import type {ViewedRowHelper} from './use-viewed-row'; import { toRaw } from 'vue'; @@ -42,6 +43,11 @@ export class VxeGridApi< public store: Store>; + /** + * 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中) + */ + public viewedRowHelper: null | ViewedRowHelper = null; + private isMounted = false; private stateHandler: StateHandler; @@ -64,6 +70,42 @@ export class VxeGridApi< bindMethods(this); } + /** + * 清除所有已读状态 + */ + clearViewedRows() { + this.viewedRowHelper?.clearViewed(); + } + + /** + * 获取所有已读的 key 集合(返回副本,避免外部修改内部状态) + */ + getViewedKeys(): Set { + const raw = this.viewedRowHelper?.viewedSet.value; + return raw ? new Set(raw) : new Set(); + } + + /** + * 判断某行是否已读 + */ + isRowViewed(record: T): boolean { + return this.viewedRowHelper?.isViewed(record) ?? false; + } + + /** + * 批量标记行为已读 + */ + markKeysAsViewed(keys: Array) { + this.viewedRowHelper?.markKeysAsViewed(keys); + } + + /** + * 标记某行为已读 + */ + markRowAsViewed(record: T) { + this.viewedRowHelper?.markAsViewed(record); + } + mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) { if (!this.isMounted && instance) { this.grid = instance; @@ -89,6 +131,13 @@ export class VxeGridApi< } } + /** + * 移除指定 key 的已读状态 + */ + removeViewedKeys(keys: Array) { + this.viewedRowHelper?.removeKeys(keys); + } + setGridOptions(options: Partial['gridOptions']>) { this.setState({ gridOptions: options, @@ -130,5 +179,6 @@ export class VxeGridApi< unmount() { this.isMounted = false; this.stateHandler.reset(); + this.viewedRowHelper = null; } } diff --git a/packages/effects/plugins/src/vxe-table/style.css b/packages/effects/plugins/src/vxe-table/style.css index 614f1df43..89d6733dd 100644 --- a/packages/effects/plugins/src/vxe-table/style.css +++ b/packages/effects/plugins/src/vxe-table/style.css @@ -117,3 +117,12 @@ .vxe-grid--layout-body-content-wrapper { overflow: hidden; } + +/* 已读行默认样式 */ +.vxe-row--viewed { + color: hsl(var(--foreground) / 50%); + + .vxe-body--column { + opacity: 0.9; + } +} diff --git a/packages/effects/plugins/src/vxe-table/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index 6ce5c07cc..ed52eb417 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/packages/effects/plugins/src/vxe-table/types.ts @@ -2,6 +2,7 @@ import type { VxeGridListeners, VxeGridPropTypes, VxeGridProps as VxeTableGridProps, + VxeTablePropTypes, VxeUIExport, } from 'vxe-table'; @@ -38,6 +39,87 @@ export interface SeparatorOptions { backgroundColor?: string; } +/** + * 自定义存储适配器接口 + * 用户可接入任意后端(API、IndexedDB wrapper、第三方库等) + */ +export interface ViewedRowStorageAdapter { + /** 读取所有已查看的 key 列表 */ + getKeys(): Promise>; + + /** 移除所有已查看数据 */ + removeKeys(): Promise; + + /** 持久化已查看的 key 列表 */ + setKeys(keys: Array): Promise; +} + +/** + * 已读行持久化 — 公共基础字段 + */ +interface ViewedRowPersistBase { + /** 持久化数据的存活时间(毫秒) */ + ttl?: number; + /** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */ + maxSize?: number; +} + +/** + * 已读行持久化配置(按 type 区分的联合类型) + * + * - 'memory' → 仅内存,不持久化 + * - 'localStorage' → 使用 localStorage 整体存储,key 必传 + * - 'sessionStorage' → 使用 sessionStorage 整体存储,key 必传 + * - 'indexedDB' → 使用 IndexedDB 单条存储,key 必传 + * - 'custom' → 用户自定义存储适配器,storage 必传 + */ +export type ViewedRowPersistOptions = + | ({ + /** IndexedDB 数据库名称,默认 'viewed-table-db' */ + dbName?: string; + /** IndexedDB 数据库版本,默认 1 */ + dbVersion?: number; + /** 存储 key / prefix(必传) */ + key: string; + /** IndexedDB 对象存储名称,默认 'viewed-table-row' */ + storeName?: string; + type: 'indexedDB'; +} & ViewedRowPersistBase) + | ({ + /** 存储 key(必传) */ + key: string; + type: 'localStorage' | 'sessionStorage'; +} & ViewedRowPersistBase) + | ({ + /** 自定义存储适配器(必传) */ + storage: ViewedRowStorageAdapter; + type: 'custom'; +} & ViewedRowPersistBase) + | (ViewedRowPersistBase & { + type: 'memory'; +}); + +/** + * 已查看row设置 + */ +export interface ViewedRowOptions { + /** 点击 CellOperation 中匹配的 code 时,自动将该行标记为已读 */ + actionCodes?: string | string[]; + /** 行唯一标识字段,默认取 gridOptions.rowConfig.keyField,最终兜底 'id' */ + keyField?: string; + /** 已查看的行key列表 */ + viewedKeys?: Array | Ref>; + /** + * 持久化配置 + * - 传 string:使用内置 localStorage,值为 storage key(向后兼容) + * - 传 object:高级配置 + * - 不传:不持久化(等同于 memory) + */ + persist?: string | ViewedRowPersistOptions; + rowClassName?: VxeTablePropTypes.RowClassName; + rowStyle?: VxeTablePropTypes.RowStyle; +} + export interface VxeGridProps< T extends Record = any, D extends BaseFormComponentType = BaseFormComponentType, @@ -83,6 +165,10 @@ export interface VxeGridProps< * 搜索表单与表格主体之间的分隔条 */ separator?: boolean | SeparatorOptions; + /** + * 已读行功能 + */ + viewedRowOptions?: boolean | ViewedRowOptions; } export type ExtendedVxeGridApi< diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts new file mode 100644 index 000000000..365a761ed --- /dev/null +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -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>(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, +): 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 = []; + for (const shortKey of shortKeys) { + const value = await manager.getItem(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, 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( + options: ViewedRowOptions & { 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>(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 { + 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) => 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)[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) { + 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)[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) { + 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 = ReturnType>; + +// ========== 工具函数 ========== + +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, +) { + // 从最新的配置中读取 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, + ); + } +} diff --git a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue index 78cab6a58..665f18f62 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -44,6 +44,7 @@ import { VxeGrid, VxeUI } from 'vxe-table'; import { extendProxyOptions } from './extends'; import { useTableForm } from './init'; +import {applyViewedRowOptions, useViewedRow} from './use-viewed-row'; import 'vxe-table/styles/cssvar.scss'; import 'vxe-pc-ui/styles/cssvar.scss'; @@ -76,8 +77,28 @@ const { tableTitleHelp, showSearchForm, separator, + viewedRowOptions, } = usePriorityValues(props, state); +// viewedRowOptions:helper 只创建一次(persist/keyField 不支持运行时切换) +// actionCodes、rowClassName、rowStyle、viewedKeys 的变化通过 options computed 自然响应 +const gridApi = props.api; + +watch( + viewedRowOptions, + (cfg) => { + // helper 已存在则不重建 + if (gridApi.viewedRowHelper) return; + + if (!cfg) return; + + const keyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id'; + const resolved = isBoolean(cfg) ? {keyField} : {keyField, ...cfg}; + gridApi.viewedRowHelper = useViewedRow(resolved); + }, + {immediate: true}, +); + const { isMobile } = usePreferences(); const isSeparator = computed(() => { if ( @@ -234,6 +255,16 @@ const options = computed(() => { mergedOptions.data = tableData.value; } } + + // 注入已读行功能(rowClassName、rowStyle、columns 拦截) + if (viewedRowOptions.value && gridApi.viewedRowHelper) { + applyViewedRowOptions( + mergedOptions, + viewedRowOptions.value, + gridApi.viewedRowHelper, + ); + } + return mergedOptions; }); diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 9f88ebd37..ebf085ffa 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/playground/src/locales/langs/en-US/examples.json @@ -38,7 +38,8 @@ "editCell": "Edit Cell", "editRow": "Edit Row", "custom-cell": "Custom Cell", - "form": "Form Table" + "form": "Form Table", + "viewed": "Row Marker" }, "captcha": { "title": "Captcha", diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index e260016ec..1a2b6a83c 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -41,7 +41,8 @@ "editCell": "单元格编辑", "editRow": "行编辑", "custom-cell": "自定义单元格", - "form": "搜索表单" + "form": "搜索表单", + "viewed": "行标记" }, "captcha": { "title": "验证码", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index 38f25e2d3..ff6b00a30 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/playground/src/router/routes/modules/examples.ts @@ -193,6 +193,14 @@ const routes: RouteRecordRaw[] = [ title: $t('examples.vxeTable.virtual'), }, }, + { + name: 'VxeTableViewedExample', + path: '/examples/vxe-table/viewed', + component: () => import('#/views/examples/vxe-table/viewed.vue'), + meta: { + title: $t('examples.vxeTable.viewed'), + }, + }, ], }, { diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue new file mode 100644 index 000000000..e535fe939 --- /dev/null +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -0,0 +1,209 @@ + + +