From e555ee065e3fd4d4b1db3f35470a7a6efb6cc672 Mon Sep 17 00:00:00 2001 From: layhuts Date: Fri, 8 May 2026 17:34:54 +0800 Subject: [PATCH 01/35] =?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/35] 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/35] =?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/35] =?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/35] =?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/35] =?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/35] 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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] =?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/35] 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/35] =?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/35] 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/35] =?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/35] =?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/35] =?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/35] =?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/35] 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/35] =?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))); From 30356a24e6e508198e6b3f30fa12dec99d376960 Mon Sep 17 00:00:00 2001 From: xueyitt <1455668754@qq.com> Date: Mon, 11 May 2026 10:09:47 +0800 Subject: [PATCH 28/35] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3vxtable=E4=B8=8D?= =?UTF-8?q?=E5=AD=98=E5=9C=A8formConfig=E6=97=B6=EF=BC=8C=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=8F=98=E6=9B=B4data=E5=A4=B1=E6=95=88=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正vxtable不存在formConfig时,直接变更data失效问题 --- packages/effects/plugins/src/vxe-table/use-vxe-grid.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 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 78cab6a58..9be8f334e 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue @@ -230,9 +230,9 @@ const options = computed(() => { } if (mergedOptions.formConfig) { mergedOptions.formConfig.enabled = false; - if (tableData.value && tableData.value.length > 0) { - mergedOptions.data = tableData.value; - } + } + if (tableData.value && tableData.value.length > 0) { + mergedOptions.data = tableData.value; } return mergedOptions; }); From ca5931e8c4b52a45e38c2582e2616e7fbefabd29 Mon Sep 17 00:00:00 2001 From: Akuria <46207353+Akur1a@users.noreply.github.com> Date: Sat, 16 May 2026 10:41:42 +0800 Subject: [PATCH 29/35] fix: preserve tree default value when treeData starts empty (#7908) When treeData is initially an empty array (e.g. before async data arrives), updateTreeValue() would clear the modelValue because no matching items could be found in the empty flattened data. This caused default values to be lost. Only call updateTreeValue() when flattenData has items, so that modelValue is preserved until the actual tree data arrives. Fixes #6522 --- .changeset/tree-default-value.md | 5 +++++ packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/tree-default-value.md diff --git a/.changeset/tree-default-value.md b/.changeset/tree-default-value.md new file mode 100644 index 000000000..3b82bf789 --- /dev/null +++ b/.changeset/tree-default-value.md @@ -0,0 +1,5 @@ +--- +"@vben-core/shadcn-ui": patch +--- + +fix: preserve tree default value when treeData starts empty diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue index 9dc784334..9ed9e3e9d 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue @@ -70,7 +70,9 @@ let lastTreeData: any = null; onMounted(() => { watchEffect(() => { flattenData.value = flatten(props.treeData, props.childrenField); - updateTreeValue(); + if (flattenData.value.length > 0) { + updateTreeValue(); + } // 只在 treeData 变化时执行展开 const currentTreeData = JSON.stringify(props.treeData); From f55b18ffd71c1e497905e6516425fb7bf4dfa6d3 Mon Sep 17 00:00:00 2001 From: Akuria <46207353+Akur1a@users.noreply.github.com> Date: Sat, 16 May 2026 10:42:35 +0800 Subject: [PATCH 30/35] fix: update primary color when toggling dark/light mode with custom theme (#7909) When a custom theme is selected and the user toggles between dark and light mode, the primary color was not being recalculated. This was caused by a guard condition in the builtin theme watcher that skipped updating themeColorPrimary for custom themes during mode changes. Remove the guard so that the primary color is always recalculated from the theme preset when the mode changes, ensuring Element Plus CSS variables stay in sync. Fixes #6615 --- .changeset/element-plus-theme-switch.md | 5 +++++ .../src/widgets/preferences/blocks/theme/builtin.vue | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .changeset/element-plus-theme-switch.md diff --git a/.changeset/element-plus-theme-switch.md b/.changeset/element-plus-theme-switch.md new file mode 100644 index 000000000..d0c07d83d --- /dev/null +++ b/.changeset/element-plus-theme-switch.md @@ -0,0 +1,5 @@ +--- +"@vben/layouts": patch +--- + +fix: update primary color when toggling dark/light mode with custom theme diff --git a/packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue b/packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue index 1bb142aee..c262eeb96 100644 --- a/packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue +++ b/packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue @@ -103,7 +103,7 @@ function selectColor() { watch( () => [modelValue.value, props.isDark] as [BuiltinThemeType, boolean], - ([themeType, isDark], [_, isDarkPrev]) => { + ([themeType, isDark]) => { const theme = builtinThemePresets.value.find( (item) => item.type === themeType, ); @@ -112,9 +112,7 @@ watch( ? theme.darkPrimaryColor || theme.primaryColor : theme.primaryColor; - if (!(theme.type === 'custom' && isDark !== isDarkPrev)) { - themeColorPrimary.value = primaryColor || theme.color; - } + themeColorPrimary.value = primaryColor || theme.color; } }, ); From 294700a247a26933a90932a901b23810fc750e3a Mon Sep 17 00:00:00 2001 From: Akuria <46207353+Akur1a@users.noreply.github.com> Date: Sat, 16 May 2026 10:43:10 +0800 Subject: [PATCH 31/35] fix: skip fixed footer height in auto-content-height calculation (#7910) * fix: skip fixed footer height in auto-content-height calculation When the Page component's footer has position: fixed, it is removed from the normal document flow and should not be subtracted from the available content height. Previously, the footer's offsetHeight was always subtracted, causing incorrect height calculation for fixed footers. Also reset shouldAutoHeight before recalculating to prevent stale state on hot reload. Fixes #4576 * fix: replace getComputedStyle footer height check with footerFixed prop Use an explicit `footerFixed` boolean prop instead of runtime getComputedStyle detection to determine whether the footer height should be excluded from content height calculation. This avoids unreliable style queries and makes the behavior deterministic. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .changeset/page-auto-content-height.md | 5 +++++ packages/effects/common-ui/src/components/page/page.vue | 7 +++++-- packages/effects/common-ui/src/components/page/types.ts | 6 ++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .changeset/page-auto-content-height.md diff --git a/.changeset/page-auto-content-height.md b/.changeset/page-auto-content-height.md new file mode 100644 index 000000000..94b81cc9c --- /dev/null +++ b/.changeset/page-auto-content-height.md @@ -0,0 +1,5 @@ +--- +"@vben/common-ui": patch +--- + +fix: skip fixed footer height in auto-content-height calculation diff --git a/packages/effects/common-ui/src/components/page/page.vue b/packages/effects/common-ui/src/components/page/page.vue index b9c6ba69f..c2509c838 100644 --- a/packages/effects/common-ui/src/components/page/page.vue +++ b/packages/effects/common-ui/src/components/page/page.vue @@ -12,7 +12,7 @@ defineOptions({ name: 'Page', }); -const { autoContentHeight = false, heightOffset = 0 } = +const { autoContentHeight = false, heightOffset = 0, footerFixed = false } = defineProps(); const headerHeight = ref(0); @@ -36,9 +36,12 @@ async function calcContentHeight() { if (!autoContentHeight) { return; } + shouldAutoHeight.value = false; await nextTick(); headerHeight.value = headerRef.value?.offsetHeight || 0; - footerHeight.value = footerRef.value?.offsetHeight || 0; + + footerHeight.value = footerFixed ? 0 : (footerRef.value?.offsetHeight || 0); + setTimeout(() => { shouldAutoHeight.value = true; }, 30); diff --git a/packages/effects/common-ui/src/components/page/types.ts b/packages/effects/common-ui/src/components/page/types.ts index c7331aedf..dfebd1221 100644 --- a/packages/effects/common-ui/src/components/page/types.ts +++ b/packages/effects/common-ui/src/components/page/types.ts @@ -14,4 +14,10 @@ export interface PageProps { * @default 0 */ heightOffset?: number; + /** + * Whether the footer is position: fixed. + * When true, footer height is excluded from content height calculation. + * @default false + */ + footerFixed?: boolean; } From 4d8d2de6ad34540ef09b05a9b4cfb4e23e093d20 Mon Sep 17 00:00:00 2001 From: Akuria <46207353+Akur1a@users.noreply.github.com> Date: Sat, 16 May 2026 10:43:47 +0800 Subject: [PATCH 32/35] fix: guard svg icon loading during docs SSR (#7912) --- .changeset/small-moons-hunt.md | 5 +++++ packages/icons/src/svg/load.ts | 8 ++++++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/small-moons-hunt.md diff --git a/.changeset/small-moons-hunt.md b/.changeset/small-moons-hunt.md new file mode 100644 index 000000000..ab3820103 --- /dev/null +++ b/.changeset/small-moons-hunt.md @@ -0,0 +1,5 @@ +--- +"@vben/icons": patch +--- + +fix: guard svg icon loading during docs SSR diff --git a/packages/icons/src/svg/load.ts b/packages/icons/src/svg/load.ts index c42dd50d2..b21869ba3 100644 --- a/packages/icons/src/svg/load.ts +++ b/packages/icons/src/svg/load.ts @@ -53,6 +53,14 @@ function parseSvg(svgData: string): IconifyIconStructure { * */ async function loadSvgIcons() { + if ( + typeof DOMParser === 'undefined' || + typeof Node === 'undefined' || + typeof XMLSerializer === 'undefined' + ) { + return; + } + const svgEagers = import.meta.glob('./icons/**', { eager: true, query: '?raw', From 42d82875cee41a9b7a617764b95143abb2c2b5bf Mon Sep 17 00:00:00 2001 From: PanFu Date: Sat, 16 May 2026 10:44:55 +0800 Subject: [PATCH 33/35] =?UTF-8?q?feat:=201=E3=80=81=E5=AE=8C=E5=96=84tree?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84=E5=85=A8=E9=80=89=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8D=E6=AD=A3=E7=A1=AE=E3=80=81=E5=85=A8=E9=80=89=E6=B2=A1?= =?UTF-8?q?=E6=9C=89label=E3=80=81item=E5=86=85=E5=AE=B9=E8=B6=85=E9=95=BF?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E5=A4=8D=E9=80=89=E6=A1=86=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E9=94=99=E4=B9=B1=E3=80=81item=E5=86=85=E5=AE=B9=E8=B6=85?= =?UTF-8?q?=E9=95=BF=E6=B2=A1=E6=9C=89tips=E6=97=A0=E6=B3=95=E7=9C=8B?= =?UTF-8?q?=E5=88=B0=E5=AE=8C=E6=95=B4=E5=86=85=E5=AE=B9=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20(#7915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: PanFu --- .../ui-kit/shadcn-ui/src/ui/tree/tree.vue | 68 +++++++++++++++---- .../ui-kit/shadcn-ui/src/ui/tree/types.ts | 2 + 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue index 9ed9e3e9d..81d5e4eda 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue @@ -6,7 +6,7 @@ import type { ClassType, Recordable } from '@vben-core/typings'; import type { TreeProps } from './types'; -import { onMounted, ref, watchEffect } from 'vue'; +import { computed, onMounted, ref, watchEffect } from 'vue'; import { ChevronRight, IconifyIcon } from '@vben-core/icons'; import { cn, get } from '@vben-core/shared/utils'; @@ -192,6 +192,32 @@ function isNodeDisabled(item: FlattenedItem>) { return props.disabled || get(item.value, props.disabledField); } +// 计算全选/半选状态 +const selectAllStatus = computed<'indeterminate' | boolean>(() => { + if (!props.multiple) return false; + if (!modelValue.value || !Array.isArray(modelValue.value)) return false; + + const allValues = flattenData.value + .filter((item) => !get(item.value, props.disabledField)) + .map((item) => get(item.value, props.valueField)); + + const selectedCount = allValues.filter((v) => + (modelValue.value as (number | string)[]).includes(v), + ).length; + + if (selectedCount === 0) return false; + if (selectedCount === allValues.length) return true; + return 'indeterminate'; +}); + +function onSelectAllChange(checked: 'indeterminate' | boolean) { + if (checked === true) { + checkAll(); + } else { + unCheckAll(); + } +} + function onToggle(item: FlattenedItem>) { emits('expand', item); } @@ -316,14 +342,16 @@ defineExpose({ :class="{ 'rotate-90': expanded?.length > 0 }" class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition" /> - +
+ + {{ selectAllLabel }} +
@@ -371,8 +399,9 @@ defineExpose({ !isNodeDisabled(item) && onToggle(item); } " - class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden focus:ring-2" + class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden" > +
-
+