支持消息 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
+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
+25 -13
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,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(/&nbsp;/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>