Merge branch 'main' into antdv-next
commit
b6aeadd9a2
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@vben/layouts": patch
|
||||
---
|
||||
|
||||
fix: update primary color when toggling dark/light mode with custom theme
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@vben/common-ui": patch
|
||||
---
|
||||
|
||||
fix: skip fixed footer height in auto-content-height calculation
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@vben/icons": patch
|
||||
---
|
||||
|
||||
fix: guard svg icon loading during docs SSR
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@vben-core/shadcn-ui": patch
|
||||
---
|
||||
|
||||
fix: preserve tree default value when treeData starts empty
|
||||
|
|
@ -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。
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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};
|
||||
|
|
@ -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 };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* 带 TTL 的存储项包装结构
|
||||
* TTL 逻辑由 StorageManager 统一管理,Driver 层不感知
|
||||
*/
|
||||
interface StorageItem<T> {
|
||||
expiry?: number;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export type { IStorageCache, StorageType, StorageValue };
|
||||
interface StorageManagerOptions {
|
||||
/** 存储驱动实例 */
|
||||
driver?: IStorageDriver;
|
||||
/** 键前缀,用于命名空间隔离 */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export type {IStorageDriver, StorageItem, StorageManagerOptions};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export interface TreeProps {
|
|||
labelField?: string;
|
||||
/** 是否多选 */
|
||||
multiple?: boolean;
|
||||
/** 选择全部时的文字 */
|
||||
selectAllLabel?: string;
|
||||
/** 显示由iconField指定的图标 */
|
||||
showIcon?: boolean;
|
||||
/** 启用展开收缩动画 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
({ 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;
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const error = validateFile(file, imageUpload);
|
||||
if (error) {
|
||||
handleUploadError(new Error(error), imageUpload);
|
||||
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'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@
|
|||
"editCell": "单元格编辑",
|
||||
"editRow": "行编辑",
|
||||
"custom-cell": "自定义单元格",
|
||||
"form": "搜索表单"
|
||||
"form": "搜索表单",
|
||||
"viewed": "行标记"
|
||||
},
|
||||
"captcha": {
|
||||
"title": "验证码",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue