diff --git a/packages/components/message/__tests__/message.test.tsx b/packages/components/message/__tests__/message.test.tsx index ade25edd98..3a4d0d07a5 100644 --- a/packages/components/message/__tests__/message.test.tsx +++ b/packages/components/message/__tests__/message.test.tsx @@ -73,6 +73,56 @@ describe('Message', () => { MessagePlugin.closeAll(); }); + it('should reset timer when messages are merged', async () => { + vi.useFakeTimers(); + MessagePlugin.closeAll(); + await nextTick(); + + // 发送第一条消息,设置较短的duration + MessagePlugin.info({ + content: '定时器测试', + mergeIdentical: true, + duration: 2000, // 2秒后消失 + }); + + await nextTick(); + + let messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + + // 等待1500ms,第一条消息即将消失 + vi.advanceTimersByTime(1500); + await nextTick(); + + messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + + // 发送第二条相同消息,duration为5000ms,应该重置定时器 + MessagePlugin.info({ + content: '定时器测试', + mergeIdentical: true, + duration: 5000, // 5秒后消失 + }); + + await nextTick(); + + // 应该仍然只有1条消息,但计数为2 + messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×2)'); + + // 再等待1000ms(总共2500ms),如果定时器没有重置,消息应该已经消失了 + // 但是因为定时器被重置为5000ms,所以消息应该还存在 + vi.advanceTimersByTime(1000); + await nextTick(); + + messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); // 这里应该通过,证明定时器被重置了 + + vi.useRealTimers(); + MessagePlugin.closeAll(); + }); + it('should create only one instance per placement', async () => { MessagePlugin.info('msg1', 0); MessagePlugin.info('msg2', 0); @@ -100,5 +150,200 @@ describe('Message', () => { expect(document.querySelectorAll('.t-message').length).toBe(0); }); + + it('should merge identical messages correctly when count > 2', async () => { + MessagePlugin.closeAll(); + await nextTick(); + + // 测试基本的2条消息合并 + MessagePlugin.info({ + content: '测试合并消息', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + MessagePlugin.info({ + content: '测试合并消息', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + // 检查是否合并为1条消息 + const messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×2)'); + + MessagePlugin.closeAll(); + }); + + it('should handle merge with different content correctly', async () => { + MessagePlugin.closeAll(); + await nextTick(); + + // 发送不同内容的消息,不应该合并 + MessagePlugin.info({ + content: '消息1', + mergeIdentical: true, + duration: 0, + }); + + MessagePlugin.info({ + content: '消息2', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + // 应该有2条不同的消息 + const messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(2); + + MessagePlugin.closeAll(); + }); + + it('should merge multiple identical messages step by step', async () => { + MessagePlugin.closeAll(); + await nextTick(); + + // 逐步发送相同消息并验证每一步 + MessagePlugin.info({ + content: '步骤测试', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + let messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('步骤测试'); + expect(messages[0].textContent).not.toContain('×'); + + // 第2条消息 + MessagePlugin.info({ + content: '步骤测试', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×2)'); + + // 第3条消息 + MessagePlugin.info({ + content: '步骤测试', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×3)'); + + MessagePlugin.closeAll(); + }); + + it('should merge messages with custom merge window', async () => { + vi.useFakeTimers(); + + MessagePlugin.closeAll(); + await nextTick(); + + // 发送第一条消息 + MessagePlugin.info({ + content: '长窗口合并测试', + mergeIdentical: true, + mergeWindow: 2000, + duration: 0, + }); + + await nextTick(); + + // 1.5秒后发送第二条相同消息,应该能合并 + vi.advanceTimersByTime(1500); + MessagePlugin.info({ + content: '长窗口合并测试', + mergeIdentical: true, + mergeWindow: 2000, + duration: 0, + }); + + await nextTick(); + vi.runOnlyPendingTimers(); + await nextTick(); + + // 应该只有1条消息(合并后的) + const messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×2)'); + + vi.useRealTimers(); + MessagePlugin.closeAll(); + }); + + it('should not merge messages with different themes', async () => { + MessagePlugin.closeAll(); + await nextTick(); + + // 发送相同内容但不同主题的消息 + MessagePlugin.info({ + content: '相同内容不同主题', + mergeIdentical: true, + duration: 0, + }); + + MessagePlugin.error({ + content: '相同内容不同主题', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + // 应该有2条消息(不同主题不合并) + const messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(2); + + MessagePlugin.closeAll(); + }); + + it('should merge messages with custom merge key', async () => { + MessagePlugin.closeAll(); + await nextTick(); + + // 发送不同内容但相同 mergeKey 的消息 + MessagePlugin.info({ + content: '消息内容1', + mergeKey: 'custom-key', + mergeIdentical: true, + duration: 0, + }); + + MessagePlugin.warning({ + content: '消息内容2', + mergeKey: 'custom-key', + mergeIdentical: true, + duration: 0, + }); + + await nextTick(); + + // 应该只有1条消息(相同 mergeKey 会合并) + const messages = document.querySelectorAll('.t-message'); + expect(messages.length).toBe(1); + expect(messages[0].textContent).toContain('(×2)'); + + MessagePlugin.closeAll(); + }); }); }); diff --git a/packages/components/message/_example-ts/merge.vue b/packages/components/message/_example-ts/merge.vue new file mode 100644 index 0000000000..e127b8b1c4 --- /dev/null +++ b/packages/components/message/_example-ts/merge.vue @@ -0,0 +1,228 @@ + + + diff --git a/packages/components/message/_example/merge.vue b/packages/components/message/_example/merge.vue new file mode 100644 index 0000000000..337eb9802d --- /dev/null +++ b/packages/components/message/_example/merge.vue @@ -0,0 +1,225 @@ + + + diff --git a/packages/components/message/message-list.tsx b/packages/components/message/message-list.tsx index 519bb93e5f..5bcf28ec49 100644 --- a/packages/components/message/message-list.tsx +++ b/packages/components/message/message-list.tsx @@ -2,8 +2,9 @@ import { computed, defineComponent, ref } from 'vue'; import type { CSSProperties } from 'vue'; import { PLACEMENT_OFFSET } from './consts'; import TMessage from './message'; -import { MessageOptions } from './type'; +import { MessageOptions, MessageItemInternal, MessageMergeConfig } from './type'; import { usePrefixClass } from '@tdesign/shared-hooks'; +import { isString } from 'lodash-es'; export const DEFAULT_Z_INDEX = 6000; @@ -29,16 +30,159 @@ export const MessageList = defineComponent({ }, setup(props, { expose }) { const COMPONENT_NAME = usePrefixClass('message__list'); - const list = ref([]); + const list = ref([]); const messageList = ref([]); + // 全局合并配置 + const globalMergeConfig = ref({ + mergeIdentical: false, + mergeWindow: 500, + maxMergeCount: 99, + showMergeCount: true, + mergeCountFormat: '(×{count})', + }); + + // 合并定时器映射 + const mergeTimers = new Map(); + + /** + * 生成消息合并标识 + */ + const generateMergeKey = (msg: MessageOptions): string => { + if (msg.mergeKey) { + return msg.mergeKey; + } + const content = isString(msg.content) ? msg.content : JSON.stringify(msg.content); + return `${msg.theme || 'info'}-${content}`; + }; + + /** + * 格式化显示内容,添加合并计数 + */ + const formatContentWithCount = (item: MessageItemInternal): string | any => { + const { + originalContent, + mergeCount, + showMergeCount: _showMergeCount, + mergeCountFormat: _mergeCountFormat, + } = item; + const config = { ...globalMergeConfig.value, ...item }; + + if (!config.showMergeCount || !mergeCount || mergeCount <= 1) { + return originalContent || item.content; + } + + const countText = (config.mergeCountFormat || '(×{count})').replace('{count}', String(mergeCount)); + + if (isString(originalContent)) { + return `${originalContent} ${countText}`; + } + + return originalContent || item.content; + }; + const styles = computed(() => ({ ...(PLACEMENT_OFFSET[props.placement as keyof typeof PLACEMENT_OFFSET] as CSSProperties), zIndex: props.zIndex !== DEFAULT_Z_INDEX ? props.zIndex : DEFAULT_Z_INDEX, })); const add = (msg: MessageOptions): number => { - const mg = { ...msg, key: getUniqueId() }; + const config = { ...globalMergeConfig.value, ...msg }; + + // 如果启用了合并功能 + if (config.mergeIdentical) { + const mergeKey = generateMergeKey(msg); + + // 查找是否存在相同的消息 + const existingIndex = list.value.findIndex((item) => { + // 使用原始内容生成合并键,避免合并计数影响匹配 + const itemMergeKey = generateMergeKey({ + ...item, + content: item.originalContent || item.content, + }); + return itemMergeKey === mergeKey; + }); + + if (existingIndex !== -1) { + const existingItem = list.value[existingIndex]; + const newCount = (existingItem.mergeCount || 1) + 1; + + // 检查是否超过最大合并次数 + if (newCount <= (config.maxMergeCount || 99)) { + // 清除之前的合并定时器 + if (existingItem.mergeTimer) { + clearTimeout(existingItem.mergeTimer); + } + + // 更新现有消息 + const updatedItem: MessageItemInternal = { + ...existingItem, + ...msg, // 使用新消息的配置覆盖 + key: existingItem.key, // 保持原有的 key + mergeCount: newCount, + originalContent: existingItem.originalContent || existingItem.content, + content: formatContentWithCount({ + ...existingItem, + mergeCount: newCount, + originalContent: existingItem.originalContent || existingItem.content, + }), + }; + + // 清除旧的定时器并重新设置消息显示定时器 + if (existingItem.mergeTimer) { + clearTimeout(existingItem.mergeTimer); + } + if (updatedItem.duration && updatedItem.duration > 0) { + updatedItem.mergeTimer = window.setTimeout(() => { + // 重新查找消息索引,因为数组可能已经变化 + const currentIndex = list.value.findIndex((item) => item.key === updatedItem.key); + if (currentIndex !== -1) { + remove(currentIndex); + } + }, updatedItem.duration); + } + + list.value[existingIndex] = updatedItem; + + return existingItem.key; + } + } + + // 设置合并定时器,在合并窗口期内等待相同消息 + if (config.mergeWindow && config.mergeWindow > 0) { + const timer = mergeTimers.get(mergeKey); + if (timer) { + clearTimeout(timer); + } + + mergeTimers.set( + mergeKey, + window.setTimeout(() => { + mergeTimers.delete(mergeKey); + }, config.mergeWindow), + ); + } + } + + // 创建新消息 + const mg: MessageItemInternal = { + ...msg, + key: getUniqueId(), + mergeCount: 1, + originalContent: msg.content, + }; + + // 为新消息设置初始定时器 + if (mg.duration && mg.duration > 0) { + mg.mergeTimer = window.setTimeout(() => { + // 重新查找消息索引,因为数组可能已经变化 + const currentIndex = list.value.findIndex((item) => item.key === mg.key); + if (currentIndex !== -1) { + remove(currentIndex); + } + }, mg.duration); + } + list.value.push(mg); return mg.key; }; @@ -48,15 +192,59 @@ export const MessageList = defineComponent({ }; const removeAll = () => { + // 清除所有合并定时器 + list.value.forEach((item) => { + if (item.mergeTimer) { + clearTimeout(item.mergeTimer); + } + }); + mergeTimers.forEach((timer) => clearTimeout(timer)); + mergeTimers.clear(); + list.value = []; }; + /** + * 根据合并标识清除消息 + */ + const clearByKey = (mergeKey: string) => { + const indexesToRemove: number[] = []; + + list.value.forEach((item, index) => { + const itemMergeKey = generateMergeKey(item); + if (itemMergeKey === mergeKey) { + if (item.mergeTimer) { + clearTimeout(item.mergeTimer); + } + indexesToRemove.push(index); + } + }); + + // 从后往前删除,避免索引变化 + indexesToRemove.reverse().forEach((index) => { + list.value.splice(index, 1); + }); + + // 清除对应的合并定时器 + if (mergeTimers.has(mergeKey)) { + clearTimeout(mergeTimers.get(mergeKey)); + mergeTimers.delete(mergeKey); + } + }; + + /** + * 配置全局合并选项 + */ + const configMerge = (config: Partial) => { + Object.assign(globalMergeConfig.value, config); + }; + const getOffset = (val: string | number) => { if (!val) return; return isNaN(Number(val)) ? val : `${val}px`; }; - const msgStyles = (item: { offset: Array }) => { + const msgStyles = (item: MessageItemInternal) => { return ( item.offset && { position: 'relative', @@ -69,6 +257,8 @@ export const MessageList = defineComponent({ const getProps = (index: number, item: MessageOptions) => { return { ...item, + // 禁用Message组件内部的duration定时器,由MessageList统一管理 + duration: 0, onCloseBtnClick: (e: any) => { if (item.onCloseBtnClick) { item.onCloseBtnClick(e); @@ -90,7 +280,7 @@ export const MessageList = defineComponent({ } }; - expose({ add, removeAll, list, messageList }); + expose({ add, removeAll, clearByKey, configMerge, list, messageList }); return () => { if (!list.value.length) return; diff --git a/packages/components/message/message.en-US.md b/packages/components/message/message.en-US.md index 3d7ee64b84..68df8a98a6 100644 --- a/packages/components/message/message.en-US.md +++ b/packages/components/message/message.en-US.md @@ -29,6 +29,11 @@ name | type | default | description | required -- | -- | -- | -- | -- attach | String / Function | 'body' | Typescript:`AttachNode`。[see more ts definition](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts) | N className | String | - | HTMLElement class | N +mergeIdentical | Boolean | false | Whether to enable merging of identical messages | N +mergeKey | String | - | Custom merge identifier for distinguishing different types of messages. If not set, `${theme}-${content}` will be used as the merge identifier | N +mergeWindow | Number | 500 | Merge time window in milliseconds. Messages with the same identifier within this time will be merged | N +showMergeCount | Boolean | true | Whether to show merge count | N +mergeCountFormat | String | '(×{count})' | Format for displaying merge count, {count} will be replaced with the actual merge count | N offset | Array | - | Typescript:`Array` | N placement | String | top | options: center/top/left/right/bottom/top-left/top-right/bottom-left/bottom-right。Typescript:`MessagePlacementList` `type MessagePlacementList = 'center' \| 'top' \| 'left' \| 'right' \| 'bottom' \| 'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right'`。[see more ts definition](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/message/type.ts) | N style | Object | - | CSS style。Typescript:`CSSProperties` | N @@ -122,6 +127,22 @@ name | params | default | description -- | -- | -- | -- \- | \- | - | \- +### MessagePlugin.clearByKey + +Also supports `this.$message.clearByKey`. Clear specific type of messages by merge key. + +name | params | default | description +-- | -- | -- | -- +mergeKey | String | - | required. The merge key of messages to be cleared + +### MessagePlugin.configMerge + +Also supports `this.$message.configMerge`. Configure global message merge options. + +name | params | default | description +-- | -- | -- | -- +config | Object | - | required. Global merge configuration options. Typescript:`MessageMergeConfig` + ### MessagePlugin.config 同时也支持 `this.$message.config`。 diff --git a/packages/components/message/message.md b/packages/components/message/message.md index d7a08d2c38..f8bf56e380 100644 --- a/packages/components/message/message.md +++ b/packages/components/message/message.md @@ -30,6 +30,12 @@ {{ plugin }} +### 消息合并功能 + +当多个相同内容的消息同时出现时,可以启用合并功能来避免重复显示,提升用户体验。 + +{{ merge }} + ## API ### Message Props @@ -58,6 +64,11 @@ duration-end | \- | 计时结束后触发 -- | -- | -- | -- | -- attach | String / Function | 'body' | 指定弹框挂载的父节点。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body。TS 类型:`AttachNode`。[通用类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/common.ts) | N className | String | - | 类名 | N +mergeIdentical | Boolean | false | 是否启用相同内容消息合并功能 | N +mergeKey | String | - | 自定义合并标识,用于区分不同类型的消息。如果不设置,则使用 `${theme}-${content}` 作为合并标识 | N +mergeWindow | Number | 500 | 合并时间窗口,单位毫秒。在此时间内的相同消息将被合并 | N +showMergeCount | Boolean | true | 是否显示合并计数 | N +mergeCountFormat | String | '(×{count})' | 合并计数显示格式,{count} 会被替换为实际的合并次数 | N offset | Array | - | 相对于 placement 的偏移量,示例:[-10, 20] 或 ['10em', '8rem']。TS 类型:`Array` | N placement | String | top | 弹出消息位置。可选项:center/top/left/right/bottom/top-left/top-right/bottom-left/bottom-right。TS 类型:`MessagePlacementList` `type MessagePlacementList = 'center' \| 'top' \| 'left' \| 'right' \| 'bottom' \| 'top-left' \| 'top-right' \| 'bottom-left' \| 'bottom-right'`。[详细类型定义](https://github.com/Tencent/tdesign-vue-next/blob/develop/packages/components/message/type.ts) | N style | Object | - | 内敛样式。TS 类型:`CSSProperties` | N @@ -153,6 +164,22 @@ options | Object | - | 必需。该插件参数为 $Message.info() 等插件执 -- | -- | -- | -- \- | \- | - | \- +### MessagePlugin.clearByKey + +同时也支持 `this.$message.clearByKey`。根据合并标识清除特定类型的消息。 + +参数名称 | 参数类型 | 参数默认值 | 参数说明 +-- | -- | -- | -- +mergeKey | String | - | 必需。要清除的消息的合并标识 + +### MessagePlugin.configMerge + +同时也支持 `this.$message.configMerge`。配置全局消息合并选项。 + +参数名称 | 参数类型 | 参数默认值 | 参数说明 +-- | -- | -- | -- +config | Object | - | 必需。全局合并配置选项。TS 类型:`MessageMergeConfig` + ### MessagePlugin.config 同时也支持 `this.$message.config`。 diff --git a/packages/components/message/plugin.tsx b/packages/components/message/plugin.tsx index 39e8d14849..a1951fd798 100644 --- a/packages/components/message/plugin.tsx +++ b/packages/components/message/plugin.tsx @@ -38,6 +38,9 @@ import { MessageQuestionMethod, MessageCloseMethod, MessageCloseAllMethod, + MessageClearByKeyMethod, + MessageConfigMergeMethod, + MessageMergeConfig, } from './type'; import { AttachNodeReturnValue } from '../common'; import { isObject, isString } from 'lodash-es'; @@ -118,6 +121,8 @@ interface ExtraApi { loading: MessageLoadingMethod; close: MessageCloseMethod; closeAll: MessageCloseAllMethod; + clearByKey: MessageClearByKeyMethod; + configMerge: MessageConfigMergeMethod; } export type MessagePluginType = Plugin & ExtraApi & MessageMethod; @@ -142,6 +147,30 @@ const extraApi: ExtraApi = { }); } }, + clearByKey: (mergeKey: string) => { + if (instanceMap instanceof Map) { + instanceMap.forEach((attach) => { + Object.keys(attach).forEach((placement) => { + const instance = attach[placement]; + if (instance.component.exposed.clearByKey) { + instance.component.exposed.clearByKey(mergeKey); + } + }); + }); + } + }, + configMerge: (config: MessageMergeConfig) => { + if (instanceMap instanceof Map) { + instanceMap.forEach((attach) => { + Object.keys(attach).forEach((placement) => { + const instance = attach[placement]; + if (instance.component.exposed.configMerge) { + instance.component.exposed.configMerge(config); + } + }); + }); + } + }, }; export const MessagePlugin = showThemeMessage as MessagePluginType & { diff --git a/packages/components/message/type.ts b/packages/components/message/type.ts index 90ddc34510..4cb0eb4f9d 100644 --- a/packages/components/message/type.ts +++ b/packages/components/message/type.ts @@ -73,6 +73,30 @@ export interface MessageOptions extends TdMessageProps { * @default 5000 */ zIndex?: number; + /** + * 是否启用相同内容消息合并 + * @default false + */ + mergeIdentical?: boolean; + /** + * 自定义合并标识,用于区分不同类型的消息 + */ + mergeKey?: string; + /** + * 合并时间窗口,单位毫秒。在此时间内的相同消息将被合并 + * @default 500 + */ + mergeWindow?: number; + /** + * 是否显示合并计数 + * @default true + */ + showMergeCount?: boolean; + /** + * 合并计数显示格式 + * @default '(×{count})' + */ + mergeCountFormat?: string; } export type MessageThemeList = 'info' | 'success' | 'warning' | 'error' | 'question' | 'loading'; @@ -142,3 +166,54 @@ export type MessageCloseMethod = (options: Promise) => void; export type MessageCloseAllMethod = () => void; export type MessageConfigMethod = (message: MessageOptions) => void; + +/** + * 消息合并配置接口 + */ +export interface MessageMergeConfig { + /** + * 是否启用相同内容消息合并 + * @default false + */ + mergeIdentical?: boolean; + /** + * 合并时间窗口,单位毫秒。在此时间内的相同消息将被合并 + * @default 500 + */ + mergeWindow?: number; + /** + * 最大合并次数 + * @default 99 + */ + maxMergeCount?: number; + /** + * 是否显示合并计数 + * @default true + */ + showMergeCount?: boolean; + /** + * 合并计数显示格式 + * @default '(×{count})' + */ + mergeCountFormat?: string; +} + +/** + * 消息项内部接口,包含合并相关属性 + */ +export interface MessageItemInternal extends MessageOptions { + key: number; + mergeCount?: number; + mergeTimer?: number; + originalContent?: string | TNode; +} + +/** + * 根据合并标识清除消息的方法 + */ +export type MessageClearByKeyMethod = (mergeKey: string) => void; + +/** + * 配置全局合并选项的方法 + */ +export type MessageConfigMergeMethod = (config: MessageMergeConfig) => void;