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