MarkdownButton 组件增强功能实现
在 Dify 项目的模板转换节点中,希望通过配置属性实现以下功能:
- 一次性点击按钮 :按钮点击一次后自动禁用,防止重复操作
- 自定义样式 :支持通过配置属性自定义按钮的外观(背景色、文字色、圆角等)
- 按钮唯一性标识 :支持为相同内容的按钮设置不同的点击状态
文件路径:app\components\base\markdown-blocks\button.tsx
实现方案
1. 核心技术思路
实现从模板引擎里面读取写法,通过判断自定义属性来对按钮进行处理,当配置只点击一次自定义属性时,通过缓存储存状态,实现持久化
问题分析 :React 组件重新渲染时,组件内部的 useState / useRef 状态会丢失,导致按钮点击状态无法保持。
解决方案 :使用 模块级别的 Set 来跨组件实例持久化点击状态。
2. 新增配置属性
属性 类型 说明 示例 data-once boolean 是否只能点击一次 true / false data-id string 按钮唯一标识 btn-submit data-bg-color string 自定义背景颜色 #ff5733 data-text-color string 自定义文字颜色 #ffffff data-border-radius string 自定义边框圆角 8px / 50% data-padding string 自定义内边距 10px 20px data-custom-class string 自定义 CSS 类名 my-button
3. 状态持久化机制
// 模块级别变量,生命周期与应用相同
const clickedButtons = new Set<string>()
// 生成稳定的按钮唯一标识
const buttonId = customId
? `custom-${customId}`
: `${message || buttonContent}-${variant || 'default'}-${size || 'medium'}-${node.position?.start?.offset || node.position?.start?.line || Math.random().toString(36).substr(2, 9)}`
按钮标识生成优先级:
- 自定义 ID (最高优先级):用户通过 data-id 属性指定
- 文档位置 :使用按钮在文档中的位置信息
- 随机数 (兜底):确保唯一性
核心代码实现
1. 完整组件代码
/**
* MarkdownButton 组件
*
* 用于渲染 Markdown 中的按钮元素,支持多种配置属性和交互功能。
*
* 支持的属性(通过 data-* 属性传递):
* - data-message: 点击按钮时发送的消息内容
* - data-variant: 按钮样式变体(primary/secondary/ghost 等)
* - data-link: 链接地址,点击时在新窗口打开
* - data-size: 按钮尺寸(small/medium/large)
* - data-once: 是否只能点击一次(true/false)
* - data-id: 按钮唯一标识(用于区分相同内容的按钮)
* - data-bg-color: 自定义背景颜色
* - data-text-color: 自定义文字颜色
* - data-border-radius: 自定义边框圆角
* - data-padding: 自定义内边距
* - data-custom-class: 自定义 CSS 类名
*
* 示例用法:
* <button data-message="主要" data-variant="primary" data-once="true">主要</button>
* <button data-message="链接" data-link="https://example.com">跳转链接</button>
* <button data-message="自定义" data-bg-color="#ff5733" data-text-color="white">自定义按钮</button>
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import Button from '@/app/components/base/button'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { cn } from '@/utils/classnames'
import { isValidUrl } from './utils'
/**
* 模块级别的已点击按钮集合
* 用于跨组件实例持久化按钮点击状态,防止组件重新渲染时状态丢失
*/
const clickedButtons = new Set<string>()
/**
* MarkdownButton 组件
* @param {object} node - ReactMarkdown 解析后的节点对象
* @param {object} node.properties - 节点的属性集合(包含所有 data-* 属性)
* @param {array} node.children - 节点的子节点
* @returns {JSX.Element} 渲染后的按钮组件
*/
const MarkdownButton = ({ node }: any) => {
// 从 ChatContext 获取发送消息的回调函数
const { onSend } = useChatContext()
// 从节点属性中提取配置参数
const variant = node.properties.dataVariant // 按钮样式变体
const message = node.properties.dataMessage // 点击发送的消息
const link = node.properties.dataLink // 跳转链接
const size = node.properties.dataSize // 按钮尺寸
const customClass = node.properties.dataCustomClass || node.properties['data-custom-class'] || '' // 自定义 CSS 类名
// 自定义样式属性
const bgColor = node.properties.dataBgColor || node.properties['data-bg-color'] // 背景颜色
const textColor = node.properties.dataTextColor || node.properties['data-text-color'] // 文字颜色
const borderRadius = node.properties.dataBorderRadius || node.properties['data-border-radius'] // 边框圆角
const padding = node.properties.dataPadding || node.properties['data-padding'] // 内边距
// 是否启用一次性点击功能(支持驼峰式和短横线式属性名)
const once = node.properties.dataOnce === 'true' || node.properties.dataOnce === true ||
node.properties['data-once'] === 'true' || node.properties['data-once'] === true
// 按钮显示文本
const buttonContent = node.children[0]?.value || ''
// 获取自定义按钮 ID(用于区分相同内容的按钮)
const customId = node.properties.dataId || node.properties['data-id']
/**
* 生成按钮唯一标识
* 优先级:自定义 ID > (消息内容/按钮文本 + 样式变体 + 尺寸 + 文档位置/随机数)
*/
const buttonId = customId
? `custom-${customId}`
: `${message || buttonContent}-${variant || 'default'}-${size || 'medium'}-${node.position?.start?.offset || node.position?.start?.line || Math.random().toString(36).substr(2, 9)}`
// 按钮禁用状态(从持久化集合中初始化)
const [isDisabled, setIsDisabled] = useState(clickedButtons.has(buttonId))
// 按钮 DOM 引用(备用)
const buttonRef = useRef<HTMLButtonElement>(null)
/**
* 组件挂载时检查按钮是否已被点击过
* 确保页面刷新或组件重新渲染后,按钮状态仍然正确
*/
useEffect(() => {
if (clickedButtons.has(buttonId)) {
setIsDisabled(true)
}
}, [buttonId])
/**
* 处理按钮点击事件
* - 如果按钮已被点击过(启用了一次性点击),直接返回
* - 如果有链接且有效,在新窗口打开链接
* - 如果有消息,发送消息
* - 如果启用了一次性点击,记录点击状态并禁用按钮
*/
const handleClick = useCallback(() => {
// 如果按钮已被点击过,直接返回
if (clickedButtons.has(buttonId)) return
// 处理链接跳转
if (link && isValidUrl(link)) {
window.open(link, '_blank')
}
// 处理消息发送
else if (message) {
onSend?.(message)
}
// 如果启用了一次性点击,记录状态并禁用
if (once) {
clickedButtons.add(buttonId)
setIsDisabled(true)
}
}, [link, message, onSend, once, buttonId])
// 渲染按钮组件
return (
<Button
variant={variant} // 样式变体
size={size} // 尺寸
disabled={isDisabled} // 禁用状态
className={cn('!h-auto min-h-8 select-none whitespace-normal !px-3', customClass)} // 基础样式 + 自定义类名
onClick={handleClick} // 点击事件处理
style={{
// 应用自定义样式(仅在属性存在时)
...(bgColor && { backgroundColor: bgColor }),
...(textColor && { color: textColor }),
...(borderRadius && { borderRadius: borderRadius }),
...(padding && { padding: padding }),
}}
>
<span className="text-[13px]">{buttonContent}</span>
</Button>
)
}
// 设置组件显示名称(便于调试)
MarkdownButton.displayName = 'MarkdownButton'
export default MarkdownButton
2. 关键实现细节
- 属性读取
// 是否启用一次性点击功能(支持驼峰式和短横线式属性名)
const once = node.properties.dataOnce === 'true' || node.properties.dataOnce === true ||
node.properties['data-once'] === 'true' || node.properties['data-once'] === true
- 点击处理逻辑
const handleClick = useCallback(() => {
// 如果按钮已被点击过,直接返回
if (clickedButtons.has(buttonId)) return
// 处理链接跳转
if (link && isValidUrl(link)) {
window.open(link, '_blank')
}
// 处理消息发送
else if (message) {
onSend?.(message)
}
// 如果启用了一次性点击,记录状态并禁用
if (once) {
clickedButtons.add(buttonId)
setIsDisabled(true)
}
}, [link, message, onSend, once, buttonId])
使用示例
1. 一次性点击按钮
<button data-message="确认提交" data-variant="primary" data-once="true">确认提交</button>
2. 多个相同按钮区分
<button data-message="主要" data-variant="primary"
data-once="true" data-id="btn-1">主要</button>
<button data-message="主要" data-variant="primary"
data-once="true" data-id="btn-2">主要</button>
3. 自定义样式按钮
<button
data-message="自定义按钮"
data-bg-color="#27ae60"
data-text-color="#ffffff"
data-border-radius="8px"
data-padding="12px 24px"
>
绿色按钮
</button>
技术亮点
1 状态持久化方案
使用模块级别的 Set 实现跨组件实例的状态共享,解决了 React 组件重新渲染时状态丢失的问题。
2 灵活的属性配置
支持驼峰式( dataOnce )和短横线式( data-once )两种属性命名方式,提供了灵活的配置体验。
3 唯一标识生成策略
通过多层级的唯一标识生成策略,确保即使是相同内容的按钮也能正确区分。
4 渐进式增强
新增功能完全向后兼容,不影响原有按钮的使用方式。