支持消息 Markdown 与欢迎语富文本

聊天/离线提示使用 Markdown 安全渲染;欢迎语改为 TipTap 富文本(颜色字号),DOMPurify 白名单清洗后展示。
This commit is contained in:
yml2213
2026-07-19 00:03:05 +08:00
parent 970bae16d1
commit fea5f4acf5
12 changed files with 3235 additions and 32 deletions
+51 -6
View File
@@ -25,9 +25,33 @@ const (
defaultTimezone = "Asia/Shanghai"
maxWelcomeSegments = 10
maxWelcomeTextLen = 500
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
)
// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。
func stripHTMLTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
// 简单实体
out := b.String()
out = strings.ReplaceAll(out, "&nbsp;", " ")
out = strings.ReplaceAll(out, "&amp;", "&")
out = strings.ReplaceAll(out, "&lt;", "<")
out = strings.ReplaceAll(out, "&gt;", ">")
return strings.TrimSpace(out)
}
// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。
type WelcomeSegment struct {
Type string `json:"type"` // text | image
@@ -47,9 +71,21 @@ func defaultWelcomeMessagesJSON() string {
func firstWelcomeText(segs []WelcomeSegment) string {
for _, s := range segs {
if s.Type == "text" && strings.TrimSpace(s.Content) != "" {
return s.Content
if s.Type != "text" {
continue
}
plain := stripHTMLTags(s.Content)
if plain == "" {
plain = strings.TrimSpace(s.Content)
}
if plain == "" {
continue
}
// welcome_message 列 size:500
if utf8.RuneCountInString(plain) > 500 {
return string([]rune(plain)[:500])
}
return plain
}
return defaultWelcomeMessage
}
@@ -96,11 +132,20 @@ func normalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string,
c := strings.TrimSpace(seg.Content)
switch t {
case "text":
if c == "" {
// 允许 HTML 富文本;禁止明显脚本
lower := strings.ToLower(c)
if strings.Contains(lower, "<script") || strings.Contains(lower, "javascript:") {
return nil, "", fmt.Errorf("第 %d 段欢迎语包含不安全内容", i+1)
}
plain := stripHTMLTags(c)
if plain == "" {
return nil, "", fmt.Errorf("第 %d 段文本不能为空", i+1)
}
if utf8.RuneCountInString(c) > maxWelcomeTextLen {
return nil, "", fmt.Errorf("第 %d 段文不能超过 %d 字", i+1, maxWelcomeTextLen)
if utf8.RuneCountInString(plain) > maxWelcomeTextLen {
return nil, "", fmt.Errorf("第 %d 段文不能超过 %d 字", i+1, maxWelcomeTextLen)
}
if utf8.RuneCountInString(c) > maxWelcomeHTMLLen {
return nil, "", fmt.Errorf("第 %d 段内容过长", i+1)
}
out = append(out, WelcomeSegment{Type: "text", Content: c})
case "image":
+2248 -2
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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",
+146
View File
@@ -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
}
/**
* 安全渲染 MarkdownGFM 子集 + 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 Formvalue / 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>
)
}
+116
View File
@@ -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,限制 1224px 量级
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 }}
/>
)
}
+21
View File
@@ -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;
+2 -1
View File
@@ -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>
+10 -4
View File
@@ -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
+23 -11
View File
@@ -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,10 +836,13 @@ const Settings = () => {
}
for (let i = 0; i < welcomeSegments.length; i++) {
const seg = welcomeSegments[i]
if (seg.type === 'text' && !seg.content.trim()) {
if (seg.type === 'text') {
const plain = seg.content.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim()
if (!plain) {
message.error(`${i + 1} 段文本不能为空`)
return
}
}
if (seg.type === 'image' && !seg.content.trim()) {
message.error(`${i + 1} 段请上传图片`)
return
@@ -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>
+15 -5
View File
@@ -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 && (