From e1f6449073b7d756a4e0985972bcf475ea98ab32 Mon Sep 17 00:00:00 2001 From: layhuts Date: Fri, 8 May 2026 20:03:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A8=E6=A0=BC=E5=B7=B2=E8=AF=BB?= =?UTF-8?q?=E8=A1=8C=E6=93=8D=E4=BD=9C=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/effects/plugins/src/vxe-table/api.ts | 60 +++ .../effects/plugins/src/vxe-table/style.css | 9 + .../effects/plugins/src/vxe-table/types.ts | 70 +++ .../plugins/src/vxe-table/use-viewed-row.ts | 503 ++++++++++++++++++ .../plugins/src/vxe-table/use-vxe-grid.vue | 50 ++ .../src/locales/langs/en-US/examples.json | 3 +- .../src/locales/langs/zh-CN/examples.json | 3 +- .../src/router/routes/modules/examples.ts | 8 + .../src/views/examples/vxe-table/viewed.vue | 178 +++++++ 9 files changed, 882 insertions(+), 2 deletions(-) create mode 100644 packages/effects/plugins/src/vxe-table/use-viewed-row.ts create mode 100644 playground/src/views/examples/vxe-table/viewed.vue diff --git a/packages/effects/plugins/src/vxe-table/api.ts b/packages/effects/plugins/src/vxe-table/api.ts index a2a6870ed..9a52e9697 100644 --- a/packages/effects/plugins/src/vxe-table/api.ts +++ b/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) => void; + removeKeys: (keys: Array) => void; + viewedSet: { value: Set }; + } = null; + constructor(options: VxeGridProps = {} as VxeGridProps) { const storeState = { ...options }; @@ -64,6 +74,41 @@ export class VxeGridApi< bindMethods(this); } + /** + * 清除所有已读状态 + */ + clearViewedRows() { + this.viewedRowHelper?.clearViewed(); + } + + /** + * 获取所有已读的 key 集合 + */ + getViewedKeys(): Set { + return this.viewedRowHelper?.viewedSet.value ?? new Set(); + } + + /** + * 判断某行是否已读 + */ + isRowViewed(record: T): boolean { + return this.viewedRowHelper?.isViewed(record) ?? false; + } + + /** + * 批量标记行为已读 + */ + markKeysAsViewed(keys: Array) { + 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) { + this.viewedRowHelper?.removeKeys(keys); + } + setGridOptions(options: Partial['gridOptions']>) { this.setState({ gridOptions: options, @@ -117,6 +169,14 @@ export class VxeGridApi< } } + /** + * 设置已读行 helper(由组件内部调用) + * @internal + */ + setViewedRowHelper(helper: VxeGridApi['viewedRowHelper']) { + this.viewedRowHelper = helper; + } + toggleSearchForm(show?: boolean) { this.setState({ showSearchForm: isBoolean(show) ? show : !this.state?.showSearchForm, diff --git a/packages/effects/plugins/src/vxe-table/style.css b/packages/effects/plugins/src/vxe-table/style.css index 614f1df43..89d6733dd 100644 --- a/packages/effects/plugins/src/vxe-table/style.css +++ b/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; + } +} diff --git a/packages/effects/plugins/src/vxe-table/types.ts b/packages/effects/plugins/src/vxe-table/types.ts index 6ce5c07cc..4ae435194 100644 --- a/packages/effects/plugins/src/vxe-table/types.ts +++ b/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>; + + /** 移除所有已查看数据 */ + removeKeys(): Promise; + + /** 持久化已查看的 key 列表 */ + setKeys(keys: Array): Promise; +} + +/** + * 已读行持久化配置 + */ +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 { + /** 点击 CellOperation 中匹配的 code 时,自动将该行标记为已读 */ + actionCodes?: string | string[]; + /** 行唯一标识字段,默认取 gridOptions.rowConfig.keyField,最终兜底 'id' */ + keyField?: string; + /** 已查看的行key列表 */ + viewedKeys?: Array | Ref>; + /** + * 持久化配置 + * - 传 string:使用内置 localStorage,值为 storage key(向后兼容) + * - 传 object:高级配置 + * - 不传:不持久化(等同于 memory) + */ + persist?: string | ViewedRowPersistOptions; + rowClassName?: VxeTablePropTypes.RowClassName; + rowStyle?: VxeTablePropTypes.RowStyle; +} + export interface VxeGridProps< T extends Record = any, D extends BaseFormComponentType = BaseFormComponentType, @@ -83,6 +149,10 @@ export interface VxeGridProps< * 搜索表单与表格主体之间的分隔条 */ separator?: boolean | SeparatorOptions; + /** + * 已读行功能 + */ + viewedRow?: boolean | ViewedRowOptions; } export type ExtendedVxeGridApi< diff --git a/packages/effects/plugins/src/vxe-table/use-viewed-row.ts b/packages/effects/plugins/src/vxe-table/use-viewed-row.ts new file mode 100644 index 000000000..c05bee552 --- /dev/null +++ b/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>(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 = []; + for (const fullKey of prefixedKeys) { + const shortKey = fullKey.replace(fullPrefix, ''); + const value = await manager.getItem(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, 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( + options: ViewedRowOptions & { 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>(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) => 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)[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) { + 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)[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) { + 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, +) { + // 注入 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, + ); + } +} diff --git a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue b/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue index 78cab6a58..c74c7d595 100644 --- a/packages/effects/plugins/src/vxe-table/use-vxe-grid.vue +++ b/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, +); + +// 初始化 + 监听配置变化时重建 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; }); diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 9f88ebd37..ebf085ffa 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/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", diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index e260016ec..1a2b6a83c 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -41,7 +41,8 @@ "editCell": "单元格编辑", "editRow": "行编辑", "custom-cell": "自定义单元格", - "form": "搜索表单" + "form": "搜索表单", + "viewed": "行标记" }, "captcha": { "title": "验证码", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index 38f25e2d3..ff6b00a30 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/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'), + }, + }, ], }, { diff --git a/playground/src/views/examples/vxe-table/viewed.vue b/playground/src/views/examples/vxe-table/viewed.vue new file mode 100644 index 000000000..f70f0030f --- /dev/null +++ b/playground/src/views/examples/vxe-table/viewed.vue @@ -0,0 +1,178 @@ + + +