Sfoglia il codice sorgente

Merge branch 'antdv-next' of https://github.com/xingyu4j/vue-vben-admin into antdv-next

xingyu4j 3 settimane fa
parent
commit
1867015e97
32 ha cambiato i file con 1954 aggiunte e 224 eliminazioni
  1. 5 0
      .changeset/element-plus-theme-switch.md
  2. 5 0
      .changeset/page-auto-content-height.md
  3. 5 0
      .changeset/small-moons-hunt.md
  4. 5 0
      .changeset/tree-default-value.md
  5. 422 0
      packages/@core/base/shared/src/cache/README.md
  6. 56 63
      packages/@core/base/shared/src/cache/__tests__/storage-manager.test.ts
  7. 4 0
      packages/@core/base/shared/src/cache/index.ts
  8. 137 0
      packages/@core/base/shared/src/cache/indexeddb-driver.ts
  9. 71 0
      packages/@core/base/shared/src/cache/local-storage-driver.ts
  10. 32 0
      packages/@core/base/shared/src/cache/memory-storage-driver.ts
  11. 83 55
      packages/@core/base/shared/src/cache/storage-manager.ts
  12. 34 12
      packages/@core/base/shared/src/cache/types.ts
  13. 5 5
      packages/@core/preferences/__tests__/preferences.test.ts
  14. 32 22
      packages/@core/preferences/src/preferences.ts
  15. 59 13
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue
  16. 2 0
      packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts
  17. 5 2
      packages/effects/common-ui/src/components/page/page.vue
  18. 6 0
      packages/effects/common-ui/src/components/page/types.ts
  19. 2 4
      packages/effects/layouts/src/widgets/preferences/blocks/theme/builtin.vue
  20. 3 3
      packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue
  21. 36 39
      packages/effects/plugins/src/tiptap/extensions.ts
  22. 50 0
      packages/effects/plugins/src/vxe-table/api.ts
  23. 9 0
      packages/effects/plugins/src/vxe-table/style.css
  24. 86 0
      packages/effects/plugins/src/vxe-table/types.ts
  25. 536 0
      packages/effects/plugins/src/vxe-table/use-viewed-row.ts
  26. 34 3
      packages/effects/plugins/src/vxe-table/use-vxe-grid.vue
  27. 8 0
      packages/icons/src/svg/load.ts
  28. 2 1
      playground/src/locales/langs/en-US/examples.json
  29. 2 1
      playground/src/locales/langs/zh-CN/examples.json
  30. 8 0
      playground/src/router/routes/modules/examples.ts
  31. 209 0
      playground/src/views/examples/vxe-table/viewed.vue
  32. 1 1
      scripts/deploy/build-local-docker-image.sh

+ 5 - 0
.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

+ 5 - 0
.changeset/page-auto-content-height.md

@@ -0,0 +1,5 @@
+---
+"@vben/common-ui": patch
+---
+
+fix: skip fixed footer height in auto-content-height calculation

+ 5 - 0
.changeset/small-moons-hunt.md

@@ -0,0 +1,5 @@
+---
+"@vben/icons": patch
+---
+
+fix: guard svg icon loading during docs SSR

+ 5 - 0
.changeset/tree-default-value.md

@@ -0,0 +1,5 @@
+---
+"@vben-core/shadcn-ui": patch
+---
+
+fix: preserve tree default value when treeData starts empty

+ 422 - 0
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<T>(key: string, defaultValue?: T \| null): Promise<T \| null>` | 获取存储项,过期或不存在返回默认值 |
+| `setItem`           | `setItem<T>(key: string, value: T, ttl?: number): Promise<void>`        | 设置存储项,可选 TTL(毫秒)  |
+| `removeItem`        | `removeItem(key: string): Promise<void>`                                | 删除指定存储项           |
+| `clear`             | `clear(): Promise<void>`                                                | 清除当前前缀下所有存储项      |
+| `clearExpiredItems` | `clearExpiredItems(): Promise<void>`                                    | 主动清理所有过期项         |
+
+---
+
+### IStorageDriver 接口
+
+自定义驱动需要实现此接口:
+
+```typescript
+interface IStorageDriver {
+  clear(): Promise<void>;
+
+  getItem<T>(key: string): Promise<null | T>;
+
+  keys(): Promise<string[]>;
+
+  removeItem(key: string): Promise<void>;
+
+  setItem<T>(key: string, value: T): Promise<void>;
+}
+```
+
+---
+
+## 高级用法
+
+### 自定义 Driver
+
+```typescript
+import type {IStorageDriver} from '@vben-core/shared/cache';
+
+class CookieStorageDriver implements IStorageDriver {
+  async getItem<T>(key: string): Promise<null | T> {
+    const value = getCookie(key);
+    return value ? JSON.parse(value) : null;
+  }
+
+  async setItem<T>(key: string, value: T): Promise<void> {
+    setCookie(key, JSON.stringify(value));
+  }
+
+  async removeItem(key: string): Promise<void> {
+    deleteCookie(key);
+  }
+
+  async clear(): Promise<void> {
+    clearAllCookies();
+  }
+
+  async keys(): Promise<string[]> {
+    return getAllCookieNames();
+  }
+}
+
+// 使用自定义 Driver
+const cache = new StorageManager({
+  driver: new CookieStorageDriver(),
+  prefix: 'ck',
+});
+```
+
+### 根据环境动态选择 Driver
+
+```typescript
+import {
+  IndexedDBDriver,
+  LocalStorageDriver,
+  MemoryStorageDriver,
+  StorageManager,
+} from '@vben-core/shared/cache';
+
+function createStorageManager(prefix: string) {
+  // SSR 环境使用内存驱动
+  if (typeof window === 'undefined') {
+    return new StorageManager({
+      driver: new MemoryStorageDriver(),
+      prefix,
+    });
+  }
+
+  // 大数据场景使用 IndexedDB
+  if (needsLargeStorage()) {
+    return new StorageManager({
+      driver: new IndexedDBDriver({ dbName: `${prefix}-db` }),
+      prefix,
+    });
+  }
+
+  // 默认使用 localStorage
+  return new StorageManager({ prefix });
+}
+```
+
+### 命名空间隔离
+
+```typescript
+// 不同模块使用不同前缀,互不干扰
+const userCache = new StorageManager({prefix: 'user'});
+const configCache = new StorageManager({prefix: 'config'});
+
+await userCache.setItem('profile', {name: '张三'});
+await configCache.setItem('profile', {theme: 'dark'});
+
+// 各自独立
+await userCache.getItem('profile'); // => { name: '张三' }
+await configCache.getItem('profile'); // => { theme: 'dark' }
+
+// 只清除 user 前缀的数据
+await userCache.clear();
+await configCache.getItem('profile'); // => { theme: 'dark' }(不受影响)
+```
+
+### 定时清理过期数据
+
+```typescript
+const cache = new StorageManager({ prefix: 'app' });
+
+// 应用启动时清理一次
+await cache.clearExpiredItems();
+
+// 或者定时清理(每 10 分钟)
+setInterval(
+  async () => {
+    await cache.clearExpiredItems();
+  },
+  10 * 60 * 1000,
+);
+```
+
+---
+
+## 数据存储格式
+
+`StorageManager` 在 Driver 层存储的数据结构为:
+
+```typescript
+interface StorageItem<T> {
+  expiry?: number; // 过期时间戳(毫秒),undefined 表示永不过期
+  value: T; // 实际业务数据
+}
+```
+
+实际存储的 key 格式为:`{prefix}-{key}`
+
+例如 `prefix = 'app'`,`key = 'user'`,则实际存储键为 `app-user`。
+
+---
+
+## 过期策略
+
+采用**惰性删除 + 主动清理**双重策略:
+
+| 策略   | 触发时机                     | 说明                  |
+|------|--------------------------|---------------------|
+| 惰性删除 | 调用 `getItem` 时           | 读取时检查过期,过期则删除并返回默认值 |
+| 主动清理 | 调用 `clearExpiredItems` 时 | 遍历所有带前缀的 key,删除已过期项 |
+
+---
+
+## 各 Driver 对比
+
+| 特性    | LocalStorageDriver | IndexedDBDriver | MemoryStorageDriver |
+|-------|--------------------|-----------------|---------------------|
+| 持久化   | ✅                  | ✅               | ❌                   |
+| 容量    | 5-10 MB            | 数百 MB+          | 受内存限制               |
+| 速度    | 快(同步)              | 中等(异步 I/O)      | 最快                  |
+| 数据类型  | 仅 JSON 可序列化        | 结构化克隆           | 任意 JS 对象            |
+| 浏览器支持 | 所有现代浏览器            | 所有现代浏览器         | 任意环境                |
+| 阻塞主线程 | 是                  | 否               | 否                   |
+| 适用场景  | 配置、Token、小数据       | 离线缓存、大数据        | 测试、SSR              |
+
+---
+
+## 在项目中的使用
+
+本项目中 `StorageManager` 主要被 `PreferenceManager` 消费,用于持久化用户偏好设置:
+
+```typescript
+// packages/@core/preferences/src/preferences.ts
+class PreferenceManager {
+  private cache: StorageManager;
+
+  constructor() {
+    this.cache = new StorageManager();
+    this.state = reactive<Preferences>({...defaultPreferences});
+  }
+
+  initPreferences = async ({namespace}) => {
+    // 用应用命名空间重新初始化
+    this.cache = new StorageManager({prefix: namespace});
+
+    // 从缓存加载偏好设置
+    const cached = await this.cache.getItem<Preferences>('preferences');
+    // ...
+  };
+}
+```
+
+---
+
+## 注意事项
+
+1. **所有方法都是异步的** — 即使底层是同步的 localStorage,API 也返回 Promise,确保切换 Driver
+   时无需改动调用方。
+
+2. **TTL 单位是毫秒** — `setItem('key', value, 60000)` 表示 60 秒后过期。
+
+3. **IndexedDB 懒初始化** — 不需要手动调用 `init()` 或 `open()`,首次操作时自动打开数据库连接并复用。
+
+4. **前缀隔离是逻辑隔离** — `clear()` 只清除当前前缀下的数据,不影响其他前缀或无前缀的数据。
+
+5. **错误处理** — LocalStorageDriver 在 JSON 解析失败时自动清除损坏数据;
+   `PreferenceManager.saveToCache` 内部 try-catch 防止未捕获异常。
+
+6. **IndexedDB 版本升级** — 如果需要修改 objectStore 结构,需要递增 `dbVersion`。当前实现在
+   `upgradeneeded` 事件中自动创建 objectStore。

+ 56 - 63
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();
   });

+ 4 - 0
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';

+ 137 - 0
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<IDBDatabase> = null;
+  private dbVersion: number;
+  private storeName: string;
+
+  constructor({
+                dbName = 'vben-storage',
+                dbVersion = 1,
+                storeName = 'kv-store',
+              }: IndexedDBDriverOptions = {}) {
+    this.dbName = dbName;
+    this.dbVersion = dbVersion;
+    this.storeName = storeName;
+  }
+
+  async clear(): Promise<void> {
+    const db = await this.getDB();
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction(this.storeName, 'readwrite');
+      const store = tx.objectStore(this.storeName);
+      store.clear();
+
+      tx.addEventListener('complete', () => resolve());
+      tx.addEventListener('error', () => reject(tx.error));
+      tx.addEventListener('abort', () =>
+        reject(tx.error ?? new Error('Transaction aborted')),
+      );
+    });
+  }
+
+  async getItem<T>(key: string): Promise<null | T> {
+    const db = await this.getDB();
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction(this.storeName, 'readonly');
+      const store = tx.objectStore(this.storeName);
+      const request = store.get(key);
+
+      request.addEventListener('success', () =>
+        resolve(request.result ?? null),
+      );
+      request.addEventListener('error', () => reject(request.error));
+    });
+  }
+
+  async keys(): Promise<string[]> {
+    const db = await this.getDB();
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction(this.storeName, 'readonly');
+      const store = tx.objectStore(this.storeName);
+      const request = store.getAllKeys();
+
+      request.addEventListener('success', () =>
+        resolve(request.result.map(String)),
+      );
+      request.addEventListener('error', () => reject(request.error));
+    });
+  }
+
+  async removeItem(key: string): Promise<void> {
+    const db = await this.getDB();
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction(this.storeName, 'readwrite');
+      const store = tx.objectStore(this.storeName);
+      store.delete(key);
+
+      tx.addEventListener('complete', () => resolve());
+      tx.addEventListener('error', () => reject(tx.error));
+      tx.addEventListener('abort', () =>
+        reject(tx.error ?? new Error('Transaction aborted')),
+      );
+    });
+  }
+
+  async setItem<T>(key: string, value: T): Promise<void> {
+    const db = await this.getDB();
+    return new Promise((resolve, reject) => {
+      const tx = db.transaction(this.storeName, 'readwrite');
+      const store = tx.objectStore(this.storeName);
+      store.put(value, key);
+
+      tx.addEventListener('complete', () => resolve());
+      tx.addEventListener('error', () => reject(tx.error));
+      tx.addEventListener('abort', () =>
+        reject(tx.error ?? new Error('Transaction aborted')),
+      );
+    });
+  }
+
+  /**
+   * 懒初始化:首次调用时打开数据库,后续复用同一个 Promise
+   */
+  private getDB(): Promise<IDBDatabase> {
+    if (!this.dbPromise) {
+      this.dbPromise = this.openDB().catch((error) => {
+        // allow retry on next call
+        this.dbPromise = null;
+        throw error;
+      });
+    }
+    return this.dbPromise;
+  }
+
+  private openDB(): Promise<IDBDatabase> {
+    return new Promise((resolve, reject) => {
+      const request = indexedDB.open(this.dbName, this.dbVersion);
+
+      request.addEventListener('upgradeneeded', () => {
+        const db = request.result;
+        if (!db.objectStoreNames.contains(this.storeName)) {
+          db.createObjectStore(this.storeName);
+        }
+      });
+
+      request.addEventListener('success', () => resolve(request.result));
+      request.addEventListener('error', () => reject(request.error));
+    });
+  }
+}
+
+export {IndexedDBDriver};
+export type {IndexedDBDriverOptions};

+ 71 - 0
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<void> {
+    this.storage.clear();
+  }
+
+  async getItem<T>(key: string): Promise<null | T> {
+    const raw = this.storage.getItem(key);
+    if (raw === null) {
+      return null;
+    }
+    try {
+      return JSON.parse(raw) as T;
+    } catch {
+      // 数据损坏,清除并返回 null
+      this.storage.removeItem(key);
+      return null;
+    }
+  }
+
+  async keys(): Promise<string[]> {
+    const result: string[] = [];
+    for (let i = 0; i < this.storage.length; i++) {
+      const key = this.storage.key(i);
+      if (key !== null) {
+        result.push(key);
+      }
+    }
+    return result;
+  }
+
+  async removeItem(key: string): Promise<void> {
+    this.storage.removeItem(key);
+  }
+
+  async setItem<T>(key: string, value: T): Promise<void> {
+    this.storage.setItem(key, JSON.stringify(value));
+  }
+}
+
+export {LocalStorageDriver};
+export type {LocalStorageDriverOptions};

+ 32 - 0
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<string, unknown>();
+
+  async clear(): Promise<void> {
+    this.store.clear();
+  }
+
+  async getItem<T>(key: string): Promise<null | T> {
+    const value = this.store.get(key);
+    return (value as T) ?? null;
+  }
+
+  async keys(): Promise<string[]> {
+    return [...this.store.keys()];
+  }
+
+  async removeItem(key: string): Promise<void> {
+    this.store.delete(key);
+  }
+
+  async setItem<T>(key: string, value: T): Promise<void> {
+    this.store.set(key, value);
+  }
+}
+
+export {MemoryStorageDriver};

+ 83 - 55
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<T> {
-  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<void> {
+    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<void> {
+    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<StorageItem<unknown>>(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<T>(key: string, defaultValue: null | T = null): null | T {
+  async getItem<T>(
+    key: string,
+    defaultValue: null | T = null,
+  ): Promise<null | T> {
     const fullKey = this.getFullKey(key);
-    const itemStr = this.storage.getItem(fullKey);
-    if (!itemStr) {
+    const raw = await this.driver.getItem<StorageItem<T>>(fullKey);
+
+    if (!raw) {
       return defaultValue;
     }
 
-    try {
-      const item: StorageItem<T> = 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<string[]> {
+    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<void> {
     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<T>(key: string, value: T, ttl?: number): void {
+  async setItem<T>(key: string, value: T, ttl?: number): Promise<void> {
     const fullKey = this.getFullKey(key);
     const expiry = ttl ? Date.now() + ttl : undefined;
     const item: StorageItem<T> = { expiry, value };
+    await this.driver.setItem(fullKey, item);
+  }
+
+  /**
+   * 根据运行环境创建默认驱动:
+   * - 浏览器环境(window.localStorage 可用)→ LocalStorageDriver
+   * - SSR / Node 环境 → MemoryStorageDriver
+   */
+  private createDefaultDriver(): IStorageDriver {
     try {
-      this.storage.setItem(fullKey, JSON.stringify(item));
+      if (typeof window !== 'undefined' && window.localStorage) {
+        return new LocalStorageDriver();
+      }
     } catch (error) {
-      console.error(`Error setting item with key "${fullKey}":`, 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;
   }
 }
 

+ 34 - 12
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<void>;
 
-interface StorageValue<T> {
-  data: T;
-  expiry: null | number;
+  /** 获取存储项 */
+  getItem<T>(key: string): Promise<null | T>;
+
+  /** 获取所有 key */
+  keys(): Promise<string[]>;
+
+  /** 移除存储项 */
+  removeItem(key: string): Promise<void>;
+
+  /** 设置存储项 */
+  setItem<T>(key: string, value: T): Promise<void>;
+}
+
+/**
+ * 带 TTL 的存储项包装结构
+ * TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
+ */
+interface StorageItem<T> {
+  expiry?: number;
+  value: T;
 }
 
-interface IStorageCache {
-  clear(): void;
-  getItem<T>(key: string): null | T;
-  key(index: number): null | string;
-  length(): number;
-  removeItem(key: string): void;
-  setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
+interface StorageManagerOptions {
+  /** 存储驱动实例 */
+  driver?: IStorageDriver;
+  /** 键前缀,用于命名空间隔离 */
+  prefix?: string;
 }
 
-export type { IStorageCache, StorageType, StorageValue };
+export type {IStorageDriver, StorageItem, StorageManagerOptions};

+ 5 - 5
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,

+ 32 - 22
packages/@core/preferences/src/preferences.ts

@@ -41,17 +41,19 @@ class PreferenceManager {
 
   constructor() {
     this.cache = new StorageManager();
-    this.state = reactive<Preferences>(
-      this.loadFromCache() || { ...defaultPreferences },
-    );
+    // 构造函数不再同步读取缓存,使用默认值初始化
+    // 真正的缓存加载在 initPreferences 中完成(已经是 async)
+    this.state = reactive<Preferences>({...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<CustomPreferencesRecord | null> {
     return this.cache.getItem<CustomPreferencesRecord>(STORAGE_KEYS.CUSTOM);
   }
 
@@ -328,7 +332,7 @@ class PreferenceManager {
    * 从缓存加载偏好设置
    * @returns 缓存的偏好设置,如果不存在则返回 null
    */
-  private loadFromCache(): null | Preferences {
+  private async loadFromCache(): Promise<null | Preferences> {
     return this.cache.getItem<Preferences>(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) {
+        await this.cache.setItem(STORAGE_KEYS.CUSTOM, {
+          ...this.customState,
+        });
+        return;
+      }
 
-    if (this.customPreferencesExtension) {
-      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);
   }
 
   /**

+ 59 - 13
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<Recordable<any>>) {
   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<Recordable<any>>) {
   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"
         />
-        <Checkbox
-          v-if="multiple"
-          @click.stop
-          @update:model-value="
-            (checked: boolean | 'indeterminate') =>
-              checked === true ? checkAll() : unCheckAll()
-          "
-        />
+        <div class="flex items-center gap-1 item-all-checkbox">
+          <Checkbox
+            v-if="multiple"
+            :model-value="selectAllStatus"
+            :indeterminate="selectAllStatus === 'indeterminate'"
+            @click.stop
+            @update:model-value="onSelectAllChange"
+          />
+          <span v-if="selectAllLabel">{{ selectAllLabel }}</span>
+        </div>
       </div>
     </div>
     <TransitionGroup :name="transition ? 'fade' : ''">
@@ -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"
       >
+        <!-- class="hover:ring-2" 鼠标移动上去时2px的圆环边框 -->
         <ChevronRight
           v-if="
             item.hasChildren &&
@@ -387,7 +418,7 @@ defineExpose({
           "
         />
         <div v-else class="h-4 w-4"></div>
-        <div class="flex items-center gap-1">
+        <div class="flex items-center gap-1 item-checkbox">
           <Checkbox
             v-if="multiple"
             :model-value="isSelected && !isNodeDisabled(item)"
@@ -405,7 +436,8 @@ defineExpose({
             "
           />
           <div
-            class="flex items-center gap-1"
+            class="flex items-center gap-1 item-checkbox"
+            :title="get(item.value, labelField)"
             @click="
               (event: MouseEvent) => {
                 if (isNodeDisabled(item)) {
@@ -455,6 +487,20 @@ defineExpose({
   border: 1px solid #666;
 }
 
+.item-checkbox {
+  width: 100%;
+  overflow: hidden;
+}
+
+.item-all-checkbox {
+  width: 100%;
+  overflow: hidden;
+
+  .text-label {
+    margin-left: 8px;
+  }
+}
+
 /* 1. 声明过渡效果 */
 .fade-move,
 .fade-enter-active,

+ 2 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/types.ts

@@ -31,6 +31,8 @@ export interface TreeProps {
   labelField?: string;
   /** 是否多选 */
   multiple?: boolean;
+  /** 选择全部时的文字 */
+  selectAllLabel?: string;
   /** 显示由iconField指定的图标 */
   showIcon?: boolean;
   /** 启用展开收缩动画 */

+ 5 - 2
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<PageProps>();
 
 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);

+ 6 - 0
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;
 }

+ 2 - 4
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;
     }
   },
 );

+ 3 - 3
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);
 }
 

+ 36 - 39
packages/effects/plugins/src/tiptap/extensions.ts

@@ -9,14 +9,12 @@ import { $t } from '@vben/locales';
 
 import { alert } from '@vben-core/popup-ui';
 
-import Document from '@tiptap/extension-document';
 import Highlight from '@tiptap/extension-highlight';
 import Image from '@tiptap/extension-image';
 import Link from '@tiptap/extension-link';
 import Placeholder from '@tiptap/extension-placeholder';
 import TextAlign from '@tiptap/extension-text-align';
 import { Color, TextStyle } from '@tiptap/extension-text-style';
-import Underline from '@tiptap/extension-underline';
 import { Plugin, PluginKey } from '@tiptap/pm/state';
 import StarterKit from '@tiptap/starter-kit';
 
@@ -274,30 +272,30 @@ function createCustomImage(
         ...this.parent?.(),
         uploadImage:
           () =>
-          ({ editor: cmdEditor }: { editor: CoreEditor }) => {
-            const input = document.createElement('input');
-            input.type = 'file';
-            input.accept = imageUpload.accept ?? DEFAULT_ACCEPT;
-            input.style.display = 'none';
-
-            input.addEventListener('change', () => {
-              const file = input.files?.[0];
-              if (!file) return;
-
-              const error = validateFile(file, imageUpload);
-              if (error) {
-                handleUploadError(new Error(error), imageUpload);
-                return;
-              }
+            ({ editor: cmdEditor }: { editor: CoreEditor }) => {
+              const input = document.createElement('input');
+              input.type = 'file';
+              input.accept = imageUpload.accept ?? DEFAULT_ACCEPT;
+              input.style.display = 'none';
+
+              input.addEventListener('change', () => {
+                const file = input.files?.[0];
+                if (!file) return;
+
+                const error = validateFile(file, imageUpload);
+                if (error) {
+                  handleUploadError(new Error(error), imageUpload);
+                  return;
+                }
 
-              createUploadProcess(cmdEditor, file, imageUpload, blobUrlTracker);
-              input.remove();
-            });
+                createUploadProcess(cmdEditor, file, imageUpload, blobUrlTracker);
+                input.remove();
+              });
 
-            document.body.append(input);
-            input.click();
-            return true;
-          },
+              document.body.append(input);
+              input.click();
+              return true;
+            },
       };
     },
 
@@ -405,13 +403,12 @@ export function createDefaultTiptapExtensions(
   options: VbenTiptapExtensionOptions = {},
 ): Extensions {
   return [
-    Document,
     StarterKit.configure({
       heading: {
         levels: [1, 2, 3, 4],
       },
+      link: false,
     }),
-    Underline,
     TextAlign.configure({
       types: ['heading', 'paragraph'],
     }),
@@ -431,20 +428,20 @@ export function createDefaultTiptapExtensions(
     }),
     options.imageUpload
       ? createCustomImage(
-          options.imageUpload,
-          options._blobUrlTracker,
-        ).configure({
-          allowBase64: true,
-          HTMLAttributes: {
-            class: 'vben-tiptap__image',
-          },
-        })
+        options.imageUpload,
+        options._blobUrlTracker,
+      ).configure({
+        allowBase64: true,
+        HTMLAttributes: {
+          class: 'vben-tiptap__image',
+        },
+      })
       : Image.configure({
-          allowBase64: true,
-          HTMLAttributes: {
-            class: 'vben-tiptap__image',
-          },
-        }),
+        allowBase64: true,
+        HTMLAttributes: {
+          class: 'vben-tiptap__image',
+        },
+      }),
     Placeholder.configure({
       placeholder: options.placeholder ?? $t('ui.tiptap.placeholder'),
     }),

+ 50 - 0
packages/effects/plugins/src/vxe-table/api.ts

@@ -6,6 +6,7 @@ import type {
 } from '@vben-core/form-ui';
 
 import type { VxeGridProps } from './types';
+import type {ViewedRowHelper} from './use-viewed-row';
 
 import { toRaw } from 'vue';
 
@@ -42,6 +43,11 @@ export class VxeGridApi<
 
   public store: Store<VxeGridProps<T, D, P>>;
 
+  /**
+   * 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中)
+   */
+  public viewedRowHelper: null | ViewedRowHelper<T> = null;
+
   private isMounted = false;
 
   private stateHandler: StateHandler;
@@ -64,6 +70,42 @@ export class VxeGridApi<
     bindMethods(this);
   }
 
+  /**
+   * 清除所有已读状态
+   */
+  clearViewedRows() {
+    this.viewedRowHelper?.clearViewed();
+  }
+
+  /**
+   * 获取所有已读的 key 集合(返回副本,避免外部修改内部状态)
+   */
+  getViewedKeys(): Set<number | string> {
+    const raw = this.viewedRowHelper?.viewedSet.value;
+    return raw ? new Set(raw) : new Set();
+  }
+
+  /**
+   * 判断某行是否已读
+   */
+  isRowViewed(record: T): boolean {
+    return this.viewedRowHelper?.isViewed(record) ?? false;
+  }
+
+  /**
+   * 批量标记行为已读
+   */
+  markKeysAsViewed(keys: Array<number | string>) {
+    this.viewedRowHelper?.markKeysAsViewed(keys);
+  }
+
+  /**
+   * 标记某行为已读
+   */
+  markRowAsViewed(record: T) {
+    this.viewedRowHelper?.markAsViewed(record);
+  }
+
   mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
     if (!this.isMounted && instance) {
       this.grid = instance;
@@ -89,6 +131,13 @@ export class VxeGridApi<
     }
   }
 
+  /**
+   * 移除指定 key 的已读状态
+   */
+  removeViewedKeys(keys: Array<number | string>) {
+    this.viewedRowHelper?.removeKeys(keys);
+  }
+
   setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) {
     this.setState({
       gridOptions: options,
@@ -130,5 +179,6 @@ export class VxeGridApi<
   unmount() {
     this.isMounted = false;
     this.stateHandler.reset();
+    this.viewedRowHelper = null;
   }
 }

+ 9 - 0
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;
+  }
+}

+ 86 - 0
packages/effects/plugins/src/vxe-table/types.ts

@@ -2,6 +2,7 @@ import type {
   VxeGridListeners,
   VxeGridPropTypes,
   VxeGridProps as VxeTableGridProps,
+  VxeTablePropTypes,
   VxeUIExport,
 } from 'vxe-table';
 
@@ -38,6 +39,87 @@ export interface SeparatorOptions {
   backgroundColor?: string;
 }
 
+/**
+ * 自定义存储适配器接口
+ * 用户可接入任意后端(API、IndexedDB wrapper、第三方库等)
+ */
+export interface ViewedRowStorageAdapter {
+  /** 读取所有已查看的 key 列表 */
+  getKeys(): Promise<Array<number | string>>;
+
+  /** 移除所有已查看数据 */
+  removeKeys(): Promise<void>;
+
+  /** 持久化已查看的 key 列表 */
+  setKeys(keys: Array<number | string>): Promise<void>;
+}
+
+/**
+ * 已读行持久化 — 公共基础字段
+ */
+interface ViewedRowPersistBase {
+  /** 持久化数据的存活时间(毫秒) */
+  ttl?: number;
+  /** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */
+  maxSize?: number;
+}
+
+/**
+ * 已读行持久化配置(按 type 区分的联合类型)
+ *
+ * - 'memory'          → 仅内存,不持久化
+ * - 'localStorage'    → 使用 localStorage 整体存储,key 必传
+ * - 'sessionStorage'  → 使用 sessionStorage 整体存储,key 必传
+ * - 'indexedDB'       → 使用 IndexedDB 单条存储,key 必传
+ * - 'custom'          → 用户自定义存储适配器,storage 必传
+ */
+export type ViewedRowPersistOptions =
+  | ({
+  /** IndexedDB 数据库名称,默认 'viewed-table-db' */
+  dbName?: string;
+  /** IndexedDB 数据库版本,默认 1 */
+  dbVersion?: number;
+  /** 存储 key / prefix(必传) */
+  key: string;
+  /** IndexedDB 对象存储名称,默认 'viewed-table-row' */
+  storeName?: string;
+  type: 'indexedDB';
+} & ViewedRowPersistBase)
+  | ({
+  /** 存储 key(必传) */
+  key: string;
+  type: 'localStorage' | 'sessionStorage';
+} & ViewedRowPersistBase)
+  | ({
+  /** 自定义存储适配器(必传) */
+  storage: ViewedRowStorageAdapter;
+  type: 'custom';
+} & ViewedRowPersistBase)
+  | (ViewedRowPersistBase & {
+  type: 'memory';
+});
+
+/**
+ * 已查看row设置
+ */
+export interface ViewedRowOptions<T = any> {
+  /** 点击 CellOperation 中匹配的 code 时,自动将该行标记为已读 */
+  actionCodes?: string | string[];
+  /** 行唯一标识字段,默认取 gridOptions.rowConfig.keyField,最终兜底 'id' */
+  keyField?: string;
+  /** 已查看的行key列表 */
+  viewedKeys?: Array<number | string> | Ref<Array<number | string>>;
+  /**
+   * 持久化配置
+   * - 传 string:使用内置 localStorage,值为 storage key(向后兼容)
+   * - 传 object:高级配置
+   * - 不传:不持久化(等同于 memory)
+   */
+  persist?: string | ViewedRowPersistOptions;
+  rowClassName?: VxeTablePropTypes.RowClassName<T>;
+  rowStyle?: VxeTablePropTypes.RowStyle<T>;
+}
+
 export interface VxeGridProps<
   T extends Record<string, any> = any,
   D extends BaseFormComponentType = BaseFormComponentType,
@@ -83,6 +165,10 @@ export interface VxeGridProps<
    * 搜索表单与表格主体之间的分隔条
    */
   separator?: boolean | SeparatorOptions;
+  /**
+   * 已读行功能
+   */
+  viewedRowOptions?: boolean | ViewedRowOptions<T>;
 }
 
 export type ExtendedVxeGridApi<

+ 536 - 0
packages/effects/plugins/src/vxe-table/use-viewed-row.ts

@@ -0,0 +1,536 @@
+import type {VxeGridProps as VxeTableGridProps} from 'vxe-table';
+
+import type {
+  ViewedRowOptions,
+  ViewedRowPersistOptions,
+  ViewedRowStorageAdapter,
+} from './types';
+
+import {isRef, shallowRef, toRaw, triggerRef, watch} from 'vue';
+
+import {isBoolean, isFunction} from '@vben/utils';
+
+import {
+  IndexedDBDriver,
+  LocalStorageDriver,
+  StorageManager,
+} from '@vben-core/shared/cache';
+
+import {useDebounceFn} from '@vueuse/core';
+
+const DEFAULT_VIEWED_CLASS = 'vxe-row--viewed';
+
+// ========== 持久化策略 ==========
+
+/**
+ * localStorage / sessionStorage 适配器
+ * 整体存储:key → [1, 2, 3]
+ */
+function createWebStorageAdapter(
+  storageType: 'localStorage' | 'sessionStorage',
+  key: string,
+  ttl?: number,
+): ViewedRowStorageAdapter {
+  const manager = new StorageManager({
+    driver: new LocalStorageDriver({storageType}),
+  });
+
+  return {
+    async getKeys() {
+      const stored = await manager.getItem<Array<number | string>>(key);
+      return stored ?? [];
+    },
+    async removeKeys() {
+      await manager.removeItem(key);
+    },
+    async setKeys(keys) {
+      await manager.setItem(key, keys, ttl);
+    },
+  };
+}
+
+/**
+ * IndexedDB 适配器
+ * 单条存储:prefix:1 → { expiry, value: 1 }
+ */
+function createIndexedDBAdapter(
+  opts: Extract<ViewedRowPersistOptions, { type: 'indexedDB' }>,
+): ViewedRowStorageAdapter {
+  const prefix = opts.key;
+  const manager = new StorageManager({
+    driver: new IndexedDBDriver({
+      dbName: opts.dbName || 'viewed-table-db',
+      dbVersion: opts.dbVersion || 1,
+      storeName: opts.storeName || 'viewed-table-row',
+    }),
+    prefix,
+  });
+
+  return {
+    async getKeys() {
+      try {
+        // 通过 StorageManager 获取当前前缀下所有 key,再逐条读取(自动过滤过期)
+        const shortKeys = await manager.keys();
+
+        const results: Array<number | string> = [];
+        for (const shortKey of shortKeys) {
+          const value = await manager.getItem<number | string>(shortKey);
+          if (value !== null) {
+            results.push(value);
+          }
+        }
+        return results;
+      } catch (error) {
+        console.error('[viewedRow] indexedDB restore failed:', error);
+        return [];
+      }
+    },
+    async removeKeys() {
+      try {
+        await manager.clear();
+      } catch (error) {
+        console.error('[viewedRow] indexedDB clear failed:', error);
+      }
+    },
+    async setKeys(keys) {
+      try {
+        const newKeySet = new Set(keys.map(String));
+        // 获取已存在的 key,避免重复写入刷新过期时间
+        const existingKeys = await manager.keys();
+        const existingKeySet = new Set(existingKeys);
+
+        // 只写入新增的 key,不覆盖已有记录的过期时间
+        const toAdd = keys.filter((key) => !existingKeySet.has(String(key)));
+        if (toAdd.length > 0) {
+          await Promise.all(
+            toAdd.map((key) => manager.setItem(String(key), key, opts.ttl)),
+          );
+        }
+
+        // 清理不在新集合中的旧 key
+        const toRemove = existingKeys.filter((k) => !newKeySet.has(k));
+        if (toRemove.length > 0) {
+          await Promise.all(toRemove.map((k) => manager.removeItem(k)));
+        }
+      } catch (error) {
+        console.error('[viewedRow] indexedDB persist failed:', error);
+      }
+    },
+  };
+}
+
+/**
+ * 根据 persist 配置创建存储适配器
+ */
+function createStorageAdapter(
+  persist?: string | ViewedRowPersistOptions,
+): null | ViewedRowStorageAdapter {
+  if (!persist) return null;
+
+  // 简写模式:string → localStorage
+  if (typeof persist === 'string') {
+    return createWebStorageAdapter('localStorage', persist);
+  }
+
+  switch (persist.type) {
+    case 'custom': {
+      // 用户自定义适配器,解除 Vue 响应式代理
+      return toRaw(persist.storage);
+    }
+    case 'indexedDB': {
+      return createIndexedDBAdapter(persist);
+    }
+    case 'localStorage': {
+      return createWebStorageAdapter('localStorage', persist.key, persist.ttl);
+    }
+    case 'memory': {
+      return null;
+    }
+    case 'sessionStorage': {
+      return createWebStorageAdapter(
+        'sessionStorage',
+        persist.key,
+        persist.ttl,
+      );
+    }
+    default: {
+      return null;
+    }
+  }
+}
+
+// ========== maxSize 淘汰 ==========
+
+/**
+ * 强制执行 maxSize 限制,超出时淘汰最早插入的 key(FIFO)
+ */
+function enforceMaxSize(set: Set<number | string>, maxSize: number): void {
+  if (maxSize > 0 && set.size > maxSize) {
+    const iterator = set.values();
+    while (set.size > maxSize) {
+      const oldest = iterator.next().value;
+      if (oldest !== undefined) {
+        set.delete(oldest);
+      }
+    }
+  }
+}
+
+// ========== 核心 composable ==========
+
+export function useViewedRow<T = any>(
+  options: ViewedRowOptions<T> & { keyField: string },
+) {
+  // ========== 解析持久化配置 ==========
+  const persistOpts: null | ViewedRowPersistOptions = options.persist
+    ? (typeof options.persist === 'string'
+      ? {key: options.persist, type: 'localStorage'}
+      : options.persist)
+    : null;
+
+  const adapter = createStorageAdapter(options.persist);
+  const maxSize = persistOpts?.maxSize ?? 100;
+
+  // ========== 初始化已读集合 ==========
+  const viewedSet = shallowRef<Set<number | string>>(new Set());
+
+  // ========== 持久化(防抖) ==========
+  function persistImmediate() {
+    if (!adapter) return;
+    adapter.setKeys([...viewedSet.value]).catch((error) => {
+      console.error('[viewedRow] persist failed:', error);
+    });
+  }
+
+  const persist = useDebounceFn(persistImmediate, 300);
+
+  // ========== 从存储恢复 ==========
+  async function restoreFromStorage(): Promise<void> {
+    if (!adapter) return;
+
+    try {
+      const stored = await adapter.getKeys();
+      if (stored && stored.length > 0) {
+        for (const key of stored) {
+          viewedSet.value.add(key);
+        }
+        if (maxSize > 0) {
+          enforceMaxSize(viewedSet.value, maxSize);
+        }
+        triggerRef(viewedSet);
+      }
+    } catch (error) {
+      console.error('[viewedRow] restore failed:', error);
+    }
+  }
+
+  // 先恢复存储,再合并外部 viewedKeys,确保 viewedKeys 是最新插入的(最后被淘汰)
+  restoreFromStorage().then(() => {
+    if (options.viewedKeys) {
+      const keys = isRef(options.viewedKeys)
+        ? options.viewedKeys.value
+        : options.viewedKeys;
+      updateViewedSet((set) => {
+        let changed = false;
+        for (const key of keys) {
+          if (!set.has(key)) {
+            set.add(key);
+            changed = true;
+          }
+        }
+        return changed;
+      });
+    }
+  });
+
+  // ========== 更新 viewedSet 的统一入口 ==========
+  function updateViewedSet(updater: (set: Set<number | string>) => boolean) {
+    const changed = updater(viewedSet.value);
+
+    if (changed) {
+      if (maxSize > 0) {
+        enforceMaxSize(viewedSet.value, maxSize);
+      }
+      triggerRef(viewedSet);
+      persist();
+    }
+  }
+
+  // ========== 监听外部 viewedKeys 变化(如果是 Ref) ==========
+  if (isRef(options.viewedKeys)) {
+    watch(options.viewedKeys, (newKeys) => {
+      updateViewedSet((set) => {
+        let changed = false;
+        for (const key of newKeys) {
+          if (!set.has(key)) {
+            set.add(key);
+            changed = true;
+          }
+        }
+        return changed;
+      });
+    });
+  }
+
+  // ========== 标记已读 ==========
+  function markAsViewed(record: T) {
+    const key = (record as Record<string, any>)[options.keyField] as
+      | number
+      | string;
+    if (key === null || key === undefined) return;
+
+    updateViewedSet((set) => {
+      if (set.has(key)) return false;
+      set.add(key);
+      return true;
+    });
+  }
+
+  function markKeysAsViewed(keys: Array<number | string>) {
+    updateViewedSet((set) => {
+      let changed = false;
+      for (const key of keys) {
+        if (!set.has(key)) {
+          set.add(key);
+          changed = true;
+        }
+      }
+      return changed;
+    });
+  }
+
+  // ========== 查询 ==========
+  function isViewed(record: T): boolean {
+    const key = (record as Record<string, any>)[options.keyField] as
+      | number
+      | string;
+    return viewedSet.value.has(key);
+  }
+
+  // ========== 清除 ==========
+  function clearViewed() {
+    const hadData = viewedSet.value.size > 0;
+    viewedSet.value.clear();
+
+    if (hadData) {
+      triggerRef(viewedSet);
+    }
+
+    if (adapter) {
+      adapter.removeKeys().catch((error) => {
+        console.error('[viewedRow] clear persist failed:', error);
+      });
+    }
+  }
+
+  // ========== 移除指定 keys ==========
+  function removeKeys(keys: Array<number | string>) {
+    updateViewedSet((set) => {
+      let changed = false;
+      for (const key of keys) {
+        if (set.has(key)) {
+          set.delete(key);
+          changed = true;
+        }
+      }
+      return changed;
+    });
+  }
+
+  // ========== rowClassName 函数 ==========
+  function getRowClassName(params: any): string {
+    if (!isViewed(params.row)) return '';
+
+    const {rowClassName} = options;
+    if (rowClassName === undefined || rowClassName === null) {
+      return DEFAULT_VIEWED_CLASS;
+    }
+    if (typeof rowClassName === 'string') {
+      return rowClassName;
+    }
+    if (isFunction(rowClassName)) {
+      return normalizeClassName(rowClassName(params));
+    }
+    return DEFAULT_VIEWED_CLASS;
+  }
+
+  // ========== rowStyle 函数 ==========
+  function getRowStyle(params: any): any {
+    if (!isViewed(params.row)) return undefined;
+
+    const {rowStyle} = options;
+    if (rowStyle === undefined || rowStyle === null) {
+      return undefined;
+    }
+    if (isFunction(rowStyle)) {
+      return rowStyle(params);
+    }
+    return rowStyle;
+  }
+
+  return {
+    clearViewed,
+    getRowClassName,
+    getRowStyle,
+    isViewed,
+    markAsViewed,
+    markKeysAsViewed,
+    removeKeys,
+    viewedSet,
+  };
+}
+
+export type ViewedRowHelper<T = any> = ReturnType<typeof useViewedRow<T>>;
+
+// ========== 工具函数 ==========
+
+function normalizeClassName(value: any): string {
+  if (!value) return '';
+  if (typeof value === 'string') return value;
+  if (typeof value === 'object') {
+    return Object.entries(value)
+      .filter(([, v]) => v)
+      .map(([k]) => k)
+      .join(' ');
+  }
+  return '';
+}
+
+function mergeClassNames(...classNames: any[]): string {
+  return classNames
+    .map((c) => normalizeClassName(c))
+    .filter(Boolean)
+    .join(' ');
+}
+
+/**
+ * 包装 columns,拦截 CellOperation 的 onClick,根据 actionCodes 自动标记已读
+ * 注意:columns 每次都是 cloneDeep 后的新对象,不存在重复包装问题
+ */
+function wrapColumnsForViewedRow(
+  columns: any[],
+  actionCodes: string[],
+  markAsViewed: (record: any) => void,
+): any[] {
+  return columns.map((column) => {
+    if (!column || typeof column !== 'object') return column;
+
+    const nextColumn = {...column};
+
+    if (nextColumn.cellRender?.name === 'CellOperation') {
+      const cellRender = {...nextColumn.cellRender};
+      const attrs = {...cellRender.attrs};
+      const originalOnClick = attrs.onClick;
+
+      attrs.onClick = (params: { code: string; row: any }) => {
+        originalOnClick?.(params);
+        if (actionCodes.includes(params.code)) {
+          markAsViewed(params.row);
+        }
+      };
+
+      cellRender.attrs = attrs;
+      nextColumn.cellRender = cellRender;
+    }
+
+    if (Array.isArray(nextColumn.children)) {
+      nextColumn.children = wrapColumnsForViewedRow(
+        nextColumn.children,
+        actionCodes,
+        markAsViewed,
+      );
+    }
+
+    return nextColumn;
+  });
+}
+
+/**
+ * 将 viewedRow 配置应用到 mergedOptions 上
+ * 注入 rowClassName、rowStyle、columns 拦截
+ */
+export function applyViewedRowOptions(
+  mergedOptions: VxeTableGridProps,
+  viewedRowConfig: boolean | ViewedRowOptions,
+  helper: ReturnType<typeof useViewedRow>,
+) {
+  // 从最新的配置中读取 rowClassName 和 rowStyle(支持运行时修改)
+  const viewedRowClassName = isBoolean(viewedRowConfig)
+    ? undefined
+    : viewedRowConfig.rowClassName;
+  const viewedRowStyle = isBoolean(viewedRowConfig)
+    ? undefined
+    : viewedRowConfig.rowStyle;
+
+  // 注入 rowClassName
+  const originalRowClassName = mergedOptions.rowClassName;
+  mergedOptions.rowClassName = (params: any) => {
+    if (!helper.isViewed(params.row)) {
+      return normalizeClassName(
+        isFunction(originalRowClassName)
+          ? originalRowClassName(params)
+          : originalRowClassName,
+      );
+    }
+
+    let viewedClass: string;
+    if (viewedRowClassName === undefined || viewedRowClassName === null) {
+      viewedClass = DEFAULT_VIEWED_CLASS;
+    } else if (typeof viewedRowClassName === 'string') {
+      viewedClass = viewedRowClassName;
+    } else if (isFunction(viewedRowClassName)) {
+      viewedClass = normalizeClassName(viewedRowClassName(params));
+    } else {
+      viewedClass = DEFAULT_VIEWED_CLASS;
+    }
+
+    return mergeClassNames(
+      isFunction(originalRowClassName)
+        ? originalRowClassName(params)
+        : originalRowClassName,
+      viewedClass,
+    );
+  };
+
+  // 注入 rowStyle
+  const originalRowStyle = mergedOptions.rowStyle;
+  mergedOptions.rowStyle = (params: any) => {
+    const originalStyle = isFunction(originalRowStyle)
+      ? originalRowStyle(params)
+      : originalRowStyle;
+
+    if (!helper.isViewed(params.row)) {
+      return originalStyle || undefined;
+    }
+
+    let viewedStyle: any;
+    if (viewedRowStyle === undefined || viewedRowStyle === null) {
+      viewedStyle = undefined;
+    } else if (isFunction(viewedRowStyle)) {
+      viewedStyle = viewedRowStyle(params);
+    } else {
+      viewedStyle = viewedRowStyle;
+    }
+
+    if (!viewedStyle && !originalStyle) return undefined;
+    if (!originalStyle) return viewedStyle;
+    if (!viewedStyle) return originalStyle;
+    return {...originalStyle, ...viewedStyle};
+  };
+
+  // 拦截 CellOperation columns
+  const actionCodes =
+    !isBoolean(viewedRowConfig) && viewedRowConfig.actionCodes
+      ? (Array.isArray(viewedRowConfig.actionCodes)
+        ? viewedRowConfig.actionCodes
+        : [viewedRowConfig.actionCodes])
+      : [];
+
+  if (actionCodes.length > 0 && Array.isArray(mergedOptions.columns)) {
+    mergedOptions.columns = wrapColumnsForViewedRow(
+      mergedOptions.columns,
+      actionCodes,
+      helper.markAsViewed,
+    );
+  }
+}

+ 34 - 3
packages/effects/plugins/src/vxe-table/use-vxe-grid.vue

@@ -44,6 +44,7 @@ import { VxeGrid, VxeUI } from 'vxe-table';
 
 import { extendProxyOptions } from './extends';
 import { useTableForm } from './init';
+import {applyViewedRowOptions, useViewedRow} from './use-viewed-row';
 
 import 'vxe-table/styles/cssvar.scss';
 import 'vxe-pc-ui/styles/cssvar.scss';
@@ -76,8 +77,28 @@ const {
   tableTitleHelp,
   showSearchForm,
   separator,
+  viewedRowOptions,
 } = usePriorityValues(props, state);
 
+// viewedRowOptions:helper 只创建一次(persist/keyField 不支持运行时切换)
+// actionCodes、rowClassName、rowStyle、viewedKeys 的变化通过 options computed 自然响应
+const gridApi = props.api;
+
+watch(
+  viewedRowOptions,
+  (cfg) => {
+    // helper 已存在则不重建
+    if (gridApi.viewedRowHelper) return;
+
+    if (!cfg) return;
+
+    const keyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id';
+    const resolved = isBoolean(cfg) ? {keyField} : {keyField, ...cfg};
+    gridApi.viewedRowHelper = useViewedRow(resolved);
+  },
+  {immediate: true},
+);
+
 const { isMobile } = usePreferences();
 const isSeparator = computed(() => {
   if (
@@ -230,10 +251,20 @@ 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;
+  }
+
+  // 注入已读行功能(rowClassName、rowStyle、columns 拦截)
+  if (viewedRowOptions.value && gridApi.viewedRowHelper) {
+    applyViewedRowOptions(
+      mergedOptions,
+      viewedRowOptions.value,
+      gridApi.viewedRowHelper,
+    );
+  }
+
   return mergedOptions;
 });
 

+ 8 - 0
packages/icons/src/svg/load.ts

@@ -53,6 +53,14 @@ function parseSvg(svgData: string): IconifyIconStructure {
  * <Icon icon="svg:avatar"></Icon>
  */
 async function loadSvgIcons() {
+  if (
+    typeof DOMParser === 'undefined' ||
+    typeof Node === 'undefined' ||
+    typeof XMLSerializer === 'undefined'
+  ) {
+    return;
+  }
+
   const svgEagers = import.meta.glob('./icons/**', {
     eager: true,
     query: '?raw',

+ 2 - 1
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",

+ 2 - 1
playground/src/locales/langs/zh-CN/examples.json

@@ -41,7 +41,8 @@
     "editCell": "单元格编辑",
     "editRow": "行编辑",
     "custom-cell": "自定义单元格",
-    "form": "搜索表单"
+    "form": "搜索表单",
+    "viewed": "行标记"
   },
   "captcha": {
     "title": "验证码",

+ 8 - 0
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'),
+            },
+          },
         ],
       },
       {

+ 209 - 0
playground/src/views/examples/vxe-table/viewed.vue

@@ -0,0 +1,209 @@
+<script lang="ts" setup>
+import type {OnActionClickParams, VxeGridProps} from '#/adapter/vxe-table';
+
+import {ref} from 'vue';
+
+import {Page, useVbenModal} from '@vben/common-ui';
+import {$t} from '@vben/locales';
+
+import {Button, message} from 'ant-design-vue';
+
+import {useVbenVxeGrid} from '#/adapter/vxe-table';
+import {getExampleTableApi} from '#/api';
+
+interface RowType {
+  category: string;
+  color: string;
+  id: string;
+  price: string;
+  productName: string;
+  releaseDate: string;
+}
+
+const gridOptions: VxeGridProps<RowType> = {
+  checkboxConfig: {
+    highlight: true,
+    labelField: 'category',
+  },
+  columns: [
+    {title: '序号', type: 'seq', width: 50},
+    {field: 'category', sortable: true, title: 'Category'},
+    {field: 'color', sortable: true, title: 'Color'},
+    {field: 'productName', sortable: true, title: 'Product Name'},
+    {field: 'price', sortable: true, title: 'Price'},
+    {field: 'releaseDate', formatter: 'formatDateTime', title: 'DateTime'},
+    {
+      align: 'center',
+      cellRender: {
+        attrs: {
+          nameField: 'category',
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+        options: [
+          {
+            code: 'view',
+            text: '查看',
+          },
+          'edit',
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: $t('system.menu.operation'),
+      width: 200,
+    },
+  ],
+  exportConfig: {},
+  height: 'auto',
+  keepSource: true,
+  proxyConfig: {
+    ajax: {
+      query: async ({page, sort}) => {
+        return await getExampleTableApi({
+          page: page.currentPage,
+          pageSize: page.pageSize,
+          sortBy: sort.field,
+          sortOrder: sort.order,
+        });
+      },
+    },
+    sort: true,
+  },
+  sortConfig: {
+    defaultSort: {field: 'category', order: 'desc'},
+    remote: true,
+  },
+  toolbarConfig: {
+    custom: true,
+    export: true,
+    // import: true,
+    refresh: true,
+    zoom: true,
+  },
+};
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridOptions,
+  viewedRowOptions: {
+    // 触发已读的操作码
+    actionCodes: ['view'],
+    // 行数据中的唯一标识字段
+    keyField: 'id',
+    // 持久化配置(简写模式,使用内置 localStorage)
+    // persist: 'viewed_rows',
+    persist: {
+      key: 'viewed-rows',
+      type: 'indexedDB',
+      ttl: 7 * 24 * 60 * 60 * 1000, // 7天过期
+      maxSize: 200,
+    },
+  },
+});
+
+function onActionClick({code, row}: OnActionClickParams<RowType>) {
+  switch (code) {
+    case 'edit': {
+      onEdit(row);
+      break;
+    }
+    case 'view': {
+      onView(row);
+      break;
+    }
+    default: {
+      break;
+    }
+  }
+}
+
+const editRow = ref<RowType>();
+const [Modal, modalApi] = useVbenModal({
+  draggable: true,
+  onConfirm: () => {
+    modalApi.setState({loading: true});
+    editRow.value && gridApi.markRowAsViewed(editRow.value);
+    modalApi.setState({loading: false});
+    modalApi.close();
+  },
+});
+
+function onEdit(row: RowType) {
+  editRow.value = row;
+  modalApi.open();
+}
+
+function onView(row: RowType) {
+  message.success({
+    content: `查看${row.category}`,
+    key: 'action_process_msg_id',
+  });
+}
+
+const isStyle = ref(false);
+
+function onStyleSet() {
+  isStyle.value = !isStyle.value;
+  gridApi.setState({
+    viewedRowOptions: {
+      rowStyle: () => {
+        return isStyle.value ? {backgroundColor: 'gray'} : '';
+      },
+    },
+  });
+}
+
+const isClassName = ref(false);
+
+function onClassNameSet() {
+  isClassName.value = !isClassName.value;
+  gridApi.setState({
+    viewedRowOptions: {
+      rowClassName: () => {
+        return isClassName.value
+          ? 'bg-red-100 vxe-row--viewed'
+          : 'vxe-row--viewed';
+      },
+    },
+  });
+}
+
+function onCustomSet() {
+  const tableData = gridApi.grid.getData();
+  const keys = tableData.slice(0, 2).map((row) => row.id);
+  gridApi.markKeysAsViewed(keys);
+}
+
+function onClearViewed() {
+  gridApi.clearViewedRows();
+}
+</script>
+
+<template>
+  <Page
+    auto-content-height
+    description="表格行标记支持存储类型 custom | indexedDB | localStorage | memory | sessionStorage 。
+    默认使用memory存储,当设置custom时需要自己实现getKeys()/setKeys()/removeKeys()。
+    具体属性查看packages/effects/plugins/src/vxe-table/types.ts。可通过gridApi调用
+    clearViewedRows()/getViewedKeys()/isRowViewed()/markKeysAsViewed()/markRowAsViewed()/removeViewedKeys()"
+    title="表格行标记示例"
+  >
+    <Modal class="w-150" title="数据修改"> 数据修改完成后设置行标记</Modal>
+    <Grid table-title="已查看行标记" table-title-help="提示">
+      <template #toolbar-tools>
+        <Button class="mr-2" type="primary" @click="onCustomSet">
+          手动标记
+        </Button>
+        <Button class="mr-2" type="primary" @click="onStyleSet">
+          设置Style
+        </Button>
+        <Button class="mr-2" type="primary" @click="onClassNameSet">
+          设置ClassName
+        </Button>
+        <Button type="primary" @click="onClearViewed"> 清空缓存</Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 1 - 1
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() {