From e555ee065e3fd4d4b1db3f35470a7a6efb6cc672 Mon Sep 17 00:00:00 2001 From: layhuts Date: Fri, 8 May 2026 17:34:54 +0800 Subject: [PATCH 01/27] =?UTF-8?q?feat(@vben-core/shared):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9EindexedDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在cache下新增indexedDB缓存 --- .../@core/base/shared/src/cache/README.md | 419 ++++++++++++++++++ .../cache/__tests__/storage-manager.test.ts | 143 +++--- packages/@core/base/shared/src/cache/index.ts | 4 + .../base/shared/src/cache/indexeddb-driver.ts | 124 ++++++ .../shared/src/cache/local-storage-driver.ts | 63 +++ .../shared/src/cache/memory-storage-driver.ts | 32 ++ .../base/shared/src/cache/storage-manager.ts | 97 ++-- packages/@core/base/shared/src/cache/types.ts | 46 +- .../preferences/__tests__/preferences.test.ts | 10 +- packages/@core/preferences/src/preferences.ts | 54 ++- .../preferences/preferences-drawer.vue | 6 +- 11 files changed, 835 insertions(+), 163 deletions(-) create mode 100644 packages/@core/base/shared/src/cache/README.md create mode 100644 packages/@core/base/shared/src/cache/indexeddb-driver.ts create mode 100644 packages/@core/base/shared/src/cache/local-storage-driver.ts create mode 100644 packages/@core/base/shared/src/cache/memory-storage-driver.ts 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..e9769c37b --- /dev/null +++ b/packages/@core/base/shared/src/cache/README.md @@ -0,0 +1,419 @@ +# Cache 模块 + +基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API +接口。 + +## 架构设计 + +``` +┌─────────────────────────────────────────────────┐ +│ 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 解析失败时自动清除损坏数据;StorageManager 的 + `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..f5d3c3691 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,139 @@ 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..d8c2f6736 --- /dev/null +++ b/packages/@core/base/shared/src/cache/indexeddb-driver.ts @@ -0,0 +1,124 @@ +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); + const request = store.clear(); + + request.addEventListener('success', () => resolve()); + request.addEventListener('error', () => reject(request.error)); + }); + } + + 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); + const request = store.delete(key); + + request.addEventListener('success', () => resolve()); + request.addEventListener('error', () => reject(request.error)); + }); + } + + 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); + const request = store.put(value, key); + + request.addEventListener('success', () => resolve()); + request.addEventListener('error', () => reject(request.error)); + }); + } + + /** + * 懒初始化:首次调用时打开数据库,后续复用同一个 Promise + */ + private getDB(): Promise { + if (!this.dbPromise) { + this.dbPromise = this.openDB(); + } + 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..9c1037743 --- /dev/null +++ b/packages/@core/base/shared/src/cache/local-storage-driver.ts @@ -0,0 +1,63 @@ +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 = {}) { + 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..5264bfcb3 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -1,53 +1,42 @@ -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'; +/** + * 存储管理器(策略模式) + * - 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 || new LocalStorageDriver(); this.prefix = prefix; - this.storage = - storageType === 'localStorage' - ? window.localStorage - : window.sessionStorage; } /** * 清除所有带前缀的存储项 */ - 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 prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix)); + 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 prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix)); + + 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 +45,32 @@ 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; } /** * 移除存储项 * @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 +79,20 @@ 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); } /** - * 获取完整的存储键 + * 获取完整的存储键(带前缀) * @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); } From 51e8b27d4c0d8722a002c405e794815f91e4675a Mon Sep 17 00:00:00 2001 From: layhuts Date: Fri, 8 May 2026 17:36:47 +0800 Subject: [PATCH 02/27] style(@vben-core/shared): formatting --- .../@core/base/shared/src/cache/README.md | 15 ++++---- .../cache/__tests__/storage-manager.test.ts | 36 ++++--------------- .../shared/src/cache/local-storage-driver.ts | 4 ++- .../base/shared/src/cache/storage-manager.ts | 11 ++++-- 4 files changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/@core/base/shared/src/cache/README.md b/packages/@core/base/shared/src/cache/README.md index e9769c37b..c9e20114b 100644 --- a/packages/@core/base/shared/src/cache/README.md +++ b/packages/@core/base/shared/src/cache/README.md @@ -308,7 +308,7 @@ await userCache.setItem('profile', {name: '张三'}); await configCache.setItem('profile', {theme: 'dark'}); // 各自独立 -await userCache.getItem('profile'); // => { name: '张三' } +await userCache.getItem('profile'); // => { name: '张三' } await configCache.getItem('profile'); // => { theme: 'dark' } // 只清除 user 前缀的数据 @@ -325,9 +325,12 @@ const cache = new StorageManager({ prefix: 'app' }); await cache.clearExpiredItems(); // 或者定时清理(每 10 分钟) -setInterval(async () => { - await cache.clearExpiredItems(); -}, 10 * 60 * 1000); +setInterval( + async () => { + await cache.clearExpiredItems(); + }, + 10 * 60 * 1000, +); ``` --- @@ -338,8 +341,8 @@ setInterval(async () => { ```typescript interface StorageItem { - expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期 - value: T; // 实际业务数据 + expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期 + value: T; // 实际业务数据 } ``` 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 f5d3c3691..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 @@ -44,11 +44,7 @@ describe('storageManager', () => { }); it('should clear expired items', async () => { - await storageManager.setItem( - 'user', - {age: 30, name: 'John Doe'}, - 1000, - ); // 1秒过期 + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 await storageManager.clearExpiredItems(); const user = await storageManager.getItem('user'); @@ -56,11 +52,7 @@ describe('storageManager', () => { }); it('should not clear non-expired items', async () => { - await storageManager.setItem( - 'user', - {age: 30, name: 'John Doe'}, - 10_000, - ); // 10秒过期 + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 await storageManager.clearExpiredItems(); const user = await storageManager.getItem('user'); @@ -87,38 +79,22 @@ describe('storageManager', () => { }); it('should remove expired items when accessed', async () => { - await storageManager.setItem( - 'user', - {age: 30, name: 'John Doe'}, - 1000, - ); // 1秒过期 + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); it('should not remove non-expired items when accessed', async () => { - await storageManager.setItem( - 'user', - {age: 30, name: 'John Doe'}, - 10_000, - ); // 10秒过期 + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); 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秒过期 + 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); // 快进时间 await storageManager.clearExpiredItems(); const user1 = await storageManager.getItem('user1'); diff --git a/packages/@core/base/shared/src/cache/local-storage-driver.ts b/packages/@core/base/shared/src/cache/local-storage-driver.ts index 9c1037743..5eb84805c 100644 --- a/packages/@core/base/shared/src/cache/local-storage-driver.ts +++ b/packages/@core/base/shared/src/cache/local-storage-driver.ts @@ -14,7 +14,9 @@ interface LocalStorageDriverOptions { class LocalStorageDriver implements IStorageDriver { private storage: Storage; - constructor({storageType = 'localStorage'}: LocalStorageDriverOptions = {}) { + constructor({ + storageType = 'localStorage', + }: LocalStorageDriverOptions = {}) { this.storage = storageType === 'localStorage' ? window.localStorage diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 5264bfcb3..3c90926a2 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -1,4 +1,8 @@ -import type {IStorageDriver, StorageItem, StorageManagerOptions} from './types'; +import type { + IStorageDriver, + StorageItem, + StorageManagerOptions, +} from './types'; import {LocalStorageDriver} from './local-storage-driver'; @@ -47,7 +51,10 @@ class StorageManager { * @param defaultValue 当项不存在或已过期时返回的默认值 * @returns 值,如果项已过期则返回默认值 */ - async getItem(key: string, defaultValue: null | T = null): Promise { + async getItem( + key: string, + defaultValue: null | T = null, + ): Promise { const fullKey = this.getFullKey(key); const raw = await this.driver.getItem>(fullKey); From e1f6449073b7d756a4e0985972bcf475ea98ab32 Mon Sep 17 00:00:00 2001 From: layhuts Date: Fri, 8 May 2026 20:03:05 +0800 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=E8=A1=A8=E6=A0=BC=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E8=A1=8C=E6=93=8D=E4=BD=9C=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/effects/plugins/src/vxe-table/api.ts | 60 +++ .../effects/plugins/src/vxe-table/style.css | 9 + .../effects/plugins/src/vxe-table/types.ts | 70 +++ .../plugins/src/vxe-table/use-viewed-row.ts | 503 ++++++++++++++++++ .../plugins/src/vxe-table/use-vxe-grid.vue | 50 ++ .../src/locales/langs/en-US/examples.json | 3 +- .../src/locales/langs/zh-CN/examples.json | 3 +- .../src/router/routes/modules/examples.ts | 8 + .../src/views/examples/vxe-table/viewed.vue | 178 +++++++ 9 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 packages/effects/plugins/src/vxe-table/use-viewed-row.ts create mode 100644 playground/src/views/examples/vxe-table/viewed.vue diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index a2a6870ed..9a52e9697 100644 --- a/packages/effects/plugins/src/vxe-table/api.ts +++ b/packages/effects/plugins/src/vxe-table/api.ts @@ -46,6 +46,16 @@ export class VxeGridApi< private stateHandler: StateHandler; + // 已读行相关方法(由 use-vxe-grid.vue 注入) + private viewedRowHelper: null | { + clearViewed: () => void; + isViewed: (record: T) => boolean; + markAsViewed: (record: T) => void; + markKeysAsViewed: (keys: Array) => void; + removeKeys: (keys: Array) => void; + viewedSet: { value: Set }; + } = null; + constructor(options: VxeGridProps = {} as VxeGridProps) { const storeState = { ...options }; @@ -64,6 +74,41 @@ export class VxeGridApi< bindMethods(this); } + /** + * 清除所有已读状态 + */ + clearViewedRows() { + this.viewedRowHelper?.clearViewed(); + } + + /** + * 获取所有已读的 key 集合 + */ + getViewedKeys(): Set { + return this.viewedRowHelper?.viewedSet.value ?? 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 +134,13 @@ export class VxeGridApi< } } + /** + * 移除指定 key 的已读状态 + */ + removeViewedKeys(keys: Array) { + this.viewedRowHelper?.removeKeys(keys); + } + setGridOptions(options: Partial['gridOptions']>) { this.setState({ gridOptions: options, @@ -117,6 +169,14 @@ export class VxeGridApi< } } + /** + * 设置已读行 helper(由组件内部调用) + * @internal + */ + setViewedRowHelper(helper: VxeGridApi['viewedRowHelper']) { + this.viewedRowHelper = helper; + } + toggleSearchForm(show?: boolean) { this.setState({ showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm, 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..4ae435194 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,71 @@ export interface SeparatorOptions { backgroundColor?: string; } +/** + * 自定义存储适配器接口 + * 用户可接入任意后端(API、IndexedDB wrapper、第三方库等) + */ +export interface ViewedRowStorageAdapter { + /** 读取所有已查看的 key 列表 */ + getKeys(): Promise>; + + /** 移除所有已查看数据 */ + removeKeys(): Promise; + + /** 持久化已查看的 key 列表 */ + setKeys(keys: Array): Promise; +} + +/** + * 已读行持久化配置 + */ +export interface ViewedRowPersistOptions { + /** + * 存储类型,默认 'localStorage' + * - memory: 仅内存,不持久化 + * - localStorage: 使用 localStorage 整体存储 + * - sessionStorage: 使用 sessionStorage 整体存储 + * - indexedDB: 使用 IndexedDB 单条存储(支持单条 TTL) + * - custom: 用户自定义存储适配器 + */ + type?: 'custom' | 'indexedDB' | 'localStorage' | 'memory' | 'sessionStorage'; + /** 存储 key / prefix(type 为 localStorage/sessionStorage/indexedDB 时必传) */ + key?: string; + /** 持久化数据的存活时间(毫秒) */ + ttl?: number; + /** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */ + maxSize?: number; + /** IndexedDB 数据库名称(仅 type='indexedDB' 时生效,默认 'viewed-table-db') */ + dbName?: string; + /** IndexedDB 数据库版本(仅 type='indexedDB' 时生效,默认 1) */ + dbVersion?: number; + /** IndexedDB 对象存储名称(仅 type='indexedDB' 时生效,默认 'viewed-table-row') */ + storeName?: string; + /** 自定义存储适配器(仅 type='custom' 时生效,不传则降级为 memory) */ + storage?: ViewedRowStorageAdapter; +} + +/** + * 已查看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 +149,10 @@ export interface VxeGridProps< * 搜索表单与表格主体之间的分隔条 */ separator?: boolean | SeparatorOptions; + /** + * 已读行功能 + */ + viewedRow?: 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..c05bee552 --- /dev/null +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -0,0 +1,503 @@ +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: ViewedRowPersistOptions, +): ViewedRowStorageAdapter { + const prefix = opts.key || 'viewed'; + 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 的 driver 获取所有 key,再逐条读取(自动过滤过期) + const allKeys = (await (manager as any).driver.keys()) as string[]; + const fullPrefix = prefix ? `${prefix}-` : ''; + const prefixedKeys = allKeys.filter((k: string) => + k.startsWith(fullPrefix), + ); + + const results: Array = []; + for (const fullKey of prefixedKeys) { + const shortKey = fullKey.replace(fullPrefix, ''); + 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 { + // 先清除旧数据,再逐条写入 + await manager.clear(); + await Promise.all( + keys.map((key) => manager.setItem(String(key), key, opts.ttl)), + ); + } 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); + } + + const {type = 'localStorage'} = persist; + + switch (type) { + case 'custom': { + if (!persist.storage) { + // 没有提供 storage 适配器,降级为 memory + return null; + } + // 用户自定义适配器,解除 Vue 响应式代理 + return toRaw(persist.storage); + } + case 'indexedDB': { + if (!persist.key) { + console.warn('[viewedRow] persist.key is required for indexedDB type'); + return null; + } + return createIndexedDBAdapter(persist); + } + case 'localStorage': { + if (!persist.key) { + console.warn( + '[viewedRow] persist.key is required for localStorage type', + ); + return null; + } + return createWebStorageAdapter('localStorage', persist.key, persist.ttl); + } + case 'memory': { + return null; + } + case 'sessionStorage': { + if (!persist.key) { + console.warn( + '[viewedRow] persist.key is required for sessionStorage type', + ); + return null; + } + 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); + + // ========== 从存储恢复 ========== + function restoreFromStorage() { + if (!adapter) return; + + adapter + .getKeys() + .then((stored) => { + 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); + }); + } + + restoreFromStorage(); + + // 合并外部传入的 viewedKeys + if (options.viewedKeys) { + const keys = isRef(options.viewedKeys) + ? options.viewedKeys.value + : options.viewedKeys; + for (const key of keys) { + viewedSet.value.add(key); + } + } + + // ========== 更新 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, + }; +} + +// ========== 工具函数 ========== + +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 + const originalRowClassName = mergedOptions.rowClassName; + mergedOptions.rowClassName = (params: any) => { + return mergeClassNames( + isFunction(originalRowClassName) + ? originalRowClassName(params) + : originalRowClassName, + helper.getRowClassName(params), + ); + }; + + // 注入 rowStyle + const originalRowStyle = mergedOptions.rowStyle; + mergedOptions.rowStyle = (params: any) => { + const viewedStyle = helper.getRowStyle(params); + const originalStyle = isFunction(originalRowStyle) + ? originalRowStyle(params) + : originalRowStyle; + 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..c74c7d595 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -19,10 +19,12 @@ import { nextTick, onMounted, onUnmounted, + shallowRef, toRaw, useSlots, useTemplateRef, watch, + watchEffect, } from 'vue'; import { usePriorityValues } from '@vben/hooks'; @@ -44,6 +46,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 +79,45 @@ const { tableTitleHelp, showSearchForm, separator, + viewedRow, } = usePriorityValues(props, state); +// ========== 已读行:响应 viewedRow 配置变化 ========== +const defaultKeyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id'; + +const viewedRowHelper = shallowRef>( + null, +); + +// 初始化 + 监听配置变化时重建 helper +watch( + viewedRow, + (cfg) => { + if (!cfg) { + viewedRowHelper.value = null; + props.api?.setViewedRowHelper?.(null); + return; + } + const resolvedOptions = isBoolean(cfg) + ? {keyField: defaultKeyField} + : {keyField: defaultKeyField, ...cfg}; + viewedRowHelper.value = useViewedRow(resolvedOptions); + // 同步更新 API 中的 helper 引用 + if (props.api?.setViewedRowHelper) { + props.api.setViewedRowHelper(viewedRowHelper.value); + } + }, + {immediate: true}, +); + +// viewedSet 变化时,主动刷新 grid 行样式 +watchEffect(() => { + const helper = viewedRowHelper.value; + if (!helper) return; + // 访问 viewedSet.value 建立依赖追踪 + void helper.viewedSet.value; +}); + const { isMobile } = usePreferences(); const isSeparator = computed(() => { if ( @@ -234,6 +274,16 @@ const options = computed(() => { mergedOptions.data = tableData.value; } } + + // 注入已读行功能(rowClassName、rowStyle、columns 拦截) + if (viewedRow.value && viewedRowHelper.value) { + applyViewedRowOptions( + mergedOptions, + viewedRow.value, + viewedRowHelper.value, + ); + } + 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..f70f0030f --- /dev/null +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -0,0 +1,178 @@ + + + From 329fa68207fb0e835fd2988d0da327c23b76db48 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 09:23:10 +0800 Subject: [PATCH 04/27] =?UTF-8?q?fix:=20=E6=89=8B=E5=8A=A8=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=A1=8C=E6=A0=87=E8=AE=B0=E7=A4=BA=E4=BE=8B=E6=97=B6?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=8E=B7=E5=8F=96=E5=88=97=E8=A1=A8=E5=89=8D?= =?UTF-8?q?=E4=B8=A4=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/src/views/examples/vxe-table/viewed.vue | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index f70f0030f..6a829b0be 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -145,10 +145,9 @@ function onView(row: RowType) { } function onCustomSet() { - gridApi.markKeysAsViewed([ - '0da74a21-362d-42ba-9c7e-078e47477620', - '1c7785d9-f16b-448b-b6a2-fb4b3557550a', - ]); + const tableData = gridApi.grid.getData(); + const keys = tableData.slice(0, 2).map((row) => row.id); + gridApi.markKeysAsViewed(keys); } function onClearViewed() { From 8ce773f264f856e2e0ee0122b10d6dd0ebd833ca Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 10:58:41 +0800 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=E5=9C=A8storage-manager=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0keys=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@core/base/shared/src/cache/storage-manager.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 3c90926a2..dc6a28d27 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -71,6 +71,18 @@ class StorageManager { 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 键 From 88d5661e0cd6985ec79329298e5eefac7e627142 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 11:02:16 +0800 Subject: [PATCH 06/27] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84ViewedRowPe?= =?UTF-8?q?rsistOptions=E5=A4=84=E7=90=86=E6=89=81=E5=B9=B3=E5=8C=96?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改成按 type 字段区分的联合类型 --- .../effects/plugins/src/vxe-table/types.ts | 58 ++++++++++++------- .../plugins/src/vxe-table/use-viewed-row.ts | 39 ++----------- 2 files changed, 43 insertions(+), 54 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index 4ae435194..7b72c9b7b 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/packages/effects/plugins/src/vxe-table/types.ts @@ -55,34 +55,50 @@ export interface ViewedRowStorageAdapter { } /** - * 已读行持久化配置 + * 已读行持久化 — 公共基础字段 */ -export interface ViewedRowPersistOptions { - /** - * 存储类型,默认 'localStorage' - * - memory: 仅内存,不持久化 - * - localStorage: 使用 localStorage 整体存储 - * - sessionStorage: 使用 sessionStorage 整体存储 - * - indexedDB: 使用 IndexedDB 单条存储(支持单条 TTL) - * - custom: 用户自定义存储适配器 - */ - type?: 'custom' | 'indexedDB' | 'localStorage' | 'memory' | 'sessionStorage'; - /** 存储 key / prefix(type 为 localStorage/sessionStorage/indexedDB 时必传) */ - key?: string; +interface ViewedRowPersistBase { /** 持久化数据的存活时间(毫秒) */ ttl?: number; /** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */ maxSize?: number; - /** IndexedDB 数据库名称(仅 type='indexedDB' 时生效,默认 'viewed-table-db') */ - dbName?: string; - /** IndexedDB 数据库版本(仅 type='indexedDB' 时生效,默认 1) */ - dbVersion?: number; - /** IndexedDB 对象存储名称(仅 type='indexedDB' 时生效,默认 'viewed-table-row') */ - storeName?: string; - /** 自定义存储适配器(仅 type='custom' 时生效,不传则降级为 memory) */ - storage?: ViewedRowStorageAdapter; } +/** + * 已读行持久化配置(按 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设置 */ diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts index c05bee552..e4f2e74ba 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -54,9 +54,9 @@ function createWebStorageAdapter( * 单条存储:prefix:1 → { expiry, value: 1 } */ function createIndexedDBAdapter( - opts: ViewedRowPersistOptions, + opts: Extract, ): ViewedRowStorageAdapter { - const prefix = opts.key || 'viewed'; + const prefix = opts.key; const manager = new StorageManager({ driver: new IndexedDBDriver({ dbName: opts.dbName || 'viewed-table-db', @@ -69,16 +69,11 @@ function createIndexedDBAdapter( return { async getKeys() { try { - // 通过 StorageManager 的 driver 获取所有 key,再逐条读取(自动过滤过期) - const allKeys = (await (manager as any).driver.keys()) as string[]; - const fullPrefix = prefix ? `${prefix}-` : ''; - const prefixedKeys = allKeys.filter((k: string) => - k.startsWith(fullPrefix), - ); + // 通过 StorageManager 获取当前前缀下所有 key,再逐条读取(自动过滤过期) + const shortKeys = await manager.keys(); const results: Array = []; - for (const fullKey of prefixedKeys) { - const shortKey = fullKey.replace(fullPrefix, ''); + for (const shortKey of shortKeys) { const value = await manager.getItem(shortKey); if (value !== null) { results.push(value); @@ -124,43 +119,21 @@ function createStorageAdapter( return createWebStorageAdapter('localStorage', persist); } - const {type = 'localStorage'} = persist; - - switch (type) { + switch (persist.type) { case 'custom': { - if (!persist.storage) { - // 没有提供 storage 适配器,降级为 memory - return null; - } // 用户自定义适配器,解除 Vue 响应式代理 return toRaw(persist.storage); } case 'indexedDB': { - if (!persist.key) { - console.warn('[viewedRow] persist.key is required for indexedDB type'); - return null; - } return createIndexedDBAdapter(persist); } case 'localStorage': { - if (!persist.key) { - console.warn( - '[viewedRow] persist.key is required for localStorage type', - ); - return null; - } return createWebStorageAdapter('localStorage', persist.key, persist.ttl); } case 'memory': { return null; } case 'sessionStorage': { - if (!persist.key) { - console.warn( - '[viewedRow] persist.key is required for sessionStorage type', - ); - return null; - } return createWebStorageAdapter( 'sessionStorage', persist.key, From 2d36d6b5100216939c960ede70b488183e796d2d Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 11:03:47 +0800 Subject: [PATCH 07/27] style: reformat code --- .../effects/plugins/src/vxe-table/types.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index 7b72c9b7b..d4f138986 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/packages/effects/plugins/src/vxe-table/types.ts @@ -74,17 +74,17 @@ interface ViewedRowPersistBase { * - '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) + | ({ + /** 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; From df5cb426d1eb2ebb0e55a9b802b792c379311dc4 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 11:54:11 +0800 Subject: [PATCH 08/27] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3indexedDB?= =?UTF-8?q?=E7=9A=84setItem=E3=80=81=EF=BC=8CremoveItem=E4=BB=A5=E5=8F=8Ac?= =?UTF-8?q?lear=E5=9C=A8=E8=AF=B7=E6=B1=82=E6=88=90=E5=8A=9F=E6=97=B6?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E4=BA=8B=E5=8A=A1=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=97=B6=E8=A7=A6=E5=8F=91resolve=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base/shared/src/cache/indexeddb-driver.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/@core/base/shared/src/cache/indexeddb-driver.ts b/packages/@core/base/shared/src/cache/indexeddb-driver.ts index d8c2f6736..d5562277e 100644 --- a/packages/@core/base/shared/src/cache/indexeddb-driver.ts +++ b/packages/@core/base/shared/src/cache/indexeddb-driver.ts @@ -34,10 +34,13 @@ class IndexedDBDriver implements IStorageDriver { return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); - const request = store.clear(); + store.clear(); - request.addEventListener('success', () => resolve()); - request.addEventListener('error', () => reject(request.error)); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); }); } @@ -74,10 +77,13 @@ class IndexedDBDriver implements IStorageDriver { return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); - const request = store.delete(key); + store.delete(key); - request.addEventListener('success', () => resolve()); - request.addEventListener('error', () => reject(request.error)); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); }); } @@ -86,10 +92,13 @@ class IndexedDBDriver implements IStorageDriver { return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, 'readwrite'); const store = tx.objectStore(this.storeName); - const request = store.put(value, key); + store.put(value, key); - request.addEventListener('success', () => resolve()); - request.addEventListener('error', () => reject(request.error)); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); }); } From b40822e3eda9e7ca7c9d4909a4c4ee186a900f8a Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:02:41 +0800 Subject: [PATCH 09/27] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3indexedDB?= =?UTF-8?q?=E7=9A=84getDB=E5=9C=A8=E5=A4=B1=E8=B4=A5=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E6=97=B6=E6=B0=B8=E8=BF=9C=E8=BF=94=E5=9B=9E=E9=94=99=E8=AF=AF?= =?UTF-8?q?Promise=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@core/base/shared/src/cache/indexeddb-driver.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/@core/base/shared/src/cache/indexeddb-driver.ts b/packages/@core/base/shared/src/cache/indexeddb-driver.ts index d5562277e..9875d888a 100644 --- a/packages/@core/base/shared/src/cache/indexeddb-driver.ts +++ b/packages/@core/base/shared/src/cache/indexeddb-driver.ts @@ -107,7 +107,11 @@ class IndexedDBDriver implements IStorageDriver { */ private getDB(): Promise { if (!this.dbPromise) { - this.dbPromise = this.openDB(); + this.dbPromise = this.openDB().catch((error) => { + // allow retry on next call + this.dbPromise = null; + throw error; + }); } return this.dbPromise; } From 0be4b51eaa03778a88be0831ff2dcdc971062626 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:33:15 +0800 Subject: [PATCH 10/27] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3window=E5=9C=A8?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E5=87=BD=E6=95=B0=E4=B8=AD=E8=BF=9B=E8=A1=8C?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E7=A0=B4=E5=9D=8FSSR/Node=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@core/base/shared/src/cache/local-storage-driver.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/@core/base/shared/src/cache/local-storage-driver.ts b/packages/@core/base/shared/src/cache/local-storage-driver.ts index 5eb84805c..6026246ea 100644 --- a/packages/@core/base/shared/src/cache/local-storage-driver.ts +++ b/packages/@core/base/shared/src/cache/local-storage-driver.ts @@ -17,6 +17,12 @@ class LocalStorageDriver implements IStorageDriver { 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 From cc6ccaab38d516047a95c8e327bf13c6b8585e7e Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:38:13 +0800 Subject: [PATCH 11/27] =?UTF-8?q?docs:=20=E4=B8=BA=E7=AC=A6=E5=90=88=20Mar?= =?UTF-8?q?kdown=20lint=20=E8=A7=84=E8=8C=83=EF=BC=8C=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E8=AF=AD=E8=A8=80=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@core/base/shared/src/cache/README.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/@core/base/shared/src/cache/README.md b/packages/@core/base/shared/src/cache/README.md index c9e20114b..5a348ce7c 100644 --- a/packages/@core/base/shared/src/cache/README.md +++ b/packages/@core/base/shared/src/cache/README.md @@ -5,19 +5,19 @@ ## 架构设计 -``` -┌─────────────────────────────────────────────────┐ -│ StorageManager │ -│ ┌───────────────┐ ┌────────────────────────┐ │ -│ │ Prefix 隔离 │ │ TTL 过期管理 │ │ -│ └───────────────┘ └────────────────────────┘ │ -├─────────────────────────────────────────────────┤ -│ IStorageDriver │ -├──────────┬──────────────────┬───────────────────┤ -│ Local │ IndexedDB │ Memory │ -│ Storage │ Driver │ Driver │ -│ Driver │ │ │ -└──────────┴──────────────────┴───────────────────┘ +``` shell +┌───────────────────────────────────────────────┐ +│ StorageManager │ +│ ┌─────────────┐ ┌───────────────────────┐ │ +│ │ Prefix 隔离 │ │ TTL 过期管理 │ │ +│ └─────────────┘ └───────────────────────┘ │ +├───────────────────────────────────────────────┤ +│ IStorageDriver │ +├──────────┬─────────────────┬──────────────────┤ +│ Local │ IndexedDB │ Memory │ +│ Storage │ Driver │ Driver │ +│ Driver │ │ │ +└──────────┴─────────────────┴──────────────────┘ ``` **分层职责:** From 75ed17a1f95fa9877674cc97ccb49f9717c4295a Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:41:10 +0800 Subject: [PATCH 12/27] =?UTF-8?q?docs:=20=E6=9B=B4=E6=AD=A3saveToCache?= =?UTF-8?q?=E6=89=80=E5=B1=9E=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@core/base/shared/src/cache/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@core/base/shared/src/cache/README.md b/packages/@core/base/shared/src/cache/README.md index 5a348ce7c..5b5dc7cf9 100644 --- a/packages/@core/base/shared/src/cache/README.md +++ b/packages/@core/base/shared/src/cache/README.md @@ -5,7 +5,7 @@ ## 架构设计 -``` shell +```shell ┌───────────────────────────────────────────────┐ │ StorageManager │ │ ┌─────────────┐ ┌───────────────────────┐ │ @@ -415,8 +415,8 @@ class PreferenceManager { 4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。 -5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据;StorageManager 的 - `saveToCache` 内部 try-catch 防止未捕获异常。 +5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; + `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。 6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 `upgradeneeded` 事件中自动创建 objectStore。 From 1f584ff0c9fd9d475b81879d5d3a4e029e76c986 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:49:21 +0800 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86window=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=97=B6=E9=99=8D=E7=BA=A7=E4=BD=BF=E7=94=A8?= =?UTF-8?q?Memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../base/shared/src/cache/storage-manager.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index dc6a28d27..3ad2653a3 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -5,6 +5,7 @@ import type { } from './types'; import {LocalStorageDriver} from './local-storage-driver'; +import {MemoryStorageDriver} from './memory-storage-driver'; /** * 存储管理器(策略模式) @@ -17,7 +18,7 @@ class StorageManager { private prefix: string; constructor({driver, prefix = ''}: StorageManagerOptions = {}) { - this.driver = driver || new LocalStorageDriver(); + this.driver = driver || this.createDefaultDriver(); this.prefix = prefix; } @@ -105,6 +106,18 @@ class StorageManager { await this.driver.setItem(fullKey, item); } + /** + * 根据运行环境创建默认驱动: + * - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver + * - SSR / Node 环境 → MemoryStorageDriver + */ + private createDefaultDriver(): IStorageDriver { + if (typeof window !== 'undefined' && window.localStorage) { + return new LocalStorageDriver(); + } + return new MemoryStorageDriver(); + } + /** * 获取完整的存储键(带前缀) * @param key 原始键 From 966723268469af119ece9313aa5b67052118b526 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 12:56:50 +0800 Subject: [PATCH 14/27] =?UTF-8?q?fix:=20=E5=8A=A0=E4=B8=8A=20-=20=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6=E6=9D=A5=E9=81=BF=E5=85=8D=E8=B7=A8=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E8=AF=AF=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@core/base/shared/src/cache/storage-manager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 3ad2653a3..a2202e1f8 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -27,7 +27,8 @@ class StorageManager { */ async clear(): Promise { const allKeys = await this.driver.keys(); - const prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix)); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix)); await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key))); } @@ -36,7 +37,8 @@ class StorageManager { */ async clearExpiredItems(): Promise { const allKeys = await this.driver.keys(); - const prefixedKeys = allKeys.filter((key) => key.startsWith(this.prefix)); + 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); From f2a17cbe78da4f48813c6128df197bcb7ab59ccf Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 13:10:41 +0800 Subject: [PATCH 15/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86getViewedKeys()?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=AF=B9=E5=86=85=E9=83=A8=E5=8F=AF=E5=8F=98?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E7=9A=84=E7=9B=B4=E6=8E=A5=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/effects/plugins/src/vxe-table/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index 9a52e9697..779a5964b 100644 --- a/packages/effects/plugins/src/vxe-table/api.ts +++ b/packages/effects/plugins/src/vxe-table/api.ts @@ -85,7 +85,8 @@ export class VxeGridApi< * 获取所有已读的 key 集合 */ getViewedKeys(): Set { - return this.viewedRowHelper?.viewedSet.value ?? new Set(); + const raw = this.viewedRowHelper?.viewedSet.value; + return raw ? new Set(raw) : new Set(); } /** From c78d89f549f9aefad55dafea24e7b1a97e90cb37 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 13:16:50 +0800 Subject: [PATCH 16/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=E9=9D=9E?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E6=80=A7=E7=9A=84=E5=85=88=E6=B8=85=E9=99=A4?= =?UTF-8?q?=E5=90=8E=E5=86=99=E5=85=A5=E6=93=8D=E4=BD=9C=E4=BC=9AsetKeys?= =?UTF-8?q?=E5=B8=A6=E6=9D=A5=E6=B0=B8=E4=B9=85=E6=80=A7=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=A3=8E=E9=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../effects/plugins/src/vxe-table/use-viewed-row.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts index e4f2e74ba..8b60a2dd7 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -94,11 +94,17 @@ function createIndexedDBAdapter( }, async setKeys(keys) { try { - // 先清除旧数据,再逐条写入 - await manager.clear(); + const newKeySet = new Set(keys.map(String)); + // 先写入新数据,确保数据安全落盘 await Promise.all( keys.map((key) => manager.setItem(String(key), key, opts.ttl)), ); + // 再清理不在新集合中的旧 key + const existingKeys = await manager.keys(); + 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); } From a5f0537cb09bf8ab47ea586628a72d0396b5a894 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 13:38:50 +0800 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86viewedKeys?= =?UTF-8?q?=E7=94=B1=E4=BA=8E=20FIFO=20=E9=A1=BA=E5=BA=8F=EF=BC=8C?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=8F=90=E4=BE=9B=E7=9A=84=E5=80=BC=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E4=BC=9A=E8=A2=AB=E5=BC=82=E6=AD=A5=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E9=9D=99=E9=BB=98=E5=9C=B0=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/src/vxe-table/use-viewed-row.ts | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts index 8b60a2dd7..bba919b3e 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -198,39 +198,45 @@ export function useViewedRow( const persist = useDebounceFn(persistImmediate, 300); // ========== 从存储恢复 ========== - function restoreFromStorage() { + async function restoreFromStorage(): Promise { if (!adapter) return; - adapter - .getKeys() - .then((stored) => { - if (stored && stored.length > 0) { - for (const key of stored) { - viewedSet.value.add(key); - } - if (maxSize > 0) { - enforceMaxSize(viewedSet.value, maxSize); - } - triggerRef(viewedSet); + try { + const stored = await adapter + .getKeys(); + if (stored && stored.length > 0) { + for (const key of stored) { + viewedSet.value.add(key); } - }) - .catch((error) => { - console.error('[viewedRow] restore failed:', error); - }); - } - - restoreFromStorage(); - - // 合并外部传入的 viewedKeys - if (options.viewedKeys) { - const keys = isRef(options.viewedKeys) - ? options.viewedKeys.value - : options.viewedKeys; - for (const key of keys) { - 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); From bbcad709ca023fc35c5dce60acb84666dade91a4 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 14:07:30 +0800 Subject: [PATCH 18/27] =?UTF-8?q?style:=20=E4=BF=AE=E6=94=B9=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/src/views/examples/vxe-table/viewed.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index 6a829b0be..5df0e9047 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -23,7 +23,7 @@ interface RowType { const gridOptions: VxeGridProps = { checkboxConfig: { highlight: true, - labelField: 'name', + labelField: 'productName', }, columns: [ {title: '序号', type: 'seq', width: 50}, @@ -88,7 +88,7 @@ const gridOptions: VxeGridProps = { const [Grid, gridApi] = useVbenVxeGrid({ gridOptions, viewedRow: { - // 触发已读的操作码(点击编辑时标记为已读) + // 触发已读的操作码 actionCodes: ['view'], // 行数据中的唯一标识字段 keyField: 'id', From 28905b0becc2ebbd88cda905ed3c9dce4e3e8743 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 14:08:19 +0800 Subject: [PATCH 19/27] style: reformat code --- packages/effects/plugins/src/vxe-table/use-viewed-row.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts index bba919b3e..6deba1e59 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -202,8 +202,7 @@ export function useViewedRow( if (!adapter) return; try { - const stored = await adapter - .getKeys(); + const stored = await adapter.getKeys(); if (stored && stored.length > 0) { for (const key of stored) { viewedSet.value.add(key); From 9c49f4bb1ed15912957a801a8d46fbdfea9c9bea Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 18:27:09 +0800 Subject: [PATCH 20/27] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96viewed?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=88=9D=E5=A7=8B=E5=8C=96,rowStyle=20rowCla?= =?UTF-8?q?ssName=20=E4=BB=8E=E6=9C=80=E6=96=B0=E7=9A=84=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=AD=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/effects/plugins/src/vxe-table/api.ts | 27 +++------- .../effects/plugins/src/vxe-table/types.ts | 2 +- .../plugins/src/vxe-table/use-viewed-row.ts | 46 +++++++++++++++- .../plugins/src/vxe-table/use-vxe-grid.vue | 54 +++++++------------ .../src/views/examples/vxe-table/viewed.vue | 37 ++++++++++++- 5 files changed, 107 insertions(+), 59 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index 779a5964b..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,20 +43,15 @@ export class VxeGridApi< public store: Store>; + /** + * 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中) + */ + public viewedRowHelper: null | ViewedRowHelper = null; + private isMounted = false; private stateHandler: StateHandler; - // 已读行相关方法(由 use-vxe-grid.vue 注入) - private viewedRowHelper: null | { - clearViewed: () => void; - isViewed: (record: T) => boolean; - markAsViewed: (record: T) => void; - markKeysAsViewed: (keys: Array) => void; - removeKeys: (keys: Array) => void; - viewedSet: { value: Set }; - } = null; - constructor(options: VxeGridProps = {} as VxeGridProps) { const storeState = { ...options }; @@ -82,7 +78,7 @@ export class VxeGridApi< } /** - * 获取所有已读的 key 集合 + * 获取所有已读的 key 集合(返回副本,避免外部修改内部状态) */ getViewedKeys(): Set { const raw = this.viewedRowHelper?.viewedSet.value; @@ -170,14 +166,6 @@ export class VxeGridApi< } } - /** - * 设置已读行 helper(由组件内部调用) - * @internal - */ - setViewedRowHelper(helper: VxeGridApi['viewedRowHelper']) { - this.viewedRowHelper = helper; - } - toggleSearchForm(show?: boolean) { this.setState({ showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm, @@ -191,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/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index d4f138986..ed52eb417 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/packages/effects/plugins/src/vxe-table/types.ts @@ -168,7 +168,7 @@ export interface VxeGridProps< /** * 已读行功能 */ - viewedRow?: boolean | ViewedRowOptions; + 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 index 6deba1e59..1c0ee0a50 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -373,6 +373,8 @@ export function useViewedRow( }; } +export type ViewedRowHelper = ReturnType>; + // ========== 工具函数 ========== function normalizeClassName(value: any): string { @@ -445,24 +447,64 @@ export function applyViewedRowOptions( 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, - helper.getRowClassName(params), + viewedClass, ); }; // 注入 rowStyle const originalRowStyle = mergedOptions.rowStyle; mergedOptions.rowStyle = (params: any) => { - const viewedStyle = helper.getRowStyle(params); 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; 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 c74c7d595..173c1bfab 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -19,12 +19,10 @@ import { nextTick, onMounted, onUnmounted, - shallowRef, toRaw, useSlots, useTemplateRef, watch, - watchEffect, } from 'vue'; import { usePriorityValues } from '@vben/hooks'; @@ -79,45 +77,31 @@ const { tableTitleHelp, showSearchForm, separator, - viewedRow, + viewedRowOptions, } = usePriorityValues(props, state); -// ========== 已读行:响应 viewedRow 配置变化 ========== -const defaultKeyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id'; +// viewedRowOptions:helper 只创建一次(persist/keyField 不支持运行时切换) +// actionCodes、rowClassName、rowStyle、viewedKeys 的变化通过 options computed 自然响应 +const gridApi = props.api; -const viewedRowHelper = shallowRef>( - null, -); - -// 初始化 + 监听配置变化时重建 helper watch( - viewedRow, + viewedRowOptions, (cfg) => { - if (!cfg) { - viewedRowHelper.value = null; - props.api?.setViewedRowHelper?.(null); - return; - } - const resolvedOptions = isBoolean(cfg) - ? {keyField: defaultKeyField} - : {keyField: defaultKeyField, ...cfg}; - viewedRowHelper.value = useViewedRow(resolvedOptions); - // 同步更新 API 中的 helper 引用 - if (props.api?.setViewedRowHelper) { - props.api.setViewedRowHelper(viewedRowHelper.value); - } + // 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}, ); -// viewedSet 变化时,主动刷新 grid 行样式 -watchEffect(() => { - const helper = viewedRowHelper.value; - if (!helper) return; - // 访问 viewedSet.value 建立依赖追踪 - void helper.viewedSet.value; -}); - const { isMobile } = usePreferences(); const isSeparator = computed(() => { if ( @@ -276,11 +260,11 @@ const options = computed(() => { } // 注入已读行功能(rowClassName、rowStyle、columns 拦截) - if (viewedRow.value && viewedRowHelper.value) { + if (viewedRowOptions.value && gridApi.viewedRowHelper) { applyViewedRowOptions( mergedOptions, - viewedRow.value, - viewedRowHelper.value, + viewedRowOptions.value, + gridApi.viewedRowHelper, ); } diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index 5df0e9047..b849ab70e 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -87,7 +87,7 @@ const gridOptions: VxeGridProps = { const [Grid, gridApi] = useVbenVxeGrid({ gridOptions, - viewedRow: { + viewedRowOptions: { // 触发已读的操作码 actionCodes: ['view'], // 行数据中的唯一标识字段 @@ -144,6 +144,33 @@ function onView(row: RowType) { }); } +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); @@ -168,7 +195,13 @@ function onClearViewed() { From 08e4bb40b41b6bed73edfdd5eace80925b8534da Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 18:28:23 +0800 Subject: [PATCH 21/27] style: reformat code --- packages/effects/plugins/src/vxe-table/use-vxe-grid.vue | 7 ++----- playground/src/views/examples/vxe-table/viewed.vue | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) 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 173c1bfab..665f18f62 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -92,11 +92,8 @@ watch( if (!cfg) return; - const keyField = - (gridOptions.value?.rowConfig as any)?.keyField || 'id'; - const resolved = isBoolean(cfg) - ? {keyField} - : {keyField, ...cfg}; + const keyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id'; + const resolved = isBoolean(cfg) ? {keyField} : {keyField, ...cfg}; gridApi.viewedRowHelper = useViewedRow(resolved); }, {immediate: true}, diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index b849ab70e..3531ef89a 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -164,8 +164,9 @@ function onClassNameSet() { gridApi.setState({ viewedRowOptions: { rowClassName: () => { - - return isClassName.value ? 'bg-red-100 vxe-row--viewed' : 'vxe-row--viewed'; + return isClassName.value + ? 'bg-red-100 vxe-row--viewed' + : 'vxe-row--viewed'; }, }, }); From 4bbd34fab9b6dd6e9814d00756feca87eb327aa8 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 22:28:03 +0800 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20labelField=20?= =?UTF-8?q?=E5=92=8C=20nameField?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/src/views/examples/vxe-table/viewed.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index 3531ef89a..ecafe0da3 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -23,7 +23,7 @@ interface RowType { const gridOptions: VxeGridProps = { checkboxConfig: { highlight: true, - labelField: 'productName', + labelField: 'category', }, columns: [ {title: '序号', type: 'seq', width: 50}, @@ -36,7 +36,7 @@ const gridOptions: VxeGridProps = { align: 'center', cellRender: { attrs: { - nameField: 'name', + nameField: 'category', onClick: onActionClick, }, name: 'CellOperation', From 71f2e5f504467de5305b5d0776f516f1a1a51e9d Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 22:51:39 +0800 Subject: [PATCH 23/27] =?UTF-8?q?fix:=20=E5=8E=BB=E9=99=A4onConfirm?= =?UTF-8?q?=E4=B8=AD=E7=9A=84setTimeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/src/views/examples/vxe-table/viewed.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue index ecafe0da3..e535fe939 100644 --- a/playground/src/views/examples/vxe-table/viewed.vue +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -124,11 +124,9 @@ const [Modal, modalApi] = useVbenModal({ draggable: true, onConfirm: () => { modalApi.setState({loading: true}); - setTimeout(() => { - editRow.value && gridApi.markRowAsViewed(editRow.value); - modalApi.setState({loading: false}); - modalApi.close(); - }, 1500); + editRow.value && gridApi.markRowAsViewed(editRow.value); + modalApi.setState({loading: false}); + modalApi.close(); }, }); From d23b246aeeec67015043ec7b7454c5ab6c85f2c1 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 23:04:38 +0800 Subject: [PATCH 24/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86localStorage=20p?= =?UTF-8?q?refix=20=3D=3D=3D=20''=E6=97=B6=E6=8F=90=E9=86=92=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=8F=AF=E8=83=BD=E4=BC=9A=E5=85=A8=E9=83=A8=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@core/base/shared/src/cache/storage-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index a2202e1f8..31c38e129 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -20,6 +20,11 @@ class StorageManager { constructor({driver, prefix = ''}: StorageManagerOptions = {}) { this.driver = driver || this.createDefaultDriver(); this.prefix = prefix; + if (!this.prefix && this.driver instanceof LocalStorageDriver) { + console.warn( + '[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.', + ); + } } /** From 1b4e126128a02a2ffbbea60386e30d8bb48126a4 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 23:10:05 +0800 Subject: [PATCH 25/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86localStorageDriv?= =?UTF-8?q?er=E5=9C=A8=E6=9F=90=E4=BA=9B=E5=8F=97=E9=99=90=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E7=8E=AF=E5=A2=83=E4=B8=8B=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=97=A0=E6=B3=95=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/@core/base/shared/src/cache/storage-manager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 31c38e129..69ef5fc55 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -119,8 +119,13 @@ class StorageManager { * - SSR / Node 环境 → MemoryStorageDriver */ private createDefaultDriver(): IStorageDriver { - if (typeof window !== 'undefined' && window.localStorage) { - return new LocalStorageDriver(); + 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(); } From 43717807a4954b708848d71f0a2dbce02a4f9ed1 Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 23:18:44 +0800 Subject: [PATCH 26/27] style: reformat code --- packages/@core/base/shared/src/cache/storage-manager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 69ef5fc55..ef42aa7f4 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -125,7 +125,10 @@ class StorageManager { } } catch (error) { // localStorage access denied (e.g. Safari private mode) - console.warn('localStorage is not accessible, falling back to MemoryStorageDriver:', error); + console.warn( + 'localStorage is not accessible, falling back to MemoryStorageDriver:', + error, + ); } return new MemoryStorageDriver(); } From 1299acd8f94b1c05cdfeda6fb6b20a026008846e Mon Sep 17 00:00:00 2001 From: layhuts Date: Sat, 9 May 2026 23:27:25 +0800 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20=E8=8E=B7=E5=8F=96=E5=B7=B2?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E7=9A=84=20key=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=86=99=E5=85=A5=E5=88=B7=E6=96=B0=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/src/vxe-table/use-viewed-row.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts index 1c0ee0a50..365a761ed 100644 --- a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts +++ b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts @@ -95,12 +95,19 @@ function createIndexedDBAdapter( async setKeys(keys) { try { const newKeySet = new Set(keys.map(String)); - // 先写入新数据,确保数据安全落盘 - await Promise.all( - keys.map((key) => manager.setItem(String(key), key, opts.ttl)), - ); - // 再清理不在新集合中的旧 key + // 获取已存在的 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)));