feat: 表格已读行操作标记
parent
51e8b27d4c
commit
e1f6449073
|
|
@ -46,6 +46,16 @@ export class VxeGridApi<
|
||||||
|
|
||||||
private stateHandler: StateHandler;
|
private stateHandler: StateHandler;
|
||||||
|
|
||||||
|
// 已读行相关方法(由 use-vxe-grid.vue 注入)
|
||||||
|
private viewedRowHelper: null | {
|
||||||
|
clearViewed: () => void;
|
||||||
|
isViewed: (record: T) => boolean;
|
||||||
|
markAsViewed: (record: T) => void;
|
||||||
|
markKeysAsViewed: (keys: Array<number | string>) => void;
|
||||||
|
removeKeys: (keys: Array<number | string>) => void;
|
||||||
|
viewedSet: { value: Set<number | string> };
|
||||||
|
} = null;
|
||||||
|
|
||||||
constructor(options: VxeGridProps<T, D, P> = {} as VxeGridProps<T, D, P>) {
|
constructor(options: VxeGridProps<T, D, P> = {} as VxeGridProps<T, D, P>) {
|
||||||
const storeState = { ...options };
|
const storeState = { ...options };
|
||||||
|
|
||||||
|
|
@ -64,6 +74,41 @@ export class VxeGridApi<
|
||||||
bindMethods(this);
|
bindMethods(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有已读状态
|
||||||
|
*/
|
||||||
|
clearViewedRows() {
|
||||||
|
this.viewedRowHelper?.clearViewed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已读的 key 集合
|
||||||
|
*/
|
||||||
|
getViewedKeys(): Set<number | string> {
|
||||||
|
return this.viewedRowHelper?.viewedSet.value ?? 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) {
|
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
|
||||||
if (!this.isMounted && instance) {
|
if (!this.isMounted && instance) {
|
||||||
this.grid = instance;
|
this.grid = instance;
|
||||||
|
|
@ -89,6 +134,13 @@ export class VxeGridApi<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定 key 的已读状态
|
||||||
|
*/
|
||||||
|
removeViewedKeys(keys: Array<number | string>) {
|
||||||
|
this.viewedRowHelper?.removeKeys(keys);
|
||||||
|
}
|
||||||
|
|
||||||
setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) {
|
setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) {
|
||||||
this.setState({
|
this.setState({
|
||||||
gridOptions: options,
|
gridOptions: options,
|
||||||
|
|
@ -117,6 +169,14 @@ export class VxeGridApi<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置已读行 helper(由组件内部调用)
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
setViewedRowHelper(helper: VxeGridApi<T, D, P>['viewedRowHelper']) {
|
||||||
|
this.viewedRowHelper = helper;
|
||||||
|
}
|
||||||
|
|
||||||
toggleSearchForm(show?: boolean) {
|
toggleSearchForm(show?: boolean) {
|
||||||
this.setState({
|
this.setState({
|
||||||
showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm,
|
showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm,
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,12 @@
|
||||||
.vxe-grid--layout-body-content-wrapper {
|
.vxe-grid--layout-body-content-wrapper {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 已读行默认样式 */
|
||||||
|
.vxe-row--viewed {
|
||||||
|
color: hsl(var(--foreground) / 50%);
|
||||||
|
|
||||||
|
.vxe-body--column {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
VxeGridListeners,
|
VxeGridListeners,
|
||||||
VxeGridPropTypes,
|
VxeGridPropTypes,
|
||||||
VxeGridProps as VxeTableGridProps,
|
VxeGridProps as VxeTableGridProps,
|
||||||
|
VxeTablePropTypes,
|
||||||
VxeUIExport,
|
VxeUIExport,
|
||||||
} from 'vxe-table';
|
} from 'vxe-table';
|
||||||
|
|
||||||
|
|
@ -38,6 +39,71 @@ export interface SeparatorOptions {
|
||||||
backgroundColor?: string;
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读行持久化配置
|
||||||
|
*/
|
||||||
|
export interface ViewedRowPersistOptions {
|
||||||
|
/**
|
||||||
|
* 存储类型,默认 'localStorage'
|
||||||
|
* - memory: 仅内存,不持久化
|
||||||
|
* - localStorage: 使用 localStorage 整体存储
|
||||||
|
* - sessionStorage: 使用 sessionStorage 整体存储
|
||||||
|
* - indexedDB: 使用 IndexedDB 单条存储(支持单条 TTL)
|
||||||
|
* - custom: 用户自定义存储适配器
|
||||||
|
*/
|
||||||
|
type?: 'custom' | 'indexedDB' | 'localStorage' | 'memory' | 'sessionStorage';
|
||||||
|
/** 存储 key / prefix(type 为 localStorage/sessionStorage/indexedDB 时必传) */
|
||||||
|
key?: string;
|
||||||
|
/** 持久化数据的存活时间(毫秒) */
|
||||||
|
ttl?: number;
|
||||||
|
/** 最大缓存数量,超出时淘汰最早标记的 key(FIFO),默认 100 */
|
||||||
|
maxSize?: number;
|
||||||
|
/** IndexedDB 数据库名称(仅 type='indexedDB' 时生效,默认 'viewed-table-db') */
|
||||||
|
dbName?: string;
|
||||||
|
/** IndexedDB 数据库版本(仅 type='indexedDB' 时生效,默认 1) */
|
||||||
|
dbVersion?: number;
|
||||||
|
/** IndexedDB 对象存储名称(仅 type='indexedDB' 时生效,默认 'viewed-table-row') */
|
||||||
|
storeName?: string;
|
||||||
|
/** 自定义存储适配器(仅 type='custom' 时生效,不传则降级为 memory) */
|
||||||
|
storage?: ViewedRowStorageAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已查看row设置
|
||||||
|
*/
|
||||||
|
export interface ViewedRowOptions<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<
|
export interface VxeGridProps<
|
||||||
T extends Record<string, any> = any,
|
T extends Record<string, any> = any,
|
||||||
D extends BaseFormComponentType = BaseFormComponentType,
|
D extends BaseFormComponentType = BaseFormComponentType,
|
||||||
|
|
@ -83,6 +149,10 @@ export interface VxeGridProps<
|
||||||
* 搜索表单与表格主体之间的分隔条
|
* 搜索表单与表格主体之间的分隔条
|
||||||
*/
|
*/
|
||||||
separator?: boolean | SeparatorOptions;
|
separator?: boolean | SeparatorOptions;
|
||||||
|
/**
|
||||||
|
* 已读行功能
|
||||||
|
*/
|
||||||
|
viewedRow?: boolean | ViewedRowOptions<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtendedVxeGridApi<
|
export type ExtendedVxeGridApi<
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
import type {VxeGridProps as VxeTableGridProps} from 'vxe-table';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ViewedRowOptions,
|
||||||
|
ViewedRowPersistOptions,
|
||||||
|
ViewedRowStorageAdapter,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import {isRef, shallowRef, toRaw, triggerRef, watch} from 'vue';
|
||||||
|
|
||||||
|
import {isBoolean, isFunction} from '@vben/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
IndexedDBDriver,
|
||||||
|
LocalStorageDriver,
|
||||||
|
StorageManager,
|
||||||
|
} from '@vben-core/shared/cache';
|
||||||
|
|
||||||
|
import {useDebounceFn} from '@vueuse/core';
|
||||||
|
|
||||||
|
const DEFAULT_VIEWED_CLASS = 'vxe-row--viewed';
|
||||||
|
|
||||||
|
// ========== 持久化策略 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage / sessionStorage 适配器
|
||||||
|
* 整体存储:key → [1, 2, 3]
|
||||||
|
*/
|
||||||
|
function createWebStorageAdapter(
|
||||||
|
storageType: 'localStorage' | 'sessionStorage',
|
||||||
|
key: string,
|
||||||
|
ttl?: number,
|
||||||
|
): ViewedRowStorageAdapter {
|
||||||
|
const manager = new StorageManager({
|
||||||
|
driver: new LocalStorageDriver({storageType}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getKeys() {
|
||||||
|
const stored = await manager.getItem<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: ViewedRowPersistOptions,
|
||||||
|
): ViewedRowStorageAdapter {
|
||||||
|
const prefix = opts.key || 'viewed';
|
||||||
|
const manager = new StorageManager({
|
||||||
|
driver: new IndexedDBDriver({
|
||||||
|
dbName: opts.dbName || 'viewed-table-db',
|
||||||
|
dbVersion: opts.dbVersion || 1,
|
||||||
|
storeName: opts.storeName || 'viewed-table-row',
|
||||||
|
}),
|
||||||
|
prefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async getKeys() {
|
||||||
|
try {
|
||||||
|
// 通过 StorageManager 的 driver 获取所有 key,再逐条读取(自动过滤过期)
|
||||||
|
const allKeys = (await (manager as any).driver.keys()) as string[];
|
||||||
|
const fullPrefix = prefix ? `${prefix}-` : '';
|
||||||
|
const prefixedKeys = allKeys.filter((k: string) =>
|
||||||
|
k.startsWith(fullPrefix),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: Array<number | string> = [];
|
||||||
|
for (const fullKey of prefixedKeys) {
|
||||||
|
const shortKey = fullKey.replace(fullPrefix, '');
|
||||||
|
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 {
|
||||||
|
// 先清除旧数据,再逐条写入
|
||||||
|
await manager.clear();
|
||||||
|
await Promise.all(
|
||||||
|
keys.map((key) => manager.setItem(String(key), key, opts.ttl)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[viewedRow] indexedDB persist failed:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 persist 配置创建存储适配器
|
||||||
|
*/
|
||||||
|
function createStorageAdapter(
|
||||||
|
persist?: string | ViewedRowPersistOptions,
|
||||||
|
): null | ViewedRowStorageAdapter {
|
||||||
|
if (!persist) return null;
|
||||||
|
|
||||||
|
// 简写模式:string → localStorage
|
||||||
|
if (typeof persist === 'string') {
|
||||||
|
return createWebStorageAdapter('localStorage', persist);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {type = 'localStorage'} = persist;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'custom': {
|
||||||
|
if (!persist.storage) {
|
||||||
|
// 没有提供 storage 适配器,降级为 memory
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 用户自定义适配器,解除 Vue 响应式代理
|
||||||
|
return toRaw(persist.storage);
|
||||||
|
}
|
||||||
|
case 'indexedDB': {
|
||||||
|
if (!persist.key) {
|
||||||
|
console.warn('[viewedRow] persist.key is required for indexedDB type');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createIndexedDBAdapter(persist);
|
||||||
|
}
|
||||||
|
case 'localStorage': {
|
||||||
|
if (!persist.key) {
|
||||||
|
console.warn(
|
||||||
|
'[viewedRow] persist.key is required for localStorage type',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createWebStorageAdapter('localStorage', persist.key, persist.ttl);
|
||||||
|
}
|
||||||
|
case 'memory': {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case 'sessionStorage': {
|
||||||
|
if (!persist.key) {
|
||||||
|
console.warn(
|
||||||
|
'[viewedRow] persist.key is required for sessionStorage type',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createWebStorageAdapter(
|
||||||
|
'sessionStorage',
|
||||||
|
persist.key,
|
||||||
|
persist.ttl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== maxSize 淘汰 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制执行 maxSize 限制,超出时淘汰最早插入的 key(FIFO)
|
||||||
|
*/
|
||||||
|
function enforceMaxSize(set: Set<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);
|
||||||
|
|
||||||
|
// ========== 从存储恢复 ==========
|
||||||
|
function restoreFromStorage() {
|
||||||
|
if (!adapter) return;
|
||||||
|
|
||||||
|
adapter
|
||||||
|
.getKeys()
|
||||||
|
.then((stored) => {
|
||||||
|
if (stored && stored.length > 0) {
|
||||||
|
for (const key of stored) {
|
||||||
|
viewedSet.value.add(key);
|
||||||
|
}
|
||||||
|
if (maxSize > 0) {
|
||||||
|
enforceMaxSize(viewedSet.value, maxSize);
|
||||||
|
}
|
||||||
|
triggerRef(viewedSet);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[viewedRow] restore failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreFromStorage();
|
||||||
|
|
||||||
|
// 合并外部传入的 viewedKeys
|
||||||
|
if (options.viewedKeys) {
|
||||||
|
const keys = isRef(options.viewedKeys)
|
||||||
|
? options.viewedKeys.value
|
||||||
|
: options.viewedKeys;
|
||||||
|
for (const key of keys) {
|
||||||
|
viewedSet.value.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 更新 viewedSet 的统一入口 ==========
|
||||||
|
function updateViewedSet(updater: (set: Set<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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具函数 ==========
|
||||||
|
|
||||||
|
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
|
||||||
|
const originalRowClassName = mergedOptions.rowClassName;
|
||||||
|
mergedOptions.rowClassName = (params: any) => {
|
||||||
|
return mergeClassNames(
|
||||||
|
isFunction(originalRowClassName)
|
||||||
|
? originalRowClassName(params)
|
||||||
|
: originalRowClassName,
|
||||||
|
helper.getRowClassName(params),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注入 rowStyle
|
||||||
|
const originalRowStyle = mergedOptions.rowStyle;
|
||||||
|
mergedOptions.rowStyle = (params: any) => {
|
||||||
|
const viewedStyle = helper.getRowStyle(params);
|
||||||
|
const originalStyle = isFunction(originalRowStyle)
|
||||||
|
? originalRowStyle(params)
|
||||||
|
: originalRowStyle;
|
||||||
|
if (!viewedStyle && !originalStyle) return undefined;
|
||||||
|
if (!originalStyle) return viewedStyle;
|
||||||
|
if (!viewedStyle) return originalStyle;
|
||||||
|
return {...originalStyle, ...viewedStyle};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拦截 CellOperation columns
|
||||||
|
const actionCodes =
|
||||||
|
!isBoolean(viewedRowConfig) && viewedRowConfig.actionCodes
|
||||||
|
? (Array.isArray(viewedRowConfig.actionCodes)
|
||||||
|
? viewedRowConfig.actionCodes
|
||||||
|
: [viewedRowConfig.actionCodes])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (actionCodes.length > 0 && Array.isArray(mergedOptions.columns)) {
|
||||||
|
mergedOptions.columns = wrapColumnsForViewedRow(
|
||||||
|
mergedOptions.columns,
|
||||||
|
actionCodes,
|
||||||
|
helper.markAsViewed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,10 +19,12 @@ import {
|
||||||
nextTick,
|
nextTick,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
|
shallowRef,
|
||||||
toRaw,
|
toRaw,
|
||||||
useSlots,
|
useSlots,
|
||||||
useTemplateRef,
|
useTemplateRef,
|
||||||
watch,
|
watch,
|
||||||
|
watchEffect,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
import { usePriorityValues } from '@vben/hooks';
|
import { usePriorityValues } from '@vben/hooks';
|
||||||
|
|
@ -44,6 +46,7 @@ import { VxeGrid, VxeUI } from 'vxe-table';
|
||||||
|
|
||||||
import { extendProxyOptions } from './extends';
|
import { extendProxyOptions } from './extends';
|
||||||
import { useTableForm } from './init';
|
import { useTableForm } from './init';
|
||||||
|
import {applyViewedRowOptions, useViewedRow} from './use-viewed-row';
|
||||||
|
|
||||||
import 'vxe-table/styles/cssvar.scss';
|
import 'vxe-table/styles/cssvar.scss';
|
||||||
import 'vxe-pc-ui/styles/cssvar.scss';
|
import 'vxe-pc-ui/styles/cssvar.scss';
|
||||||
|
|
@ -76,8 +79,45 @@ const {
|
||||||
tableTitleHelp,
|
tableTitleHelp,
|
||||||
showSearchForm,
|
showSearchForm,
|
||||||
separator,
|
separator,
|
||||||
|
viewedRow,
|
||||||
} = usePriorityValues(props, state);
|
} = usePriorityValues(props, state);
|
||||||
|
|
||||||
|
// ========== 已读行:响应 viewedRow 配置变化 ==========
|
||||||
|
const defaultKeyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id';
|
||||||
|
|
||||||
|
const viewedRowHelper = shallowRef<null | ReturnType<typeof useViewedRow>>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化 + 监听配置变化时重建 helper
|
||||||
|
watch(
|
||||||
|
viewedRow,
|
||||||
|
(cfg) => {
|
||||||
|
if (!cfg) {
|
||||||
|
viewedRowHelper.value = null;
|
||||||
|
props.api?.setViewedRowHelper?.(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedOptions = isBoolean(cfg)
|
||||||
|
? {keyField: defaultKeyField}
|
||||||
|
: {keyField: defaultKeyField, ...cfg};
|
||||||
|
viewedRowHelper.value = useViewedRow(resolvedOptions);
|
||||||
|
// 同步更新 API 中的 helper 引用
|
||||||
|
if (props.api?.setViewedRowHelper) {
|
||||||
|
props.api.setViewedRowHelper(viewedRowHelper.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
);
|
||||||
|
|
||||||
|
// viewedSet 变化时,主动刷新 grid 行样式
|
||||||
|
watchEffect(() => {
|
||||||
|
const helper = viewedRowHelper.value;
|
||||||
|
if (!helper) return;
|
||||||
|
// 访问 viewedSet.value 建立依赖追踪
|
||||||
|
void helper.viewedSet.value;
|
||||||
|
});
|
||||||
|
|
||||||
const { isMobile } = usePreferences();
|
const { isMobile } = usePreferences();
|
||||||
const isSeparator = computed(() => {
|
const isSeparator = computed(() => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -234,6 +274,16 @@ const options = computed(() => {
|
||||||
mergedOptions.data = tableData.value;
|
mergedOptions.data = tableData.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注入已读行功能(rowClassName、rowStyle、columns 拦截)
|
||||||
|
if (viewedRow.value && viewedRowHelper.value) {
|
||||||
|
applyViewedRowOptions(
|
||||||
|
mergedOptions,
|
||||||
|
viewedRow.value,
|
||||||
|
viewedRowHelper.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return mergedOptions;
|
return mergedOptions;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@
|
||||||
"editCell": "Edit Cell",
|
"editCell": "Edit Cell",
|
||||||
"editRow": "Edit Row",
|
"editRow": "Edit Row",
|
||||||
"custom-cell": "Custom Cell",
|
"custom-cell": "Custom Cell",
|
||||||
"form": "Form Table"
|
"form": "Form Table",
|
||||||
|
"viewed": "Row Marker"
|
||||||
},
|
},
|
||||||
"captcha": {
|
"captcha": {
|
||||||
"title": "Captcha",
|
"title": "Captcha",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@
|
||||||
"editCell": "单元格编辑",
|
"editCell": "单元格编辑",
|
||||||
"editRow": "行编辑",
|
"editRow": "行编辑",
|
||||||
"custom-cell": "自定义单元格",
|
"custom-cell": "自定义单元格",
|
||||||
"form": "搜索表单"
|
"form": "搜索表单",
|
||||||
|
"viewed": "行标记"
|
||||||
},
|
},
|
||||||
"captcha": {
|
"captcha": {
|
||||||
"title": "验证码",
|
"title": "验证码",
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,14 @@ const routes: RouteRecordRaw[] = [
|
||||||
title: $t('examples.vxeTable.virtual'),
|
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,178 @@
|
||||||
|
<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: 'name',
|
||||||
|
},
|
||||||
|
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: 'name',
|
||||||
|
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,
|
||||||
|
viewedRow: {
|
||||||
|
// 触发已读的操作码(点击编辑时标记为已读)
|
||||||
|
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});
|
||||||
|
setTimeout(() => {
|
||||||
|
editRow.value && gridApi.markRowAsViewed(editRow.value);
|
||||||
|
modalApi.setState({loading: false});
|
||||||
|
modalApi.close();
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onEdit(row: RowType) {
|
||||||
|
editRow.value = row;
|
||||||
|
modalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onView(row: RowType) {
|
||||||
|
message.success({
|
||||||
|
content: `查看${row.category}`,
|
||||||
|
key: 'action_process_msg_id',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCustomSet() {
|
||||||
|
gridApi.markKeysAsViewed([
|
||||||
|
'0da74a21-362d-42ba-9c7e-078e47477620',
|
||||||
|
'1c7785d9-f16b-448b-b6a2-fb4b3557550a',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 type="primary" @click="onClearViewed"> 清空缓存</Button>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
Loading…
Reference in New Issue