支持消息 Markdown 与欢迎语富文本
聊天/离线提示使用 Markdown 安全渲染;欢迎语改为 TipTap 富文本(颜色字号),DOMPurify 白名单清洗后展示。
This commit is contained in:
Generated
+2248
-2
File diff suppressed because it is too large
Load Diff
@@ -13,13 +13,26 @@
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tiptap/extension-color": "^3.28.0",
|
||||
"@tiptap/extension-link": "^3.28.0",
|
||||
"@tiptap/extension-text-align": "^3.28.0",
|
||||
"@tiptap/extension-text-style": "^3.28.0",
|
||||
"@tiptap/extension-underline": "^3.28.0",
|
||||
"@tiptap/pm": "^3.28.0",
|
||||
"@tiptap/react": "^3.28.0",
|
||||
"@tiptap/starter-kit": "^3.28.0",
|
||||
"antd": "^6.5.1",
|
||||
"dompurify": "^3.4.12",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwindcss": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import type { Components } from 'react-markdown'
|
||||
|
||||
/** 列表/预览用:去掉常见 MD 标记,保留纯文本 */
|
||||
export function stripMarkdown(src: string): string {
|
||||
if (!src) return ''
|
||||
return src
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
.replace(/^>\s+/gm, '')
|
||||
.replace(/^[-*+]\s+/gm, '')
|
||||
.replace(/^\d+\.\s+/gm, '')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const schema = {
|
||||
...defaultSchema,
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames || []),
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
a: [...(defaultSchema.attributes?.a || []), 'href', 'title', 'target', 'rel'],
|
||||
code: [...(defaultSchema.attributes?.code || []), 'className'],
|
||||
},
|
||||
// 不开放任意 style / class 做颜色
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
href: ['http', 'https', 'mailto'],
|
||||
},
|
||||
}
|
||||
|
||||
type Tone = 'default' | 'inverse'
|
||||
|
||||
const componentsFor = (tone: Tone): Components => {
|
||||
const linkClass =
|
||||
tone === 'inverse'
|
||||
? 'underline text-white/95 hover:text-white break-all'
|
||||
: 'underline text-[#2563eb] hover:text-[#1d4ed8] break-all'
|
||||
const codeInline =
|
||||
tone === 'inverse'
|
||||
? 'px-1 py-0.5 rounded bg-white/15 text-[0.9em] font-mono'
|
||||
: 'px-1 py-0.5 rounded bg-neutral-100 text-neutral-800 text-[0.9em] font-mono'
|
||||
const codeBlock =
|
||||
tone === 'inverse'
|
||||
? 'block p-2 rounded-lg bg-black/20 text-[12px] font-mono overflow-x-auto my-1'
|
||||
: 'block p-2 rounded-lg bg-neutral-100 text-neutral-800 text-[12px] font-mono overflow-x-auto my-1'
|
||||
|
||||
return {
|
||||
p: ({ children }) => <p className="m-0 mb-1.5 last:mb-0 leading-normal whitespace-pre-wrap break-words">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="opacity-80">{children}</del>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="m-0 mb-1.5 pl-4 list-disc last:mb-0">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="m-0 mb-1.5 pl-4 list-decimal last:mb-0">{children}</ol>,
|
||||
li: ({ children }) => <li className="my-0.5 leading-normal">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className={
|
||||
tone === 'inverse'
|
||||
? 'm-0 mb-1.5 pl-2 border-l-2 border-white/40 opacity-95'
|
||||
: 'm-0 mb-1.5 pl-2 border-l-2 border-neutral-300 text-neutral-600'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
h1: ({ children }) => <p className="m-0 mb-1 font-semibold text-[1.05em] leading-snug">{children}</p>,
|
||||
h2: ({ children }) => <p className="m-0 mb-1 font-semibold text-[1.02em] leading-snug">{children}</p>,
|
||||
h3: ({ children }) => <p className="m-0 mb-1 font-semibold leading-snug">{children}</p>,
|
||||
h4: ({ children }) => <p className="m-0 mb-1 font-semibold leading-snug">{children}</p>,
|
||||
h5: ({ children }) => <p className="m-0 mb-1 font-semibold leading-snug">{children}</p>,
|
||||
h6: ({ children }) => <p className="m-0 mb-1 font-semibold leading-snug">{children}</p>,
|
||||
hr: () => (
|
||||
<hr className={tone === 'inverse' ? 'my-2 border-white/20' : 'my-2 border-neutral-200'} />
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className)
|
||||
if (isBlock) {
|
||||
return <code className={`${codeBlock} ${className || ''}`}>{children}</code>
|
||||
}
|
||||
return <code className={codeInline}>{children}</code>
|
||||
},
|
||||
pre: ({ children }) => <pre className="m-0 mb-1.5 last:mb-0 overflow-x-auto">{children}</pre>,
|
||||
// 禁用 MD 内嵌图片,统一走 type=image 消息
|
||||
img: () => null,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-1">
|
||||
<table className="text-[12px] border-collapse w-full">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className={tone === 'inverse' ? 'border border-white/20 px-1.5 py-0.5 text-left' : 'border border-neutral-200 px-1.5 py-0.5 text-left bg-neutral-50'}>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className={tone === 'inverse' ? 'border border-white/20 px-1.5 py-0.5' : 'border border-neutral-200 px-1.5 py-0.5'}>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownBodyProps {
|
||||
children: string
|
||||
/** inverse:坐席发出的蓝底白字气泡 */
|
||||
tone?: Tone
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全渲染 Markdown(GFM 子集 + sanitize)。
|
||||
* 用于欢迎语、离线提示、聊天气泡等。
|
||||
*/
|
||||
export default function MarkdownBody({ children, tone = 'default', className = '' }: MarkdownBodyProps) {
|
||||
const src = children ?? ''
|
||||
if (!src.trim()) return null
|
||||
|
||||
return (
|
||||
<div className={`md-body text-[inherit] leading-normal break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||
components={componentsFor(tone)}
|
||||
>
|
||||
{src}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Button, Input, Modal, Tooltip, message } from 'antd'
|
||||
import {
|
||||
BoldOutlined, ItalicOutlined, StrikethroughOutlined, OrderedListOutlined,
|
||||
UnorderedListOutlined, LinkOutlined, EyeOutlined, EditOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
rows?: number
|
||||
/** 是否显示字数 */
|
||||
showCount?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
type WrapMode = 'bold' | 'italic' | 'strike' | 'code'
|
||||
|
||||
function wrapSelection(
|
||||
el: HTMLTextAreaElement,
|
||||
value: string,
|
||||
before: string,
|
||||
after: string,
|
||||
placeholder = '文本',
|
||||
): { next: string; start: number; end: number } {
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const selected = value.slice(start, end)
|
||||
const inner = selected || placeholder
|
||||
const inserted = before + inner + after
|
||||
const next = value.slice(0, start) + inserted + value.slice(end)
|
||||
const selStart = start + before.length
|
||||
const selEnd = selStart + inner.length
|
||||
return { next, start: selStart, end: selEnd }
|
||||
}
|
||||
|
||||
function prefixLines(
|
||||
el: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
): { next: string; start: number; end: number } {
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
// 扩展到整行
|
||||
let lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
let lineEnd = value.indexOf('\n', end)
|
||||
if (lineEnd < 0) lineEnd = value.length
|
||||
const block = value.slice(lineStart, lineEnd)
|
||||
const lines = block.split('\n')
|
||||
const numbered = prefix === '1. '
|
||||
const nextBlock = lines
|
||||
.map((line, i) => {
|
||||
const bare = line.replace(/^(\s*)([-*+]|\d+\.)\s+/, '$1')
|
||||
if (numbered) return `${i + 1}. ${bare}`
|
||||
return `- ${bare}`
|
||||
})
|
||||
.join('\n')
|
||||
const next = value.slice(0, lineStart) + nextBlock + value.slice(lineEnd)
|
||||
return { next, start: lineStart, end: lineStart + nextBlock.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 可视化 Markdown 编辑:工具栏点击即可加粗/列表/链接/表情,底层仍存 MD 字符串。
|
||||
* 兼容 antd Form(value / onChange)。
|
||||
*/
|
||||
export default function MarkdownEditor({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '输入内容,可用上方按钮设置格式…',
|
||||
maxLength = 500,
|
||||
rows = 3,
|
||||
showCount = true,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: MarkdownEditorProps) {
|
||||
const taRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [preview, setPreview] = useState(false)
|
||||
const [linkOpen, setLinkOpen] = useState(false)
|
||||
const [linkText, setLinkText] = useState('')
|
||||
const [linkUrl, setLinkUrl] = useState('https://')
|
||||
|
||||
const apply = (next: string, selStart: number, selEnd: number) => {
|
||||
const clipped = maxLength > 0 && next.length > maxLength ? next.slice(0, maxLength) : next
|
||||
onChange?.(clipped)
|
||||
requestAnimationFrame(() => {
|
||||
const el = taRef.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
const s = Math.min(selStart, clipped.length)
|
||||
const e = Math.min(selEnd, clipped.length)
|
||||
el.setSelectionRange(s, e)
|
||||
})
|
||||
}
|
||||
|
||||
const withTextarea = (fn: (el: HTMLTextAreaElement) => void) => {
|
||||
const el = taRef.current
|
||||
if (!el) {
|
||||
message.info('请先点击输入框再使用格式按钮')
|
||||
return
|
||||
}
|
||||
fn(el)
|
||||
}
|
||||
|
||||
const onWrap = (mode: WrapMode) => {
|
||||
withTextarea(el => {
|
||||
const map: Record<WrapMode, [string, string, string]> = {
|
||||
bold: ['**', '**', '加粗文字'],
|
||||
italic: ['*', '*', '斜体文字'],
|
||||
strike: ['~~', '~~', '删除线'],
|
||||
code: ['`', '`', '代码'],
|
||||
}
|
||||
const [b, a, ph] = map[mode]
|
||||
const { next, start, end } = wrapSelection(el, value, b, a, ph)
|
||||
apply(next, start, end)
|
||||
})
|
||||
}
|
||||
|
||||
const onList = (ordered: boolean) => {
|
||||
withTextarea(el => {
|
||||
const { next, start, end } = prefixLines(el, value, ordered ? '1. ' : '- ')
|
||||
apply(next, start, end)
|
||||
})
|
||||
}
|
||||
|
||||
const openLink = () => {
|
||||
withTextarea(el => {
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const selected = value.slice(start, end)
|
||||
setLinkText(selected || '链接文字')
|
||||
setLinkUrl('https://')
|
||||
setLinkOpen(true)
|
||||
})
|
||||
}
|
||||
|
||||
const confirmLink = () => {
|
||||
const el = taRef.current
|
||||
if (!el) {
|
||||
setLinkOpen(false)
|
||||
return
|
||||
}
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const text = (linkText || '链接').trim()
|
||||
let url = (linkUrl || '').trim()
|
||||
if (url && !/^https?:\/\//i.test(url) && !url.startsWith('mailto:')) {
|
||||
url = `https://${url}`
|
||||
}
|
||||
if (!url) {
|
||||
message.warning('请填写链接地址')
|
||||
return
|
||||
}
|
||||
const md = `[${text}](${url})`
|
||||
const next = value.slice(0, start) + md + value.slice(end)
|
||||
apply(next, start + md.length, start + md.length)
|
||||
setLinkOpen(false)
|
||||
}
|
||||
|
||||
const onEmoji = (emoji: string) => {
|
||||
const el = taRef.current
|
||||
if (!el) {
|
||||
onChange?.((value || '') + emoji)
|
||||
return
|
||||
}
|
||||
const { next, cursor } = insertAtCursor(el, value, emoji)
|
||||
apply(next, cursor, cursor)
|
||||
}
|
||||
|
||||
const count = [...(value || '')].length
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-neutral-200 bg-white overflow-hidden ${className}`}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1 border-b border-neutral-100 bg-neutral-50">
|
||||
<Tooltip title="加粗">
|
||||
<Button type="text" size="small" icon={<BoldOutlined />} disabled={disabled || preview} onClick={() => onWrap('bold')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="斜体">
|
||||
<Button type="text" size="small" icon={<ItalicOutlined />} disabled={disabled || preview} onClick={() => onWrap('italic')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除线">
|
||||
<Button type="text" size="small" icon={<StrikethroughOutlined />} disabled={disabled || preview} onClick={() => onWrap('strike')} />
|
||||
</Tooltip>
|
||||
<span className="w-px h-4 bg-neutral-200 mx-0.5" />
|
||||
<Tooltip title="无序列表">
|
||||
<Button type="text" size="small" icon={<UnorderedListOutlined />} disabled={disabled || preview} onClick={() => onList(false)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="有序列表">
|
||||
<Button type="text" size="small" icon={<OrderedListOutlined />} disabled={disabled || preview} onClick={() => onList(true)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="插入链接">
|
||||
<Button type="text" size="small" icon={<LinkOutlined />} disabled={disabled || preview} onClick={openLink} />
|
||||
</Tooltip>
|
||||
<span className="w-px h-4 bg-neutral-200 mx-0.5" />
|
||||
<span className="inline-flex items-center [&_button]:!w-7 [&_button]:!h-7 [&_button]:!text-neutral-600">
|
||||
<EmojiPicker
|
||||
onSelect={onEmoji}
|
||||
disabled={disabled || preview}
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-neutral-600 hover:bg-neutral-100 disabled:opacity-40 border-0 bg-transparent cursor-pointer"
|
||||
title="表情"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Tooltip title={preview ? '返回编辑' : '预览效果'}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={preview ? <EditOutlined /> : <EyeOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => setPreview(p => !p)}
|
||||
>
|
||||
<span className="text-xs">{preview ? '编辑' : '预览'}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="min-h-[72px] px-3 py-2 text-sm text-neutral-800">
|
||||
{value?.trim() ? (
|
||||
<MarkdownBody>{value}</MarkdownBody>
|
||||
) : (
|
||||
<span className="text-neutral-400 text-sm">暂无内容</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
maxLength={maxLength > 0 ? maxLength : undefined}
|
||||
placeholder={placeholder}
|
||||
className="w-full resize-y border-0 outline-none px-3 py-2 text-sm text-neutral-800 leading-6 bg-transparent placeholder:text-neutral-400 min-h-[72px]"
|
||||
onChange={e => onChange?.(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCount && maxLength > 0 && (
|
||||
<div className="px-3 py-1 text-right text-xs text-neutral-400 border-t border-neutral-50">
|
||||
{count} / {maxLength}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="插入链接"
|
||||
open={linkOpen}
|
||||
onCancel={() => setLinkOpen(false)}
|
||||
onOk={confirmLink}
|
||||
okText="插入"
|
||||
destroyOnHidden
|
||||
width={400}
|
||||
>
|
||||
<div className="space-y-3 mt-2">
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500 mb-1">显示文字</div>
|
||||
<Input value={linkText} onChange={e => setLinkText(e.target.value)} placeholder="链接文字" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500 mb-1">网址</div>
|
||||
<Input value={linkUrl} onChange={e => setLinkUrl(e.target.value)} placeholder="https://" />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TextStyle } from '@tiptap/extension-text-style'
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Button, Dropdown, Tooltip } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import {
|
||||
BoldOutlined, ItalicOutlined, UnderlineOutlined, StrikethroughOutlined,
|
||||
UnorderedListOutlined, OrderedListOutlined, LinkOutlined,
|
||||
FontColorsOutlined, FontSizeOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker from '@/components/common/EmojiPicker'
|
||||
|
||||
/** 扩展 textStyle 支持 font-size */
|
||||
const FontSize = Extension.create({
|
||||
name: 'fontSize',
|
||||
addOptions() {
|
||||
return { types: ['textStyle'] }
|
||||
},
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
fontSize: {
|
||||
default: null,
|
||||
parseHTML: element => element.style.fontSize || null,
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.fontSize) return {}
|
||||
return { style: `font-size: ${attributes.fontSize}` }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setFontSize:
|
||||
(fontSize: string) =>
|
||||
({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize }).run(),
|
||||
unsetFontSize:
|
||||
() =>
|
||||
({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fontSize: {
|
||||
setFontSize: (fontSize: string) => ReturnType
|
||||
unsetFontSize: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ label: '默认黑', value: '#333333' },
|
||||
{ label: '强调红', value: '#e60000' },
|
||||
{ label: '链接蓝', value: '#2563eb' },
|
||||
{ label: '成功绿', value: '#16a34a' },
|
||||
{ label: '警示橙', value: '#d97706' },
|
||||
{ label: '灰色', value: '#64748b' },
|
||||
]
|
||||
|
||||
const PRESET_SIZES = [
|
||||
{ label: '小', value: '12px' },
|
||||
{ label: '默认', value: '14px' },
|
||||
{ label: '中', value: '16px' },
|
||||
{ label: '大', value: '18px' },
|
||||
]
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
value?: string
|
||||
onChange?: (html: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 欢迎语专用富文本编辑器(HTML)。
|
||||
* 支持:加粗/斜体/下划线/颜色/字号/列表/链接/表情。
|
||||
* 聊天消息请继续用 Markdown,不要用此组件。
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '输入欢迎语,选中文字后设置颜色与字号…',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [3, 4] },
|
||||
codeBlock: false,
|
||||
code: false,
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
FontSize,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
}),
|
||||
],
|
||||
content: value || '',
|
||||
editable: !disabled,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'prose prose-sm max-w-none min-h-[88px] px-3 py-2 outline-none text-[14px] text-neutral-800 leading-relaxed focus:outline-none',
|
||||
'data-placeholder': placeholder,
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const html = ed.isEmpty ? '' : ed.getHTML()
|
||||
onChange?.(html)
|
||||
},
|
||||
})
|
||||
|
||||
// 外部 value 变化时同步(切换段落 / 加载设置)
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const current = editor.isEmpty ? '' : editor.getHTML()
|
||||
const next = value || ''
|
||||
if (next !== current) {
|
||||
editor.commands.setContent(next || '', { emitUpdate: false })
|
||||
}
|
||||
}, [value, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.setEditable(!disabled)
|
||||
}, [disabled, editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const setLink = () => {
|
||||
const prev = editor.getAttributes('link').href as string | undefined
|
||||
const url = window.prompt('链接地址', prev || 'https://')
|
||||
if (url === null) return
|
||||
const t = url.trim()
|
||||
if (t === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
return
|
||||
}
|
||||
const href = /^https?:\/\//i.test(t) || t.startsWith('mailto:') ? t : `https://${t}`
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||
}
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
editor.chain().focus().insertContent(emoji).run()
|
||||
}
|
||||
|
||||
const btn = (active: boolean) =>
|
||||
`!w-7 !h-7 !inline-flex !items-center !justify-center ${active ? '!bg-blue-50 !text-blue-600' : ''}`
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border border-neutral-200 bg-white overflow-hidden ${className}`}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-1.5 py-1 border-b border-neutral-100 bg-neutral-50">
|
||||
<Tooltip title="加粗">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('bold'))}
|
||||
icon={<BoldOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="斜体">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('italic'))}
|
||||
icon={<ItalicOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下划线">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('underline'))}
|
||||
icon={<UnderlineOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除线">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('strike'))}
|
||||
icon={<StrikethroughOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="w-px h-4 bg-neutral-200 mx-0.5" />
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
menu={{
|
||||
items: [
|
||||
...PRESET_COLORS.map(c => ({
|
||||
key: c.value,
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm border border-neutral-200" style={{ background: c.value }} />
|
||||
{c.label}
|
||||
</span>
|
||||
),
|
||||
onClick: () => { editor.chain().focus().setColor(c.value).run() },
|
||||
})),
|
||||
{ type: 'divider' as const, key: 'd1' },
|
||||
{
|
||||
key: 'clear-color',
|
||||
label: '清除颜色',
|
||||
onClick: () => { editor.chain().focus().unsetColor().run() },
|
||||
},
|
||||
] as MenuProps['items'],
|
||||
}}
|
||||
>
|
||||
<Tooltip title="文字颜色">
|
||||
<Button type="text" size="small" className={btn(false)} icon={<FontColorsOutlined />} disabled={disabled} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
menu={{
|
||||
items: [
|
||||
...PRESET_SIZES.map(s => ({
|
||||
key: s.value,
|
||||
label: s.label,
|
||||
onClick: () => { editor.chain().focus().setFontSize(s.value).run() },
|
||||
})),
|
||||
{ type: 'divider' as const, key: 'd2' },
|
||||
{
|
||||
key: 'clear-size',
|
||||
label: '默认大小',
|
||||
onClick: () => { editor.chain().focus().unsetFontSize().run() },
|
||||
},
|
||||
] as MenuProps['items'],
|
||||
}}
|
||||
>
|
||||
<Tooltip title="字号">
|
||||
<Button type="text" size="small" className={btn(false)} icon={<FontSizeOutlined />} disabled={disabled} />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<span className="w-px h-4 bg-neutral-200 mx-0.5" />
|
||||
<Tooltip title="无序列表">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('bulletList'))}
|
||||
icon={<UnorderedListOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="有序列表">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('orderedList'))}
|
||||
icon={<OrderedListOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="链接">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={btn(editor.isActive('link'))}
|
||||
icon={<LinkOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={setLink}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="inline-flex [&_button]:!w-7 [&_button]:!h-7">
|
||||
<EmojiPicker
|
||||
onSelect={insertEmoji}
|
||||
disabled={disabled}
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-neutral-600 hover:bg-neutral-100 disabled:opacity-40 border-0 bg-transparent cursor-pointer"
|
||||
title="表情"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
<style>{`
|
||||
.ProseMirror p { margin: 0 0 0.4em; }
|
||||
.ProseMirror p:last-child { margin-bottom: 0; }
|
||||
.ProseMirror ul, .ProseMirror ol { margin: 0.25em 0; padding-left: 1.25em; }
|
||||
.ProseMirror a { color: #2563eb; text-decoration: underline; }
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { Config } from 'dompurify'
|
||||
|
||||
/** 欢迎语富文本允许的标签与属性(严格白名单) */
|
||||
const PURIFY_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'del',
|
||||
'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'h4', 'blockquote', 'div',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'style', 'class'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// 只允许 http(s)/mailto
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅放行安全的行内样式(颜色 / 字号 / 粗体相关 / 背景 / 对齐)
|
||||
*/
|
||||
function sanitizeStyle(style: string): string {
|
||||
if (!style) return ''
|
||||
const allowed = new Set([
|
||||
'color',
|
||||
'background-color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-style',
|
||||
'text-decoration',
|
||||
'text-align',
|
||||
])
|
||||
const parts: string[] = []
|
||||
for (const decl of style.split(';')) {
|
||||
const idx = decl.indexOf(':')
|
||||
if (idx < 0) continue
|
||||
const prop = decl.slice(0, idx).trim().toLowerCase()
|
||||
let val = decl.slice(idx + 1).trim()
|
||||
if (!allowed.has(prop) || !val) continue
|
||||
// 拒绝 url( / expression / javascript
|
||||
if (/url\s*\(|expression\s*\(|javascript:/i.test(val)) continue
|
||||
if (prop === 'font-size') {
|
||||
// 仅 px / em / rem,限制 12–24px 量级
|
||||
const m = val.match(/^(\d+(?:\.\d+)?)(px|em|rem)$/i)
|
||||
if (!m) continue
|
||||
const n = parseFloat(m[1])
|
||||
const unit = m[2].toLowerCase()
|
||||
if (unit === 'px' && (n < 12 || n > 24)) continue
|
||||
if ((unit === 'em' || unit === 'rem') && (n < 0.75 || n > 1.5)) continue
|
||||
val = `${n}${unit}`
|
||||
}
|
||||
if (prop === 'color' || prop === 'background-color') {
|
||||
// #rgb / #rrggbb / rgb() / rgba() / 常见色名
|
||||
if (!/^(#[0-9a-f]{3,8}|rgb\(|rgba\(|[a-z]+)$/i.test(val.replace(/\s/g, ''))) {
|
||||
// allow spaces in rgb(1, 2, 3)
|
||||
if (!/^(#[0-9a-f]{3,8}|rgba?\([^)]+\)|[a-z]+)$/i.test(val)) continue
|
||||
}
|
||||
}
|
||||
parts.push(`${prop}: ${val}`)
|
||||
}
|
||||
return parts.join('; ')
|
||||
}
|
||||
|
||||
function hookStyles(node: Element) {
|
||||
if (node.hasAttribute('style')) {
|
||||
const cleaned = sanitizeStyle(node.getAttribute('style') || '')
|
||||
if (cleaned) node.setAttribute('style', cleaned)
|
||||
else node.removeAttribute('style')
|
||||
}
|
||||
if (node.tagName === 'A') {
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
let hooksInstalled = false
|
||||
function ensureHooks() {
|
||||
if (hooksInstalled || typeof window === 'undefined') return
|
||||
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
||||
if (data.attrName === 'style') {
|
||||
data.attrValue = sanitizeStyle(data.attrValue || '')
|
||||
if (!data.attrValue) data.keepAttr = false
|
||||
}
|
||||
})
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||
if (node instanceof Element) hookStyles(node)
|
||||
})
|
||||
hooksInstalled = true
|
||||
}
|
||||
|
||||
/** 判断字符串是否像 HTML(用于兼容旧纯文本欢迎语) */
|
||||
export function looksLikeHtml(s: string): boolean {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(s || '')
|
||||
}
|
||||
|
||||
export function sanitizeWelcomeHtml(html: string): string {
|
||||
if (!html?.trim()) return ''
|
||||
ensureHooks()
|
||||
return String(DOMPurify.sanitize(html, PURIFY_CONFIG))
|
||||
}
|
||||
|
||||
export interface SafeHtmlProps {
|
||||
html: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 安全渲染欢迎语 HTML(仅白名单标签与样式) */
|
||||
export default function SafeHtml({ html, className = '' }: SafeHtmlProps) {
|
||||
const clean = sanitizeWelcomeHtml(html)
|
||||
if (!clean) return null
|
||||
return (
|
||||
<div
|
||||
className={`welcome-html text-[13px] leading-normal break-words ${className}`}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,27 @@
|
||||
--shadow-floating: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 欢迎语富文本(SafeHtml)气泡内样式 */
|
||||
.welcome-html p {
|
||||
margin: 0 0 0.35em;
|
||||
}
|
||||
.welcome-html p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.welcome-html ul,
|
||||
.welcome-html ol {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
.welcome-html a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
.welcome-html strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AvailableAgent, type Channel, type Message, type Session, type SessionEvent,
|
||||
} from '@/services/api'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
@@ -672,7 +673,7 @@ const ChatHistory = () => {
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-48 max-h-48" />
|
||||
) : (
|
||||
<p className="text-sm text-neutral-800 m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
<MarkdownBody className="text-sm text-neutral-800">{msg.content}</MarkdownBody>
|
||||
)}
|
||||
<span className="text-xs text-neutral-400 mt-0.5 block">{formatTime(msg.sent_at)}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
||||
@@ -327,7 +328,7 @@ const Dashboard = () => {
|
||||
cursor = lastBatchSeq
|
||||
}
|
||||
if (latest && selectedIdRef.current === id) {
|
||||
const preview = latest.type === 'image' ? '[图片]' : latest.content
|
||||
const preview = latest.type === 'image' ? '[图片]' : stripMarkdown(latest.content)
|
||||
setSessions(previous => previous.map(session =>
|
||||
session.id === id
|
||||
? {
|
||||
@@ -360,7 +361,7 @@ const Dashboard = () => {
|
||||
return true
|
||||
}
|
||||
|
||||
const preview = msg.type === 'image' ? '[图片]' : msg.content
|
||||
const preview = msg.type === 'image' ? '[图片]' : stripMarkdown(msg.content)
|
||||
let appended = false
|
||||
setDetail(previous => {
|
||||
if (selectedIdRef.current !== sessionId) return previous
|
||||
@@ -1183,7 +1184,12 @@ const Dashboard = () => {
|
||||
{message.type === 'image' ? (
|
||||
<ChatImage src={message.content} alt="聊天图片" className="max-w-64 max-h-64" />
|
||||
) : (
|
||||
<p className="text-sm leading-normal whitespace-pre-wrap break-words m-0">{message.content}</p>
|
||||
<MarkdownBody
|
||||
tone={isAgent ? 'inverse' : 'default'}
|
||||
className="text-sm"
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs text-neutral-400 ${isAgent ? 'text-right' : ''}`}>
|
||||
@@ -1289,7 +1295,7 @@ const Dashboard = () => {
|
||||
ref={messageInputRef}
|
||||
rows={3}
|
||||
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
|
||||
placeholder="输入回复内容… / 调话术,Enter 发送,Shift+Enter 换行"
|
||||
placeholder="支持 Markdown(**加粗** *斜体* 列表 链接)… / 调话术,Enter 发送"
|
||||
value={messageInput}
|
||||
onChange={event => {
|
||||
const v = event.target.value
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import MarkdownEditor from '@/components/common/MarkdownEditor'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor'
|
||||
|
||||
const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [
|
||||
{ value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' },
|
||||
@@ -834,9 +836,12 @@ const Settings = () => {
|
||||
}
|
||||
for (let i = 0; i < welcomeSegments.length; i++) {
|
||||
const seg = welcomeSegments[i]
|
||||
if (seg.type === 'text' && !seg.content.trim()) {
|
||||
message.error(`第 ${i + 1} 段文本不能为空`)
|
||||
return
|
||||
if (seg.type === 'text') {
|
||||
const plain = seg.content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()
|
||||
if (!plain) {
|
||||
message.error(`第 ${i + 1} 段文本不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (seg.type === 'image' && !seg.content.trim()) {
|
||||
message.error(`第 ${i + 1} 段请上传图片`)
|
||||
@@ -857,7 +862,8 @@ const Settings = () => {
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">欢迎语(多段)</div>
|
||||
<p className="text-xs text-neutral-500 m-0 mt-0.5">
|
||||
访客打开窗口且尚无消息时按顺序展示;可混排文本与图片,最多 {MAX_WELCOME_SEGMENTS} 段。
|
||||
访客打开窗口且尚无消息时按顺序展示;可混排富文本与图片,最多 {MAX_WELCOME_SEGMENTS} 段。
|
||||
选中文字可设置<strong className="font-medium text-neutral-600">颜色、字号、加粗</strong>等(仅欢迎语;正常聊天仍为 Markdown)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -913,14 +919,10 @@ const Settings = () => {
|
||||
</div>
|
||||
</div>
|
||||
{seg.type === 'text' ? (
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
showCount
|
||||
<RichTextEditor
|
||||
value={seg.content}
|
||||
placeholder="访客打开窗口时展示的文案"
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
placeholder="输入欢迎语,选中文字后设置颜色、字号…"
|
||||
onChange={v => {
|
||||
setWelcomeSegments(prev =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, content: v } : s)),
|
||||
)
|
||||
@@ -1006,8 +1008,18 @@ const Settings = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="offline_prompt" label="离线留言提示" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
||||
<Form.Item
|
||||
name="offline_prompt"
|
||||
label="离线留言提示"
|
||||
rules={[{ max: 500 }]}
|
||||
extra="点工具栏设置加粗、列表、链接、表情;可点「预览」查看效果"
|
||||
>
|
||||
<MarkdownEditor
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="无客服在线时展示…"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item className="!mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存</Button>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||||
import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml'
|
||||
|
||||
interface Message {
|
||||
id: number
|
||||
@@ -949,15 +951,23 @@ const VisitorChat = ({
|
||||
) : (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal whitespace-pre-wrap"
|
||||
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal"
|
||||
>
|
||||
{seg.content}
|
||||
{looksLikeHtml(seg.content) ? (
|
||||
<SafeHtml html={seg.content} />
|
||||
) : (
|
||||
<MarkdownBody>{seg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-600 leading-normal">
|
||||
{offlinePrompt}
|
||||
{looksLikeHtml(offlinePrompt) ? (
|
||||
<SafeHtml html={offlinePrompt} />
|
||||
) : (
|
||||
<MarkdownBody>{offlinePrompt}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -986,7 +996,7 @@ const VisitorChat = ({
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
<MarkdownBody tone="inverse">{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
@@ -1003,7 +1013,7 @@ const VisitorChat = ({
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
<MarkdownBody>{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
|
||||
Reference in New Issue
Block a user