Kaynağa Gözat

feat: 表格已读行操作标记

layhuts 1 ay önce
ebeveyn
işleme
e1f6449073

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

@@ -46,6 +46,16 @@ export class VxeGridApi<
 
   private stateHandler: StateHandler;
 
+  // 已读行相关方法(由 use-vxe-grid.vue 注入)
+  private viewedRowHelper: null | {
+    clearViewed: () => void;
+    isViewed: (record: T) => boolean;
+    markAsViewed: (record: T) => void;
+    markKeysAsViewed: (keys: Array<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>) {
     const storeState = { ...options };
 
@@ -64,6 +74,41 @@ export class VxeGridApi<
     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) {
     if (!this.isMounted && 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']>) {
     this.setState({
       gridOptions: options,
@@ -117,6 +169,14 @@ export class VxeGridApi<
     }
   }
 
+  /**
+   * 设置已读行 helper(由组件内部调用)
+   * @internal
+   */
+  setViewedRowHelper(helper: VxeGridApi<T, D, P>['viewedRowHelper']) {
+    this.viewedRowHelper = helper;
+  }
+
   toggleSearchForm(show?: boolean) {
     this.setState({
       showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm,

+ 9 - 0
packages/effects/plugins/src/vxe-table/style.css

@@ -117,3 +117,12 @@
 .vxe-grid--layout-body-content-wrapper {
   overflow: hidden;
 }
+
+/* 已读行默认样式 */
+.vxe-row--viewed {
+  color: hsl(var(--foreground) / 50%);
+
+  .vxe-body--column {
+    opacity: 0.9;
+  }
+}

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

@@ -2,6 +2,7 @@ import type {
   VxeGridListeners,
   VxeGridPropTypes,
   VxeGridProps as VxeTableGridProps,
+  VxeTablePropTypes,
   VxeUIExport,
 } from 'vxe-table';
 
@@ -38,6 +39,71 @@ 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>;
+}
+
+/**
+ * 已读行持久化配置
+ */
+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<
   T extends Record<string, any> = any,
   D extends BaseFormComponentType = BaseFormComponentType,
@@ -83,6 +149,10 @@ export interface VxeGridProps<
    * 搜索表单与表格主体之间的分隔条
    */
   separator?: boolean | SeparatorOptions;
+  /**
+   * 已读行功能
+   */
+  viewedRow?: boolean | ViewedRowOptions<T>;
 }
 
 export type ExtendedVxeGridApi<

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

@@ -0,0 +1,503 @@
+import type {VxeGridProps as VxeTableGridProps} from 'vxe-table';
+
+import type {
+  ViewedRowOptions,
+  ViewedRowPersistOptions,
+  ViewedRowStorageAdapter,
+} from './types';
+
+import {isRef, shallowRef, toRaw, triggerRef, watch} from 'vue';
+
+import {isBoolean, isFunction} from '@vben/utils';
+
+import {
+  IndexedDBDriver,
+  LocalStorageDriver,
+  StorageManager,
+} from '@vben-core/shared/cache';
+
+import {useDebounceFn} from '@vueuse/core';
+
+const DEFAULT_VIEWED_CLASS = 'vxe-row--viewed';
+
+// ========== 持久化策略 ==========
+
+/**
+ * localStorage / sessionStorage 适配器
+ * 整体存储:key → [1, 2, 3]
+ */
+function createWebStorageAdapter(
+  storageType: 'localStorage' | 'sessionStorage',
+  key: string,
+  ttl?: number,
+): ViewedRowStorageAdapter {
+  const manager = new StorageManager({
+    driver: new LocalStorageDriver({storageType}),
+  });
+
+  return {
+    async getKeys() {
+      const stored = await manager.getItem<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,
+    );
+  }
+}

+ 50 - 0
packages/effects/plugins/src/vxe-table/use-vxe-grid.vue

@@ -19,10 +19,12 @@ import {
   nextTick,
   onMounted,
   onUnmounted,
+  shallowRef,
   toRaw,
   useSlots,
   useTemplateRef,
   watch,
+  watchEffect,
 } from 'vue';
 
 import { usePriorityValues } from '@vben/hooks';
@@ -44,6 +46,7 @@ import { VxeGrid, VxeUI } from 'vxe-table';
 
 import { extendProxyOptions } from './extends';
 import { useTableForm } from './init';
+import {applyViewedRowOptions, useViewedRow} from './use-viewed-row';
 
 import 'vxe-table/styles/cssvar.scss';
 import 'vxe-pc-ui/styles/cssvar.scss';
@@ -76,8 +79,45 @@ const {
   tableTitleHelp,
   showSearchForm,
   separator,
+  viewedRow,
 } = usePriorityValues(props, state);
 
+// ========== 已读行:响应 viewedRow 配置变化 ==========
+const defaultKeyField = (gridOptions.value?.rowConfig as any)?.keyField || 'id';
+
+const viewedRowHelper = shallowRef<null | 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 isSeparator = computed(() => {
   if (
@@ -234,6 +274,16 @@ const options = computed(() => {
       mergedOptions.data = tableData.value;
     }
   }
+
+  // 注入已读行功能(rowClassName、rowStyle、columns 拦截)
+  if (viewedRow.value && viewedRowHelper.value) {
+    applyViewedRowOptions(
+      mergedOptions,
+      viewedRow.value,
+      viewedRowHelper.value,
+    );
+  }
+
   return mergedOptions;
 });
 

+ 2 - 1
playground/src/locales/langs/en-US/examples.json

@@ -38,7 +38,8 @@
     "editCell": "Edit Cell",
     "editRow": "Edit Row",
     "custom-cell": "Custom Cell",
-    "form": "Form Table"
+    "form": "Form Table",
+    "viewed": "Row Marker"
   },
   "captcha": {
     "title": "Captcha",

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

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

+ 8 - 0
playground/src/router/routes/modules/examples.ts

@@ -193,6 +193,14 @@ const routes: RouteRecordRaw[] = [
               title: $t('examples.vxeTable.virtual'),
             },
           },
+          {
+            name: 'VxeTableViewedExample',
+            path: '/examples/vxe-table/viewed',
+            component: () => import('#/views/examples/vxe-table/viewed.vue'),
+            meta: {
+              title: $t('examples.vxeTable.viewed'),
+            },
+          },
         ],
       },
       {

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

@@ -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>