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/.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/.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/.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/base/shared/src/cache/README.md b/packages/@core/base/shared/src/cache/README.md new file mode 100644 index 000000000..5b5dc7cf9 --- /dev/null +++ b/packages/@core/base/shared/src/cache/README.md @@ -0,0 +1,422 @@ +# Cache 模块 + +基于**策略模式**的异步存储管理方案,支持多种存储后端(localStorage、IndexedDB、Memory),提供统一的 API +接口。 + +## 架构设计 + +```shell +┌───────────────────────────────────────────────┐ +│ StorageManager │ +│ ┌─────────────┐ ┌───────────────────────┐ │ +│ │ Prefix 隔离 │ │ TTL 过期管理 │ │ +│ └─────────────┘ └───────────────────────┘ │ +├───────────────────────────────────────────────┤ +│ IStorageDriver │ +├──────────┬─────────────────┬──────────────────┤ +│ Local │ IndexedDB │ Memory │ +│ Storage │ Driver │ Driver │ +│ Driver │ │ │ +└──────────┴─────────────────┴──────────────────┘ +``` + +**分层职责:** + +| 层级 | 职责 | +|------------------|----------------------------| +| `StorageManager` | 命名空间前缀隔离、TTL 过期检查、统一对外 API | +| `IStorageDriver` | 纯粹的 KV 存取抽象接口 | +| 各 Driver 实现 | 对接具体存储引擎,不感知前缀和 TTL | + +--- + +## 快速开始 + +### 基本使用(默认 localStorage) + +```typescript +import {StorageManager} from '@vben-core/shared/cache'; + +const cache = new StorageManager({prefix: 'myapp'}); +// 使用 IndexedDB +//new StorageManager({ driver: new IndexedDBDriver(), prefix: 'app' }); + +// 使用 sessionStorage +//new StorageManager({ driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), prefix: 'app' }); + +// 测试环境 +//new StorageManager({ driver: new MemoryStorageDriver(), prefix: 'test' }); + +// 存储数据 +await cache.setItem('user', {name: '张三', age: 28}); + +// 读取数据 +const user = await cache.getItem('user'); +// => { name: '张三', age: 28 } + +// 带默认值读取 +const settings = await cache.getItem('settings', {theme: 'light'}); +// 如果不存在,返回 { theme: 'light' } + +// 删除数据 +await cache.removeItem('user'); + +// 清除当前前缀下所有数据 +await cache.clear(); +``` + +### 带 TTL 过期 + +```typescript +const cache = new StorageManager({prefix: 'session'}); + +// 设置 5 分钟后过期(TTL 单位为毫秒) +await cache.setItem('token', 'abc123', 5 * 60 * 1000); + +// 5 分钟内可以正常读取 +const token = await cache.getItem('token'); +// => 'abc123' + +// 5 分钟后自动返回 null(惰性删除) +const expiredToken = await cache.getItem('token'); +// => null + +// 主动清理所有过期项 +await cache.clearExpiredItems(); +``` + +--- + +## 存储驱动 + +### LocalStorageDriver(默认) + +基于浏览器 `localStorage` 或 `sessionStorage`,数据持久化存储。 + +```typescript +import { LocalStorageDriver, StorageManager } from '@vben-core/shared/cache'; + +// 使用 localStorage(默认) +const cache = new StorageManager({ + driver: new LocalStorageDriver(), + prefix: 'app', +}); + +// 使用 sessionStorage +const sessionCache = new StorageManager({ + driver: new LocalStorageDriver({ storageType: 'sessionStorage' }), + prefix: 'app', +}); +``` + +**特点:** + +- 同步 API 用 async 包装,保持接口统一 +- 自动处理 JSON 序列化/反序列化 +- 数据损坏时自动清除并返回 null +- 存储上限约 5-10MB(视浏览器而定) + +**适用场景:** 用户偏好设置、小型配置数据、Token 存储 + +--- + +### IndexedDBDriver + +基于浏览器 IndexedDB,支持大容量结构化数据存储。 + +```typescript +import {IndexedDBDriver, StorageManager} from '@vben-core/shared/cache'; + +const cache = new StorageManager({ + driver: new IndexedDBDriver({ + dbName: 'my-app-db', // 数据库名称,默认 'vben-storage' + dbVersion: 1, // 数据库版本,默认 1 + storeName: 'cache-store', // 对象存储名称,默认 'kv-store' + }), + prefix: 'data', +}); + +// 存储大量数据 +await cache.setItem('table-data', largeDataArray); + +// 存储二进制友好的结构(IndexedDB 原生支持) +await cache.setItem('config', { + columns: [...], + filters: [...], + pagination: {page: 1, size: 20}, +}); +``` + +**特点:** + +- 懒初始化:首次操作时自动打开数据库,无需手动调用 `init()` +- 存储容量大(通常数百 MB 到 GB 级别) +- 支持结构化克隆(可存储 Date、RegExp、Blob 等复杂类型) +- 天然异步,不阻塞主线程 + +**适用场景:** 离线数据缓存、大型表格数据、文件/图片缓存、复杂业务数据 + +--- + +### MemoryStorageDriver + +基于内存 Map,数据不持久化,页面刷新即丢失。 + +```typescript +import { MemoryStorageDriver, StorageManager } from '@vben-core/shared/cache'; + +const cache = new StorageManager({ + driver: new MemoryStorageDriver(), + prefix: 'test', +}); +``` + +**特点:** + +- 读写速度最快 +- 无浏览器 API 依赖 +- 数据随页面生命周期销毁 + +**适用场景:** 单元测试、SSR 服务端渲染、临时运行时缓存 + +--- + +## API 参考 + +### StorageManager + +#### 构造函数 + +```typescript +new StorageManager(options?: StorageManagerOptions) +``` + +| 参数 | 类型 | 默认值 | 说明 | +|----------|------------------|----------------------------|--------------| +| `driver` | `IStorageDriver` | `new LocalStorageDriver()` | 存储驱动实例 | +| `prefix` | `string` | `''` | 键前缀,用于命名空间隔离 | + +#### 方法 + +| 方法 | 签名 | 说明 | +|---------------------|-------------------------------------------------------------------------|-------------------| +| `getItem` | `getItem(key: string, defaultValue?: T \| null): Promise` | 获取存储项,过期或不存在返回默认值 | +| `setItem` | `setItem(key: string, value: T, ttl?: number): Promise` | 设置存储项,可选 TTL(毫秒) | +| `removeItem` | `removeItem(key: string): Promise` | 删除指定存储项 | +| `clear` | `clear(): Promise` | 清除当前前缀下所有存储项 | +| `clearExpiredItems` | `clearExpiredItems(): Promise` | 主动清理所有过期项 | + +--- + +### IStorageDriver 接口 + +自定义驱动需要实现此接口: + +```typescript +interface IStorageDriver { + clear(): Promise; + + getItem(key: string): Promise; + + keys(): Promise; + + removeItem(key: string): Promise; + + setItem(key: string, value: T): Promise; +} +``` + +--- + +## 高级用法 + +### 自定义 Driver + +```typescript +import type {IStorageDriver} from '@vben-core/shared/cache'; + +class CookieStorageDriver implements IStorageDriver { + async getItem(key: string): Promise { + const value = getCookie(key); + return value ? JSON.parse(value) : null; + } + + async setItem(key: string, value: T): Promise { + setCookie(key, JSON.stringify(value)); + } + + async removeItem(key: string): Promise { + deleteCookie(key); + } + + async clear(): Promise { + clearAllCookies(); + } + + async keys(): Promise { + return getAllCookieNames(); + } +} + +// 使用自定义 Driver +const cache = new StorageManager({ + driver: new CookieStorageDriver(), + prefix: 'ck', +}); +``` + +### 根据环境动态选择 Driver + +```typescript +import { + IndexedDBDriver, + LocalStorageDriver, + MemoryStorageDriver, + StorageManager, +} from '@vben-core/shared/cache'; + +function createStorageManager(prefix: string) { + // SSR 环境使用内存驱动 + if (typeof window === 'undefined') { + return new StorageManager({ + driver: new MemoryStorageDriver(), + prefix, + }); + } + + // 大数据场景使用 IndexedDB + if (needsLargeStorage()) { + return new StorageManager({ + driver: new IndexedDBDriver({ dbName: `${prefix}-db` }), + prefix, + }); + } + + // 默认使用 localStorage + return new StorageManager({ prefix }); +} +``` + +### 命名空间隔离 + +```typescript +// 不同模块使用不同前缀,互不干扰 +const userCache = new StorageManager({prefix: 'user'}); +const configCache = new StorageManager({prefix: 'config'}); + +await userCache.setItem('profile', {name: '张三'}); +await configCache.setItem('profile', {theme: 'dark'}); + +// 各自独立 +await userCache.getItem('profile'); // => { name: '张三' } +await configCache.getItem('profile'); // => { theme: 'dark' } + +// 只清除 user 前缀的数据 +await userCache.clear(); +await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响) +``` + +### 定时清理过期数据 + +```typescript +const cache = new StorageManager({ prefix: 'app' }); + +// 应用启动时清理一次 +await cache.clearExpiredItems(); + +// 或者定时清理(每 10 分钟) +setInterval( + async () => { + await cache.clearExpiredItems(); + }, + 10 * 60 * 1000, +); +``` + +--- + +## 数据存储格式 + +`StorageManager` 在 Driver 层存储的数据结构为: + +```typescript +interface StorageItem { + expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期 + value: T; // 实际业务数据 +} +``` + +实际存储的 key 格式为:`{prefix}-{key}` + +例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。 + +--- + +## 过期策略 + +采用**惰性删除 + 主动清理**双重策略: + +| 策略 | 触发时机 | 说明 | +|------|--------------------------|---------------------| +| 惰性删除 | 调用 `getItem` 时 | 读取时检查过期,过期则删除并返回默认值 | +| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 | + +--- + +## 各 Driver 对比 + +| 特性 | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver | +|-------|--------------------|-----------------|---------------------| +| 持久化 | ✅ | ✅ | ❌ | +| 容量 | 5-10 MB | 数百 MB+ | 受内存限制 | +| 速度 | 快(同步) | 中等(异步 I/O) | 最快 | +| 数据类型 | 仅 JSON 可序列化 | 结构化克隆 | 任意 JS 对象 | +| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器 | 任意环境 | +| 阻塞主线程 | 是 | 否 | 否 | +| 适用场景 | 配置、Token、小数据 | 离线缓存、大数据 | 测试、SSR | + +--- + +## 在项目中的使用 + +本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置: + +```typescript +// packages/@core/preferences/src/preferences.ts +class PreferenceManager { + private cache: StorageManager; + + constructor() { + this.cache = new StorageManager(); + this.state = reactive({...defaultPreferences}); + } + + initPreferences = async ({namespace}) => { + // 用应用命名空间重新初始化 + this.cache = new StorageManager({prefix: namespace}); + + // 从缓存加载偏好设置 + const cached = await this.cache.getItem('preferences'); + // ... + }; +} +``` + +--- + +## 注意事项 + +1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver + 时无需改动调用方。 + +2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。 + +3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。 + +4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。 + +5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据; + `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。 + +6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在 + `upgradeneeded` 事件中自动创建 objectStore。 diff --git a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts b/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts index a5abe5a71..6d58cedd2 100644 --- a/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts +++ b/packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import {MemoryStorageDriver} from '../memory-storage-driver'; import { StorageManager } from '../storage-manager'; describe('storageManager', () => { @@ -7,123 +8,115 @@ describe('storageManager', () => { beforeEach(() => { vi.useFakeTimers(); - localStorage.clear(); storageManager = new StorageManager({ + driver: new MemoryStorageDriver(), prefix: 'test_', }); }); - it('should set and get an item', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - const user = storageManager.getItem('user'); + it('should set and get an item', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should return default value if item does not exist', () => { - const user = storageManager.getItem('nonexistent', { + it('should return default value if item does not exist', async () => { + const user = await storageManager.getItem('nonexistent', { age: 0, name: 'Default User', }); expect(user).toEqual({ age: 0, name: 'Default User' }); }); - it('should remove an item', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - storageManager.removeItem('user'); - const user = storageManager.getItem('user'); + it('should remove an item', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + await storageManager.removeItem('user'); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should clear all items with the prefix', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }); - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); - storageManager.clear(); - expect(storageManager.getItem('user1')).toBeNull(); - expect(storageManager.getItem('user2')).toBeNull(); + it('should clear all items with the prefix', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}); + await storageManager.clear(); + expect(await storageManager.getItem('user1')).toBeNull(); + expect(await storageManager.getItem('user2')).toBeNull(); }); - it('should clear expired items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 + it('should clear expired items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should not clear non-expired items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期 + it('should not clear non-expired items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should handle JSON parse errors gracefully', () => { - localStorage.setItem('test_user', '{ invalid JSON }'); - const user = storageManager.getItem('user', { - age: 0, - name: 'Default User', - }); - expect(user).toEqual({ age: 0, name: 'Default User' }); - }); - it('should return null for non-existent items without default value', () => { - const user = storageManager.getItem('nonexistent'); + it('should return null for non-existent items without default value', async () => { + const user = await storageManager.getItem('nonexistent'); expect(user).toBeNull(); }); - it('should overwrite existing items', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); - storageManager.setItem('user', { age: 25, name: 'Jane Doe' }); - const user = storageManager.getItem('user'); + it('should overwrite existing items', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user', {age: 25, name: 'Jane Doe'}); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 25, name: 'Jane Doe' }); }); - it('should handle items without expiry correctly', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); + it('should handle items without expiry correctly', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); vi.advanceTimersByTime(5000); - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should remove expired items when accessed', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 + it('should remove expired items when accessed', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 vi.advanceTimersByTime(1001); // 快进时间 - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toBeNull(); }); - it('should not remove non-expired items when accessed', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期 + it('should not remove non-expired items when accessed', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}, 10_000); // 10秒过期 vi.advanceTimersByTime(5000); // 快进时间 - const user = storageManager.getItem('user'); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should handle multiple items with different expiry times', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期 - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期 + it('should handle multiple items with different expiry times', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}, 1000); // 1秒过期 + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}, 2000); // 2秒过期 vi.advanceTimersByTime(1500); // 快进时间 - storageManager.clearExpiredItems(); - const user1 = storageManager.getItem('user1'); - const user2 = storageManager.getItem('user2'); + await storageManager.clearExpiredItems(); + const user1 = await storageManager.getItem('user1'); + const user2 = await storageManager.getItem('user2'); expect(user1).toBeNull(); expect(user2).toEqual({ age: 25, name: 'Jane Doe' }); }); - it('should handle items with no expiry', () => { - storageManager.setItem('user', { age: 30, name: 'John Doe' }); + it('should handle items with no expiry', async () => { + await storageManager.setItem('user', {age: 30, name: 'John Doe'}); vi.advanceTimersByTime(10_000); // 快进时间 - storageManager.clearExpiredItems(); - const user = storageManager.getItem('user'); + await storageManager.clearExpiredItems(); + const user = await storageManager.getItem('user'); expect(user).toEqual({ age: 30, name: 'John Doe' }); }); - it('should clear all items correctly', () => { - storageManager.setItem('user1', { age: 30, name: 'John Doe' }); - storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }); - storageManager.clear(); - const user1 = storageManager.getItem('user1'); - const user2 = storageManager.getItem('user2'); + it('should clear all items correctly', async () => { + await storageManager.setItem('user1', {age: 30, name: 'John Doe'}); + await storageManager.setItem('user2', {age: 25, name: 'Jane Doe'}); + await storageManager.clear(); + const user1 = await storageManager.getItem('user1'); + const user2 = await storageManager.getItem('user2'); expect(user1).toBeNull(); expect(user2).toBeNull(); }); diff --git a/packages/@core/base/shared/src/cache/index.ts b/packages/@core/base/shared/src/cache/index.ts index 8c44d7f16..e1d5c6e86 100644 --- a/packages/@core/base/shared/src/cache/index.ts +++ b/packages/@core/base/shared/src/cache/index.ts @@ -1 +1,5 @@ +export * from './indexeddb-driver'; +export * from './local-storage-driver'; +export * from './memory-storage-driver'; export * from './storage-manager'; +export type * from './types'; diff --git a/packages/@core/base/shared/src/cache/indexeddb-driver.ts b/packages/@core/base/shared/src/cache/indexeddb-driver.ts new file mode 100644 index 000000000..9875d888a --- /dev/null +++ b/packages/@core/base/shared/src/cache/indexeddb-driver.ts @@ -0,0 +1,137 @@ +import type {IStorageDriver} from './types'; + +interface IndexedDBDriverOptions { + /** 数据库名称 */ + dbName?: string; + /** 数据库版本 */ + dbVersion?: number; + /** 对象存储名称 */ + storeName?: string; +} + +/** + * IndexedDB 驱动 + * 采用懒初始化模式,首次操作时自动打开数据库 + */ +class IndexedDBDriver implements IStorageDriver { + private dbName: string; + private dbPromise: null | Promise = null; + private dbVersion: number; + private storeName: string; + + constructor({ + dbName = 'vben-storage', + dbVersion = 1, + storeName = 'kv-store', + }: IndexedDBDriverOptions = {}) { + this.dbName = dbName; + this.dbVersion = dbVersion; + this.storeName = storeName; + } + + async clear(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + const store = tx.objectStore(this.storeName); + store.clear(); + + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); + }); + } + + async getItem(key: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readonly'); + const store = tx.objectStore(this.storeName); + const request = store.get(key); + + request.addEventListener('success', () => + resolve(request.result ?? null), + ); + request.addEventListener('error', () => reject(request.error)); + }); + } + + async keys(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readonly'); + const store = tx.objectStore(this.storeName); + const request = store.getAllKeys(); + + request.addEventListener('success', () => + resolve(request.result.map(String)), + ); + request.addEventListener('error', () => reject(request.error)); + }); + } + + async removeItem(key: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + const store = tx.objectStore(this.storeName); + store.delete(key); + + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); + }); + } + + async setItem(key: string, value: T): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + const store = tx.objectStore(this.storeName); + store.put(value, key); + + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + tx.addEventListener('abort', () => + reject(tx.error ?? new Error('Transaction aborted')), + ); + }); + } + + /** + * 懒初始化:首次调用时打开数据库,后续复用同一个 Promise + */ + private getDB(): Promise { + if (!this.dbPromise) { + this.dbPromise = this.openDB().catch((error) => { + // allow retry on next call + this.dbPromise = null; + throw error; + }); + } + return this.dbPromise; + } + + private openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.addEventListener('upgradeneeded', () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName); + } + }); + + request.addEventListener('success', () => resolve(request.result)); + request.addEventListener('error', () => reject(request.error)); + }); + } +} + +export {IndexedDBDriver}; +export type {IndexedDBDriverOptions}; diff --git a/packages/@core/base/shared/src/cache/local-storage-driver.ts b/packages/@core/base/shared/src/cache/local-storage-driver.ts new file mode 100644 index 000000000..6026246ea --- /dev/null +++ b/packages/@core/base/shared/src/cache/local-storage-driver.ts @@ -0,0 +1,71 @@ +import type {IStorageDriver} from './types'; + +type StorageType = 'localStorage' | 'sessionStorage'; + +interface LocalStorageDriverOptions { + /** 使用 localStorage 还是 sessionStorage */ + storageType?: StorageType; +} + +/** + * LocalStorage / SessionStorage 驱动 + * 用 async 包装同步 API,保持接口统一 + */ +class LocalStorageDriver implements IStorageDriver { + private storage: Storage; + + constructor({ + storageType = 'localStorage', + }: LocalStorageDriverOptions = {}) { + if (typeof window === 'undefined') { + // eslint-disable-next-line unicorn/prefer-type-error -- not a type check, it's an environment check + throw new Error( + 'LocalStorageDriver is not available in non-browser environments. Use MemoryStorageDriver instead.', + ); + } + this.storage = + storageType === 'localStorage' + ? window.localStorage + : window.sessionStorage; + } + + async clear(): Promise { + this.storage.clear(); + } + + async getItem(key: string): Promise { + const raw = this.storage.getItem(key); + if (raw === null) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + // 数据损坏,清除并返回 null + this.storage.removeItem(key); + return null; + } + } + + async keys(): Promise { + const result: string[] = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i); + if (key !== null) { + result.push(key); + } + } + return result; + } + + async removeItem(key: string): Promise { + this.storage.removeItem(key); + } + + async setItem(key: string, value: T): Promise { + this.storage.setItem(key, JSON.stringify(value)); + } +} + +export {LocalStorageDriver}; +export type {LocalStorageDriverOptions}; diff --git a/packages/@core/base/shared/src/cache/memory-storage-driver.ts b/packages/@core/base/shared/src/cache/memory-storage-driver.ts new file mode 100644 index 000000000..530c19ef5 --- /dev/null +++ b/packages/@core/base/shared/src/cache/memory-storage-driver.ts @@ -0,0 +1,32 @@ +import type {IStorageDriver} from './types'; + +/** + * 内存存储驱动 + * 适用于测试环境和 SSR 场景,数据不持久化 + */ +class MemoryStorageDriver implements IStorageDriver { + private store = new Map(); + + async clear(): Promise { + this.store.clear(); + } + + async getItem(key: string): Promise { + const value = this.store.get(key); + return (value as T) ?? null; + } + + async keys(): Promise { + return [...this.store.keys()]; + } + + async removeItem(key: string): Promise { + this.store.delete(key); + } + + async setItem(key: string, value: T): Promise { + this.store.set(key, value); + } +} + +export {MemoryStorageDriver}; diff --git a/packages/@core/base/shared/src/cache/storage-manager.ts b/packages/@core/base/shared/src/cache/storage-manager.ts index 4360621e4..ef42aa7f4 100644 --- a/packages/@core/base/shared/src/cache/storage-manager.ts +++ b/packages/@core/base/shared/src/cache/storage-manager.ts @@ -1,53 +1,54 @@ -type StorageType = 'localStorage' | 'sessionStorage'; +import type { + IStorageDriver, + StorageItem, + StorageManagerOptions, +} from './types'; -interface StorageManagerOptions { - prefix?: string; - storageType?: StorageType; -} - -interface StorageItem { - expiry?: number; - value: T; -} +import {LocalStorageDriver} from './local-storage-driver'; +import {MemoryStorageDriver} from './memory-storage-driver'; +/** + * 存储管理器(策略模式) + * - prefix(命名空间隔离)在此层处理 + * - TTL(过期机制)在此层处理 + * - Driver 只负责纯粹的 KV 存取 + */ class StorageManager { + private driver: IStorageDriver; private prefix: string; - private storage: Storage; - constructor({ - prefix = '', - storageType = 'localStorage', - }: StorageManagerOptions = {}) { + constructor({driver, prefix = ''}: StorageManagerOptions = {}) { + this.driver = driver || this.createDefaultDriver(); this.prefix = prefix; - this.storage = - storageType === 'localStorage' - ? window.localStorage - : window.sessionStorage; + if (!this.prefix && this.driver instanceof LocalStorageDriver) { + console.warn( + '[StorageManager] empty prefix combined with LocalStorageDriver — clear()/keys() will affect every localStorage entry.', + ); + } } /** * 清除所有带前缀的存储项 */ - clear(): void { - const keysToRemove: string[] = []; - for (let i = 0; i < this.storage.length; i++) { - const key = this.storage.key(i); - if (key && key.startsWith(this.prefix)) { - keysToRemove.push(key); - } - } - keysToRemove.forEach((key) => this.storage.removeItem(key)); + async clear(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix)); + await Promise.all(prefixedKeys.map((key) => this.driver.removeItem(key))); } /** * 清除所有过期的存储项 */ - clearExpiredItems(): void { - for (let i = 0; i < this.storage.length; i++) { - const key = this.storage.key(i); - if (key && key.startsWith(this.prefix)) { - const shortKey = key.replace(this.prefix, ''); - this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项 + async clearExpiredItems(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + const prefixedKeys = allKeys.filter((key) => key.startsWith(fullPrefix)); + + for (const fullKey of prefixedKeys) { + const raw = await this.driver.getItem>(fullKey); + if (raw && raw.expiry && Date.now() > raw.expiry) { + await this.driver.removeItem(fullKey); } } } @@ -56,36 +57,47 @@ class StorageManager { * 获取存储项 * @param key 键 * @param defaultValue 当项不存在或已过期时返回的默认值 - * @returns 值,如果项已过期或解析错误则返回默认值 + * @returns 值,如果项已过期则返回默认值 */ - getItem(key: string, defaultValue: null | T = null): null | T { + async getItem( + key: string, + defaultValue: null | T = null, + ): Promise { const fullKey = this.getFullKey(key); - const itemStr = this.storage.getItem(fullKey); - if (!itemStr) { + const raw = await this.driver.getItem>(fullKey); + + if (!raw) { return defaultValue; } - try { - const item: StorageItem = JSON.parse(itemStr); - if (item.expiry && Date.now() > item.expiry) { - this.storage.removeItem(fullKey); - return defaultValue; - } - return item.value; - } catch (error) { - console.error(`Error parsing item with key "${fullKey}":`, error); - this.storage.removeItem(fullKey); // 如果解析失败,删除该项 + // TTL 检查 + if (raw.expiry && Date.now() > raw.expiry) { + await this.driver.removeItem(fullKey); return defaultValue; } + + return raw.value; + } + + /** + * 获取当前前缀下的所有存储键(已去除前缀部分) + */ + async keys(): Promise { + const allKeys = await this.driver.keys(); + const fullPrefix = this.prefix ? `${this.prefix}-` : ''; + if (!fullPrefix) return allKeys; + return allKeys + .filter((key) => key.startsWith(fullPrefix)) + .map((key) => key.slice(fullPrefix.length)); } /** * 移除存储项 * @param key 键 */ - removeItem(key: string): void { + async removeItem(key: string): Promise { const fullKey = this.getFullKey(key); - this.storage.removeItem(fullKey); + await this.driver.removeItem(fullKey); } /** @@ -94,24 +106,40 @@ class StorageManager { * @param value 值 * @param ttl 存活时间(毫秒) */ - setItem(key: string, value: T, ttl?: number): void { + async setItem(key: string, value: T, ttl?: number): Promise { const fullKey = this.getFullKey(key); const expiry = ttl ? Date.now() + ttl : undefined; const item: StorageItem = { expiry, value }; - try { - this.storage.setItem(fullKey, JSON.stringify(item)); - } catch (error) { - console.error(`Error setting item with key "${fullKey}":`, error); - } + await this.driver.setItem(fullKey, item); } /** - * 获取完整的存储键 + * 根据运行环境创建默认驱动: + * - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver + * - SSR / Node 环境 → MemoryStorageDriver + */ + private createDefaultDriver(): IStorageDriver { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return new LocalStorageDriver(); + } + } catch (error) { + // localStorage access denied (e.g. Safari private mode) + console.warn( + 'localStorage is not accessible, falling back to MemoryStorageDriver:', + error, + ); + } + return new MemoryStorageDriver(); + } + + /** + * 获取完整的存储键(带前缀) * @param key 原始键 * @returns 带前缀的完整键 */ private getFullKey(key: string): string { - return `${this.prefix}-${key}`; + return this.prefix ? `${this.prefix}-${key}` : key; } } diff --git a/packages/@core/base/shared/src/cache/types.ts b/packages/@core/base/shared/src/cache/types.ts index d8939208e..c967223a6 100644 --- a/packages/@core/base/shared/src/cache/types.ts +++ b/packages/@core/base/shared/src/cache/types.ts @@ -1,17 +1,39 @@ -type StorageType = 'localStorage' | 'sessionStorage'; +/** + * 存储驱动接口(策略模式核心抽象) + * 所有存储实现(localStorage、IndexedDB、Memory 等)都需要实现此接口 + * Driver 层只负责纯粹的 KV 存取,不感知 TTL 和前缀 + */ +interface IStorageDriver { + /** 清除所有存储项 */ + clear(): Promise; -interface StorageValue { - data: T; - expiry: null | number; + /** 获取存储项 */ + getItem(key: string): Promise; + + /** 获取所有 key */ + keys(): Promise; + + /** 移除存储项 */ + removeItem(key: string): Promise; + + /** 设置存储项 */ + setItem(key: string, value: T): Promise; } -interface IStorageCache { - clear(): void; - getItem(key: string): null | T; - key(index: number): null | string; - length(): number; - removeItem(key: string): void; - setItem(key: string, value: T, expiryInMinutes?: number): void; +/** + * 带 TTL 的存储项包装结构 + * TTL 逻辑由 StorageManager 统一管理,Driver 层不感知 + */ +interface StorageItem { + expiry?: number; + value: T; } -export type { IStorageCache, StorageType, StorageValue }; +interface StorageManagerOptions { + /** 存储驱动实例 */ + driver?: IStorageDriver; + /** 键前缀,用于命名空间隔离 */ + prefix?: string; +} + +export type {IStorageDriver, StorageItem, StorageManagerOptions}; diff --git a/packages/@core/preferences/__tests__/preferences.test.ts b/packages/@core/preferences/__tests__/preferences.test.ts index 84e9844e4..6afdb2182 100644 --- a/packages/@core/preferences/__tests__/preferences.test.ts +++ b/packages/@core/preferences/__tests__/preferences.test.ts @@ -105,7 +105,7 @@ describe('preferences', () => { expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true); }); - it('resets preferences to default', () => { + it('resets preferences to default', async () => { // 先更新一些偏好设置 preferenceManager.updatePreferences({ theme: { @@ -114,7 +114,7 @@ describe('preferences', () => { }); // 然后重置偏好设置 - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); }); @@ -174,7 +174,7 @@ describe('preferences', () => { ); }); - it('resets preferences to default correctly', () => { + it('resets preferences to default correctly', async () => { // 先更新一些偏好设置 preferenceManager.updatePreferences({ app: { locale: 'en-US' }, @@ -185,7 +185,7 @@ describe('preferences', () => { }); // 然后重置偏好设置 - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); }); @@ -377,7 +377,7 @@ describe('preferences', () => { reportTitle: '月报', }); - preferenceManager.resetPreferences(); + await preferenceManager.resetPreferences(); expect(preferenceManager.getCustomPreferences()).toEqual({ pageSize: 20, diff --git a/packages/@core/preferences/src/preferences.ts b/packages/@core/preferences/src/preferences.ts index 7f82bb094..ce1cd17d4 100644 --- a/packages/@core/preferences/src/preferences.ts +++ b/packages/@core/preferences/src/preferences.ts @@ -41,17 +41,19 @@ class PreferenceManager { constructor() { this.cache = new StorageManager(); - this.state = reactive( - this.loadFromCache() || { ...defaultPreferences }, - ); + // 构造函数不再同步读取缓存,使用默认值初始化 + // 真正的缓存加载在 initPreferences 中完成(已经是 async) + this.state = reactive({...defaultPreferences}); this.debouncedSave = useDebounceFn(() => this.saveToCache(), 150); } /** * 清除所有缓存的偏好设置 */ - clearCache = () => { - Object.values(STORAGE_KEYS).forEach((key) => this.cache.removeItem(key)); + clearCache = async () => { + await Promise.all( + Object.values(STORAGE_KEYS).map((key) => this.cache.removeItem(key)), + ); }; /** @@ -130,7 +132,7 @@ class PreferenceManager { ); // 加载缓存的偏好设置并与初始配置合并 - const cachedPreferences = this.loadFromCache() || {}; + const cachedPreferences = (await this.loadFromCache()) || {}; const mergedPreference = merge( {}, cachedPreferences, @@ -139,14 +141,16 @@ class PreferenceManager { // 更新偏好设置 this.updatePreferences(mergedPreference); + + const cachedCustom = (await this.loadCustomFromCache()) || {}; this.replaceCustomPreferences( merge( {}, - this.sanitizeCustomPreferences(this.loadCustomFromCache() || {}), + this.sanitizeCustomPreferences(cachedCustom), this.initialCustomPreferences, ), ); - this.saveToCache(); + await this.saveToCache(); // 设置监听器 this.setupWatcher(); @@ -160,13 +164,13 @@ class PreferenceManager { /** * 重置偏好设置到初始状态 */ - resetPreferences = () => { + resetPreferences = async () => { // 将状态重置为初始偏好设置 Object.assign(this.state, this.initialPreferences); this.replaceCustomPreferences(this.initialCustomPreferences); // 保存偏好设置至缓存 - this.saveToCache(); + await this.saveToCache(); // 直接触发 UI 更新 this.handleUpdates(this.state); @@ -211,7 +215,7 @@ class PreferenceManager { // 根据更新的值执行更新 this.handleUpdates(updates); - // 保存到缓存 + // 保存到缓存(fire-and-forget,通过 debounce 控制频率) this.debouncedSave(); }; @@ -320,7 +324,7 @@ class PreferenceManager { * 从缓存加载扩展偏好设置 * @returns 缓存的扩展偏好设置,如果不存在则返回 null */ - private loadCustomFromCache(): CustomPreferencesRecord | null { + private async loadCustomFromCache(): Promise { return this.cache.getItem(STORAGE_KEYS.CUSTOM); } @@ -328,7 +332,7 @@ class PreferenceManager { * 从缓存加载偏好设置 * @returns 缓存的偏好设置,如果不存在则返回 null */ - private loadFromCache(): null | Preferences { + private async loadFromCache(): Promise { return this.cache.getItem(STORAGE_KEYS.MAIN); } @@ -387,17 +391,23 @@ class PreferenceManager { /** * 保存偏好设置到缓存 */ - private saveToCache() { - this.cache.setItem(STORAGE_KEYS.MAIN, this.state); - this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale); - this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode); + private async saveToCache() { + try { + await this.cache.setItem(STORAGE_KEYS.MAIN, this.state); + await this.cache.setItem(STORAGE_KEYS.LOCALE, this.state.app.locale); + await this.cache.setItem(STORAGE_KEYS.THEME, this.state.theme.mode); - if (this.customPreferencesExtension) { - this.cache.setItem(STORAGE_KEYS.CUSTOM, { ...this.customState }); - return; + if (this.customPreferencesExtension) { + await this.cache.setItem(STORAGE_KEYS.CUSTOM, { + ...this.customState, + }); + return; + } + + await this.cache.removeItem(STORAGE_KEYS.CUSTOM); + } catch (error) { + console.error('Failed to save preferences to cache:', error); } - - this.cache.removeItem(STORAGE_KEYS.CUSTOM); } /** diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue index 9dc784334..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'; @@ -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); @@ -190,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); } @@ -314,14 +342,16 @@ defineExpose({ :class="{ 'rotate-90': expanded?.length > 0 }" class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition" /> - +
+ + {{ selectAllLabel }} +
@@ -369,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" > +
-
+
*/ async function loadSvgIcons() { + if ( + typeof DOMParser === 'undefined' || + typeof Node === 'undefined' || + typeof XMLSerializer === 'undefined' + ) { + return; + } + const svgEagers = import.meta.glob('./icons/**', { eager: true, query: '?raw', diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 9f88ebd37..ebf085ffa 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/playground/src/locales/langs/en-US/examples.json @@ -38,7 +38,8 @@ "editCell": "Edit Cell", "editRow": "Edit Row", "custom-cell": "Custom Cell", - "form": "Form Table" + "form": "Form Table", + "viewed": "Row Marker" }, "captcha": { "title": "Captcha", diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index e260016ec..1a2b6a83c 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -41,7 +41,8 @@ "editCell": "单元格编辑", "editRow": "行编辑", "custom-cell": "自定义单元格", - "form": "搜索表单" + "form": "搜索表单", + "viewed": "行标记" }, "captcha": { "title": "验证码", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index 38f25e2d3..ff6b00a30 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/playground/src/router/routes/modules/examples.ts @@ -193,6 +193,14 @@ const routes: RouteRecordRaw[] = [ title: $t('examples.vxeTable.virtual'), }, }, + { + name: 'VxeTableViewedExample', + path: '/examples/vxe-table/viewed', + component: () => import('#/views/examples/vxe-table/viewed.vue'), + meta: { + title: $t('examples.vxeTable.viewed'), + }, + }, ], }, { diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue new file mode 100644 index 000000000..e535fe939 --- /dev/null +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -0,0 +1,209 @@ + + + diff --git a/scripts/deploy/build-local-docker-image.sh b/scripts/deploy/build-local-docker-image.sh index 4881487f4..b6372810a 100755 --- a/scripts/deploy/build-local-docker-image.sh +++ b/scripts/deploy/build-local-docker-image.sh @@ -13,7 +13,7 @@ function stop_and_remove_container() { function remove_image() { # Remove the existing image - docker rmi vben-admin-pro >/dev/null 2>&1 + docker rmi ${IMAGE_NAME} >/dev/null 2>&1 } function install_dependencies() {