diff --git a/packages/pro-components/chat/_util/reactify.tsx b/packages/pro-components/chat/_util/reactify.tsx index fb65659e7b..6579c196d3 100644 --- a/packages/pro-components/chat/_util/reactify.tsx +++ b/packages/pro-components/chat/_util/reactify.tsx @@ -5,7 +5,7 @@ import { createRoot } from 'react-dom/client'; // 检测 React 版本 const isReact18Plus = () => typeof createRoot !== 'undefined'; const isReact19Plus = (): boolean => { - const majorVersion = parseInt(React.version.split('.')[0]); + const majorVersion = parseInt(React.version.split('.')[0], 10); return majorVersion >= 19; }; @@ -118,6 +118,15 @@ const styleObjectToString = (style: any) => { .join(' '); }; +/** + * 动态slot配置白名单 + * 某些webc的slot是动态渲染的(即只有当light dom中存在对应的slot内容时,shadow dom才会渲染slot标签)。此时无法通过shadow dom检测到slot是否存在,需要通过配置白名单来强制启用 + */ +const dynamicSlotConfig: Record boolean> = { + 't-chatbot': (slotName) => slotName.startsWith('sender-'), + 't-chat-item': (slotName) => slotName === 'actionbar', +}; + const reactify = ( WC: string, ): React.ForwardRefExoticComponent & React.RefAttributes> => { @@ -154,9 +163,18 @@ const reactify = ( return; } + // 将React prop转换为slot,如innerHeader -> inner-header + let slotName = prop; + if (prop.endsWith('Slot')) { + // 移除 'Slot' 后缀 + slotName = prop.slice(0, -4); + } + // 转换驼峰命名为连字符命名 + slotName = hyphenate(slotName); + // 检查是否需要更新(避免相同内容的重复渲染) - const currentRenderer = this.slotRenderers.get(prop); - if (currentRenderer && this.isSameReactElement(prop, val)) { + const currentRenderer = this.slotRenderers.get(slotName); + if (currentRenderer && this.isSameReactElement(slotName, val)) { return; // 相同内容,跳过更新 } @@ -165,19 +183,19 @@ const reactify = ( // 立即缓存新元素,防止重复调用 if (isValidReactNode(val)) { - this.lastRenderedElements.set(prop, val); - } - - // 清理旧的渲染器 - if (currentRenderer) { - this.cleanupSlotRenderer(prop); + this.lastRenderedElements.set(slotName, val); } // 如果val是函数,为WebComponent提供一个函数,该函数返回渲染后的DOM if (typeof val === 'function') { + // 清理旧的渲染器 + if (currentRenderer) { + this.cleanupSlotRenderer(slotName); + } + const renderSlot = (params?: any) => { const reactNode = val(params); - return this.renderReactNodeToSlot(reactNode, prop); + return this.renderReactNodeToSlot(reactNode, slotName); }; webComponent[prop] = renderSlot; // 函数类型处理完成后立即移除标记 @@ -190,10 +208,23 @@ const reactify = ( // 使用微任务延迟渲染,确保在当前渲染周期完成后执行 Promise.resolve().then(() => { - if (webComponent.update) { - webComponent.update(); + // 已有渲染器则直接更新,避免failed to execute removeChild + if (currentRenderer) { + this.updateSlotContent(slotName, val); + } else if (!currentRenderer && dynamicSlotConfig[WC]?.(slotName)) { + // 如果webc组件是动态生成slot容器的话,先将
appendChild到webc中 + this.renderReactNodeToSlot(val, slotName); + + // 强制更新一次,确保shadow dom生成对应的slot + if (webComponent.update) { + webComponent.update(); + } + } else { + if (webComponent.update) { + webComponent.update(); + } + this.renderReactNodeToSlot(val, slotName); } - this.renderReactNodeToSlot(val, prop); // 渲染完成后移除处理标记 this.processingSlots.delete(prop); }); @@ -210,14 +241,41 @@ const reactify = ( // 总是异步清理React渲染器,避免竞态条件 Promise.resolve().then(() => { - this.safeCleanupRenderer(renderer); + Reactify.safeCleanupRenderer(renderer); }); this.slotRenderers.delete(slotName); } + private updateSlotContent(slotName: string, reactNode: React.ReactNode) { + const webComponent = this.ref.current; + if (!webComponent) return; + + // 查找现有的slot容器 + const existingContainer = webComponent.querySelector(`[slot="${slotName}"]`) as HTMLElement; + + if (existingContainer && isValidReactNode(reactNode)) { + // 在现有容器中重新渲染webc的slot + try { + if (React.isValidElement(reactNode)) { + const renderer = createRenderer(existingContainer); + renderer.render(reactNode); + } else if (typeof reactNode === 'string' || typeof reactNode === 'number') { + existingContainer.textContent = String(reactNode); + } + } catch (error) { + console.warn('[reactify] Error updating slot content:', error); + // 如果更新失败,回退到清理重建 + this.cleanupSlotRenderer(slotName); + this.renderReactNodeToSlot(reactNode, slotName); + } + } else { + this.renderReactNodeToSlot(reactNode, slotName); + } + } + // 安全清理渲染器 - private safeCleanupRenderer(cleanup: () => void) { + private static safeCleanupRenderer(cleanup: () => void) { try { cleanup(); } catch (error) { @@ -230,13 +288,22 @@ const reactify = ( const webComponent = this.ref.current; if (!webComponent) return; - // 查找并移除所有匹配的slot容器 - const containers = webComponent.querySelectorAll(`[slot="${slotName}"]`); - containers.forEach((container: Element) => { - if (container.parentNode) { - container.parentNode.removeChild(container); - } - }); + try { + // 查找并移除所有匹配的slot容器 + const containers = webComponent.querySelectorAll(`[slot="${slotName}"]`); + containers.forEach((container: Element) => { + // 确保容器仍然在dom树中且有父节点 + if (container.parentNode && container.parentNode.contains(container)) { + try { + container.parentNode.removeChild(container); + } catch (error) { + console.warn(`[reactify] Error removing slot container for "${slotName}":`, error); + } + } + }); + } catch (error) { + console.warn(`[reactify] Error clearing slot containers for "${slotName}":`, error); + } } // 缓存最后渲染的React元素,用于比较 @@ -255,12 +322,10 @@ const reactify = ( return true; } - // 对于React元素,比较type、key和props + // 对于React元素,只比较type和key,不比较props,因为当props包含函数或react dom 时会导致循环引用 if (React.isValidElement(lastElement) && React.isValidElement(val)) { - const typeMatch = lastElement.type === val.type; - const keyMatch = lastElement.key === val.key; - const propsMatch = JSON.stringify(lastElement.props) === JSON.stringify(val.props); - return typeMatch && keyMatch && propsMatch; + // 允许props更新 + return false; } return false; @@ -367,12 +432,21 @@ const reactify = ( return; } - // 检查是否是slot prop(通过组件的slotProps静态属性或Slot后缀) - if (isReactElement(val) && !prop.match(/^on[A-Za-z]/) && !prop.match(/^render[A-Za-z]/)) { - const componentClass = this.ref.current?.constructor as any; - const declaredSlots = componentClass?.slotProps || []; + // 检查是否是slot prop + if (isValidReactNode(val) && !prop.match(/^on[A-Za-z]/) && !prop.match(/^render[A-Za-z]/)) { + const isSlotProp = prop.endsWith('Slot'); + const possibleSlotName = hyphenate(isSlotProp ? prop.slice(0, -4) : prop); + + // 将react组件的prop当做slot处理 + // 1. 检查webc中shadow dom是否存在该slot + let hasSlotInDOM = this.ref.current?.shadowRoot?.querySelector(`slot[name="${possibleSlotName}"]`) !== null; + + // 2. 检查是否配置了动态slot白名单 + if (!hasSlotInDOM && dynamicSlotConfig[WC]) { + hasSlotInDOM = dynamicSlotConfig[WC](possibleSlotName); + } - if (declaredSlots.includes(prop) || prop.endsWith('Slot')) { + if (hasSlotInDOM) { this.handleSlotProp(prop, val); return; } @@ -430,14 +504,14 @@ const reactify = ( clearSlotRenderers() { this.slotRenderers.forEach((cleanup) => { - this.safeCleanupRenderer(cleanup); + Reactify.safeCleanupRenderer(cleanup); }); this.slotRenderers.clear(); this.processingSlots.clear(); } render() { - const { children, className, innerRef, ...rest } = this.props; + const { children, className, ...rest } = this.props; return createElement(WC, { class: className, ...rest, ref: this.ref }, children); } diff --git a/packages/pro-components/chat/chat-actionbar/_example/base.tsx b/packages/pro-components/chat/chat-actionbar/_example/base.tsx index d58af3abc9..a5e180a28d 100644 --- a/packages/pro-components/chat/chat-actionbar/_example/base.tsx +++ b/packages/pro-components/chat/chat-actionbar/_example/base.tsx @@ -1,15 +1,25 @@ import React from 'react'; -import { Space } from 'tdesign-react'; +import { MessagePlugin, Space } from 'tdesign-react'; import { ChatActionBar } from '@tdesign-react/chat'; +import { HeartIcon } from 'tdesign-icons-react'; const ChatActionBarExample = () => { const onActions = (name, data) => { console.log('消息事件触发:', name, data); }; + const customIconActions = [ + // 预设项 + 'good', + + // 自定项,可传自定义icon图标,也可以通过onClick覆盖onAction的事件回调 + MessagePlugin.success('点赞')} key="custom-icon" />, + ]; + return ( - + + ); }; diff --git a/packages/pro-components/chat/chat-actionbar/_example/custom.tsx b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx index dfee769317..7c2b087710 100644 --- a/packages/pro-components/chat/chat-actionbar/_example/custom.tsx +++ b/packages/pro-components/chat/chat-actionbar/_example/custom.tsx @@ -1,15 +1,47 @@ import React from 'react'; -import { Space } from 'tdesign-react'; +import { MessagePlugin, Space, Button } from 'tdesign-react'; import { ChatActionBar } from '@tdesign-react/chat'; +import { HeartIcon, UserIcon } from 'tdesign-icons-react'; const ChatActionBarExample = () => { const onActions = (name, data) => { console.log('消息事件触发:', name, data); }; + const customActions = [ + // 1. 使用 ChatActionBar 提供的预设项 + 'good', + 'bad', + + // 2. 通过直接传入 React Element 来自定义 icon 图标: + MessagePlugin.success('点击了喜欢')} />, + + // 3. 自定义带文本的按钮样式 +
MessagePlugin.info('前往用户界面')} + > + + 用户 +
, + + // 4. 通过添加 ignoreWrapper 属性,来完全自定义样式的按钮(不继承默认样式) + , + ]; + return ( - + ); }; diff --git a/packages/pro-components/chat/chat-actionbar/chat-actionbar.md b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md index c2f481e215..bf205c510c 100644 --- a/packages/pro-components/chat/chat-actionbar/chat-actionbar.md +++ b/packages/pro-components/chat/chat-actionbar/chat-actionbar.md @@ -18,7 +18,10 @@ spline: aigc ## 自定义 -目前仅支持有限的自定义,包括调整顺序,展示指定项 +对于自定义 React 组件: +- 默认会自动包裹与预设操作一致的样式(如 hover 背景)。 +- 如果需要完全自定义样式,可以给组件添加 `ignoreWrapper` 属性。 +- 可以直接在组件上绑定 `onClick` 等事件。 {{ custom }} @@ -34,3 +37,4 @@ handleAction | Function | - | 操作回调函数。TS类型:`(name: TdChatActi comment | ChatComment | - | 用户反馈状态,可选项:'good'/'bad' | N copyText | string | - | 复制按钮的复制文本 | N tooltipProps | TooltipProps | - | tooltip的属性 [类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/tooltip/type.ts) | N +ignoreWrapper | boolean | false | 在自定义 React 节点上添加此属性,可取消继承默认的样式包裹 | N diff --git a/packages/pro-components/chat/chat-actionbar/index.tsx b/packages/pro-components/chat/chat-actionbar/index.tsx index 1a21b91b1a..83aae06d04 100644 --- a/packages/pro-components/chat/chat-actionbar/index.tsx +++ b/packages/pro-components/chat/chat-actionbar/index.tsx @@ -1,52 +1,58 @@ +import React, { useMemo, forwardRef } from 'react'; import { TdChatActionProps } from 'tdesign-web-components'; import 'tdesign-web-components/lib/chat-action'; import reactify from '../_util/reactify'; -export const ChatActionBar: React.ForwardRefExoticComponent< +const BaseChatActionBar = reactify('t-chat-action') as React.ComponentType; + +export const ChatActionBar = forwardRef((props, ref) => { + const { actionBar, handleAction, ...rest } = props; + + const { finalActionBar, slots } = useMemo(() => { + if (!Array.isArray(actionBar)) return { finalActionBar: actionBar, slots: null }; + + const newActionBar = []; + const newSlots: React.ReactNode[] = []; + + actionBar.forEach((item, index) => { + if (typeof item === 'string') { + newActionBar.push(item); + } else if (React.isValidElement(item)) { + const reactItem = item as React.ReactElement; + + const slotName = `action-item-${index}`; + + // 设置 ignoreWrapper 属性可以让 actionBar 中的元素不继承父级默认的样式 + const ignoreWrapper = reactItem.props.ignoreWrapper as any; + + // 通过 webc 内部 wrapper 的事件冒泡来触发 handleAction + newActionBar.push({ name: slotName, ignoreWrapper }); + + newSlots.push( +
+ {item} +
, + ); + } else { + newActionBar.push(item); + } + }); + + return { finalActionBar: newActionBar, slots: newSlots }; + }, [actionBar]); + + return ( + + {slots} + {props.children} + + ); +}) as React.ForwardRefExoticComponent< Omit & React.RefAttributes & { [key: string]: any; } -> = reactify('t-chat-action'); +>; export default ChatActionBar; export type { TdChatActionProps, TdChatActionsName } from 'tdesign-web-components'; - -// 方案1 -// import { reactifyLazy } from './_util/reactifyLazy'; -// const ChatActionBar = reactifyLazy<{ -// size: 'small' | 'medium' | 'large', -// variant: 'primary' | 'secondary' | 'outline' -// }>( -// 't-chat-action', -// 'tdesign-web-components/esm/chat-action' -// ); - -// import ChatAction from 'tdesign-web-components/esm/chat-action'; -// import React, { forwardRef, useEffect } from 'react'; - -// // 注册Web Components组件 -// const registerChatAction = () => { -// if (!customElements.get('t-chat-action')) { -// customElements.define('t-chat-action', ChatAction); -// } -// }; - -// // 在组件挂载时注册 -// const useRegisterWebComponent = () => { -// useEffect(() => { -// registerChatAction(); -// }, []); -// }; - -// // 使用reactify创建React组件 -// const BaseChatActionBar = reactify('t-chat-action'); - -// // 包装组件,确保Web Components已注册 -// export const ChatActionBar2 = forwardRef< -// HTMLElement | undefined, -// Omit & { [key: string]: any } -// >((props, ref) => { -// useRegisterWebComponent(); -// return ; -// }); diff --git a/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx b/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx index 9f4644d14b..5717f94c47 100644 --- a/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx +++ b/packages/pro-components/chat/chat-engine/_example/comprehensive.tsx @@ -256,9 +256,8 @@ export default function Comprehensive() { onChange={(e) => setInputValue(e.detail)} onSend={handleSend} onStop={() => chatEngine.abortChat()} - > - {/* 自定义输入框底部区域slot,可以增加模型选项 */} -
+ footerPrefix={ + /* 自定义输入框底部区域slot,可以增加模型选项 */
- + } + >
); } diff --git a/packages/pro-components/chat/chat-engine/_example/custom-content.tsx b/packages/pro-components/chat/chat-engine/_example/custom-content.tsx index c76468d172..f0eae3f160 100644 --- a/packages/pro-components/chat/chat-engine/_example/custom-content.tsx +++ b/packages/pro-components/chat/chat-engine/_example/custom-content.tsx @@ -420,9 +420,8 @@ export default function CustomContent() { onStop={onStop} onFileSelect={onFileSelect} onFileRemove={onFileRemove} - > - {/* 自定义输入框底部区域slot */} -
+ footerPrefix={ + /* 自定义输入框底部区域slot */ -
- + } + /> ); } diff --git a/packages/pro-components/chat/chat-message/_example/action.tsx b/packages/pro-components/chat/chat-message/_example/action.tsx index 5163dd7c10..2ca6d661ee 100644 --- a/packages/pro-components/chat/chat-message/_example/action.tsx +++ b/packages/pro-components/chat/chat-message/_example/action.tsx @@ -21,10 +21,11 @@ export default function ChatMessageExample() { name="TDesignAI" role={message.role} content={message.content} - > - {/* 植入插槽用来追加消息底部操作栏 */} - - + actionbar={ + /* 追加消息底部操作栏 */ + + } + /> ); } diff --git a/packages/pro-components/chat/chat-sender/_example/custom.tsx b/packages/pro-components/chat/chat-sender/_example/custom.tsx index 8c093cd6fe..abf4666709 100644 --- a/packages/pro-components/chat/chat-sender/_example/custom.tsx +++ b/packages/pro-components/chat/chat-sender/_example/custom.tsx @@ -120,10 +120,9 @@ const ChatSenderExample = () => { attachmentsProps={{ items: files, }} - > - {/* 自定义输入框上方区域,可用来引用内容或提示场景 */} - {showRef && ( -
+ innerHeader={ + /* 自定义输入框上方区域,可用来引用内容或提示场景 */ + showRef && ( {
- - )} - {/* 自定义输入框底部区域slot,可以增加模型选项 */} -
+ ) + } + inputPrefix={ + /* 自定义输入框左侧区域,可以用来触发工具场景切换 */ + + + {options.filter((item) => item.value === scene)[0].content} + + + } + footerPrefix={ + /* 自定义输入框底部区域,可以增加模型选项 */ -
- {/* 自定义输入框左侧区域slot,可以用来触发工具场景切换 */} -
- - - {options.filter((item) => item.value === scene)[0].content} - - -
- {/* 自定义提交区域slot */} -
- {!loading ? ( + } + actions={ + /* 自定义提交区域 */ + !loading ? ( + /> ) : ( - - )} -
- + } + actions={} +/> +``` + +**插槽写法**:通过 `slot` 属性指定插入位置,兼容 Web Components 原生写法 +```tsx + +
引用内容
+
...
+
+``` 同时示例中演示了通过`CSS变量覆盖`实现样式定制 diff --git a/packages/pro-components/chat/chatbot/_example/basic.tsx b/packages/pro-components/chat/chatbot/_example/basic.tsx index 9d5c93bbaf..f7282d4ded 100644 --- a/packages/pro-components/chat/chatbot/_example/basic.tsx +++ b/packages/pro-components/chat/chatbot/_example/basic.tsx @@ -193,9 +193,8 @@ export default function chatSample() { onChatReady={() => { setReady(true); }} - > - {/* 自定义输入框底部区域slot,可以增加模型选项 */} -
+ senderFooterPrefix={ + /* 自定义输入框底部区域,可以增加模型选项 */
- + } + /> ); } diff --git a/packages/pro-components/chat/chatbot/_example/comprehensive.tsx b/packages/pro-components/chat/chatbot/_example/comprehensive.tsx index 76df7a2c37..81965d489a 100644 --- a/packages/pro-components/chat/chatbot/_example/comprehensive.tsx +++ b/packages/pro-components/chat/chatbot/_example/comprehensive.tsx @@ -191,9 +191,8 @@ export default function chatSample() { onChatReady={() => { setReady(true); }} - > - {/* 自定义输入框底部区域slot,可以增加模型选项 */} -
+ senderFooterPrefix={ + /* 自定义输入框底部区域slot,可以增加模型选项 */