完善访客结束流程与转接可见性
- 会话结束后支持重新咨询,评价改为选星+快捷文案再提交 - 转接/分配事件写入操作人与目标坐席,工作台时间线与备注可见
This commit is contained in:
@@ -594,11 +594,39 @@ func (h *SessionHandler) Assign(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
model.DB.Create(&model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "assign", Detail: "会话分配"})
|
||||
operatorID := middleware.GetUserID(c)
|
||||
operatorName := userDisplayName(operatorID)
|
||||
targetName := userDisplayName(req.AgentID)
|
||||
detail := "会话分配给 " + targetName
|
||||
if operatorID == req.AgentID {
|
||||
detail = operatorName + " 领取了会话"
|
||||
} else {
|
||||
detail = operatorName + " 将会话分配给 " + targetName
|
||||
}
|
||||
model.DB.Create(&model.SessionEvent{
|
||||
SessionID: session.ID,
|
||||
OperatorID: operatorID,
|
||||
Action: "assign",
|
||||
Detail: detail,
|
||||
})
|
||||
session.AgentID = &req.AgentID
|
||||
session.Status = "active"
|
||||
broadcastSessionUpdate(session)
|
||||
middleware.JSON(c, gin.H{"message": "分配成功"})
|
||||
middleware.JSON(c, gin.H{"message": "分配成功", "detail": detail})
|
||||
}
|
||||
|
||||
func userDisplayName(userID uint) string {
|
||||
var user model.User
|
||||
if err := model.DB.Select("id", "nickname", "username").First(&user, userID).Error; err != nil {
|
||||
return "未知坐席"
|
||||
}
|
||||
if strings.TrimSpace(user.Nickname) != "" {
|
||||
return user.Nickname
|
||||
}
|
||||
if strings.TrimSpace(user.Username) != "" {
|
||||
return user.Username
|
||||
}
|
||||
return "未知坐席"
|
||||
}
|
||||
|
||||
func (h *SessionHandler) Transfer(c *gin.Context) {
|
||||
@@ -621,6 +649,19 @@ func (h *SessionHandler) Transfer(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"})
|
||||
return
|
||||
}
|
||||
if session.AgentID != nil && *session.AgentID == req.AgentID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能转接给当前接待坐席"})
|
||||
return
|
||||
}
|
||||
|
||||
operatorID := middleware.GetUserID(c)
|
||||
operatorName := userDisplayName(operatorID)
|
||||
targetName := userDisplayName(req.AgentID)
|
||||
fromName := ""
|
||||
if session.AgentID != nil {
|
||||
fromName = userDisplayName(*session.AgentID)
|
||||
}
|
||||
|
||||
result := model.DB.Model(&model.Session{}).
|
||||
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active").
|
||||
Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0})
|
||||
@@ -633,16 +674,22 @@ func (h *SessionHandler) Transfer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 详情写明「谁 → 谁」,便于接手坐席在备注/时间线中一眼看清
|
||||
detail := operatorName + " 转接给 " + targetName
|
||||
if fromName != "" && fromName != operatorName {
|
||||
detail = operatorName + " 将会话由 " + fromName + " 转接给 " + targetName
|
||||
}
|
||||
|
||||
model.DB.Create(&model.SessionEvent{
|
||||
SessionID: session.ID,
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
OperatorID: operatorID,
|
||||
Action: "transfer",
|
||||
Detail: "会话转接",
|
||||
Detail: detail,
|
||||
})
|
||||
session.AgentID = &req.AgentID
|
||||
broadcastSessionUpdate(session)
|
||||
|
||||
middleware.JSON(c, gin.H{"message": "转接成功"})
|
||||
middleware.JSON(c, gin.H{"message": "转接成功", "detail": detail})
|
||||
}
|
||||
|
||||
func (h *SessionHandler) End(c *gin.Context) {
|
||||
|
||||
@@ -422,7 +422,8 @@ const Dashboard = () => {
|
||||
|
||||
if (payload.type === 'session_created' || payload.type === 'session_updated') {
|
||||
void loadAllRef.current({ silent: true })
|
||||
if (sameSession) void syncAfterSeqRef.current(sid, { markRead: false })
|
||||
// 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events)
|
||||
if (sameSession) void loadDetailRef.current(sid, false, { silent: true })
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
@@ -524,7 +525,51 @@ const Dashboard = () => {
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
const notes = detail?.events.filter(event => event.action === 'note' || event.action === 'offline_leave' || event.action === 'auto_assign').slice().reverse() || []
|
||||
const timelineEvents = (detail?.events || []).filter(event =>
|
||||
event.action === 'transfer'
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'end'
|
||||
|| event.action === 'offline_leave',
|
||||
)
|
||||
const notes = detail?.events
|
||||
.filter(event =>
|
||||
event.action === 'note'
|
||||
|| event.action === 'offline_leave'
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'transfer'
|
||||
|| event.action === 'end',
|
||||
)
|
||||
.slice()
|
||||
.reverse() || []
|
||||
|
||||
type ChatTimelineItem =
|
||||
| { kind: 'message'; at: number; message: Message }
|
||||
| { kind: 'event'; at: number; event: SessionEvent }
|
||||
|
||||
const chatTimeline: ChatTimelineItem[] = (() => {
|
||||
const items: ChatTimelineItem[] = []
|
||||
for (const message of detail?.messages || []) {
|
||||
items.push({ kind: 'message', at: new Date(message.sent_at).getTime(), message })
|
||||
}
|
||||
for (const event of timelineEvents) {
|
||||
items.push({ kind: 'event', at: new Date(event.created_at).getTime(), event })
|
||||
}
|
||||
items.sort((a, b) => a.at - b.at || (a.kind === 'event' ? -1 : 1))
|
||||
return items
|
||||
})()
|
||||
|
||||
const eventLabel = (action: string) => {
|
||||
switch (action) {
|
||||
case 'transfer': return '会话转接'
|
||||
case 'assign': return '人工分配'
|
||||
case 'auto_assign': return '自动分配'
|
||||
case 'end': return '结束会话'
|
||||
case 'offline_leave': return '离线留言'
|
||||
default: return '系统记录'
|
||||
}
|
||||
}
|
||||
|
||||
const emitTyping = () => {
|
||||
if (!selectedId || !canOperate) return
|
||||
@@ -939,12 +984,29 @@ const Dashboard = () => {
|
||||
会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
{(detail?.messages || []).map(message => {
|
||||
{chatTimeline.map(item => {
|
||||
if (item.kind === 'event') {
|
||||
const { event } = item
|
||||
return (
|
||||
<div key={`ev-${event.id}`} className="flex justify-center mb-4">
|
||||
<span className="inline-flex flex-col items-center max-w-[90%] px-3 py-1.5 rounded-lg text-xs text-violet-700 bg-violet-50 border border-violet-100">
|
||||
<span className="font-medium text-violet-800">{eventLabel(event.action)}</span>
|
||||
<span className="text-center leading-relaxed mt-0.5">{event.detail || eventLabel(event.action)}</span>
|
||||
<span className="text-[11px] text-violet-500/80 mt-0.5">
|
||||
{new Date(event.created_at).toLocaleString('zh-CN', {
|
||||
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const message = item.message
|
||||
const isAgent = message.sender_type === 'agent'
|
||||
const visitorInitial = selectedCustomer.name.slice(0, 1)
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
key={`msg-${message.id}`}
|
||||
className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
@@ -976,7 +1038,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{detail?.messages.length === 0 && (
|
||||
{chatTimeline.length === 0 && (
|
||||
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
|
||||
)}
|
||||
{visitorTyping && selectedCustomer && (
|
||||
@@ -1207,18 +1269,42 @@ const Dashboard = () => {
|
||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||
) : notes.map(note => {
|
||||
const isLeave = note.action === 'offline_leave'
|
||||
const isAssign = note.action === 'auto_assign'
|
||||
const isAuto = note.action === 'auto_assign'
|
||||
const isTransfer = note.action === 'transfer'
|
||||
const isAssign = note.action === 'assign'
|
||||
const isEnd = note.action === 'end'
|
||||
const box = isLeave
|
||||
? 'bg-orange-50 text-orange-900 border-orange-100'
|
||||
: isAssign
|
||||
: isTransfer
|
||||
? 'bg-violet-50 text-violet-900 border-violet-100'
|
||||
: (isAuto || isAssign)
|
||||
? 'bg-blue-50 text-blue-900 border-blue-100'
|
||||
: isEnd
|
||||
? 'bg-neutral-100 text-neutral-700 border-neutral-200'
|
||||
: 'bg-amber-50 text-amber-900 border-amber-100'
|
||||
const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70'
|
||||
const timeCls = isLeave
|
||||
? 'text-orange-600/70'
|
||||
: isTransfer
|
||||
? 'text-violet-600/70'
|
||||
: (isAuto || isAssign)
|
||||
? 'text-blue-600/70'
|
||||
: isEnd
|
||||
? 'text-neutral-500'
|
||||
: 'text-amber-600/70'
|
||||
const title = isLeave
|
||||
? '离线留言'
|
||||
: isTransfer
|
||||
? '会话转接'
|
||||
: isAuto
|
||||
? '自动分配'
|
||||
: isAssign
|
||||
? '人工分配'
|
||||
: isEnd
|
||||
? '结束会话'
|
||||
: null
|
||||
return (
|
||||
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
|
||||
{(isLeave || isAssign) && (
|
||||
<div className="font-medium mb-0.5">{isLeave ? '离线留言' : '自动分配'}</div>
|
||||
)}
|
||||
{title && <div className="font-medium mb-0.5">{title}</div>}
|
||||
{note.detail}
|
||||
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
|
||||
+220
-56
@@ -54,8 +54,24 @@ const VisitorChat = ({
|
||||
const [rated, setRated] = useState(false)
|
||||
const [sessionEnded, setSessionEnded] = useState(false)
|
||||
const [agentTyping, setAgentTyping] = useState(false)
|
||||
const [ratingScore, setRatingScore] = useState(0)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const [ratingSubmitting, setRatingSubmitting] = useState(false)
|
||||
|
||||
/** 评价快捷文案:按星级展示更贴切的选项 */
|
||||
const ratingPresets = useMemo(() => {
|
||||
if (ratingScore >= 5) {
|
||||
return ['服务很棒,非常感谢!', '回复及时,专业耐心', '问题已顺利解决', '下次还会咨询']
|
||||
}
|
||||
if (ratingScore === 4) {
|
||||
return ['整体不错,体验良好', '基本解决了问题', '态度很好,继续加油', '回复比较及时']
|
||||
}
|
||||
if (ratingScore >= 1 && ratingScore <= 3) {
|
||||
return ['等待时间偏长', '问题未完全解决', '希望回复更清晰一些', '需要再跟进一下']
|
||||
}
|
||||
return ['服务很好,感谢!', '回复及时,很专业', '问题已解决', '等待时间稍长']
|
||||
}, [ratingScore])
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [sendError, setSendError] = useState('')
|
||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||
@@ -256,9 +272,96 @@ const VisitorChat = ({
|
||||
return true
|
||||
}, [])
|
||||
|
||||
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
|
||||
const bootstrapNewSession = useCallback(async () => {
|
||||
const res = await fetch(
|
||||
`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const json = await res.json()
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '初始化会话失败')
|
||||
}
|
||||
const data = json.data
|
||||
const sid = data.session_id as number
|
||||
const token = data.visitor_token as string
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
sessionIdRef.current = sid
|
||||
visitorTokenRef.current = token
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
else setAgentName('')
|
||||
setSessionEnded(false)
|
||||
setRated(false)
|
||||
setShowRating(false)
|
||||
setLeaveSent(false)
|
||||
setMessages([])
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
localStorage.removeItem(msgsKey)
|
||||
lastSeqRef.current = 0
|
||||
await loadMessages(sid, token, { full: true })
|
||||
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
const sock = socketRef.current
|
||||
if (!sock) return
|
||||
try {
|
||||
sock.onclose = null
|
||||
sock.close()
|
||||
} catch { /* ignore */ }
|
||||
socketRef.current = null
|
||||
}, [])
|
||||
|
||||
/** 会话结束后重新咨询:清本地凭证并 Init 新会话 */
|
||||
const startNewSession = useCallback(async () => {
|
||||
if (sending) return
|
||||
setSending(true)
|
||||
setSendError('')
|
||||
setShowRating(false)
|
||||
closeSocket()
|
||||
localStorage.removeItem(storageKey)
|
||||
localStorage.removeItem(tokenKey)
|
||||
localStorage.removeItem(msgsKey)
|
||||
setSessionId(null)
|
||||
setVisitorToken('')
|
||||
sessionIdRef.current = null
|
||||
visitorTokenRef.current = ''
|
||||
setMessages([])
|
||||
setInput('')
|
||||
setSessionEnded(false)
|
||||
setRated(false)
|
||||
setRatingScore(0)
|
||||
setRatingText('')
|
||||
setHoverStar(0)
|
||||
setAgentTyping(false)
|
||||
setAgentName('')
|
||||
setLeaveSent(false)
|
||||
setPendingImage(prev => {
|
||||
if (prev?.preview) URL.revokeObjectURL(prev.preview)
|
||||
return null
|
||||
})
|
||||
lastSeqRef.current = 0
|
||||
initRef.current = true
|
||||
try {
|
||||
await bootstrapNewSession()
|
||||
} catch (e) {
|
||||
console.error('Start new session failed:', e)
|
||||
setSendError(e instanceof Error ? e.message : '发起新咨询失败,请稍后重试')
|
||||
initRef.current = false
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [sending, closeSocket, storageKey, tokenKey, msgsKey, bootstrapNewSession])
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
if (sessionId && visitorToken) {
|
||||
// 本地有 seq 游标时增量补齐,否则全量
|
||||
// 本地有 seq 游标时增量补齐,否则全量(若已结束会由 loadMessages 带回状态)
|
||||
const after = lastSeqRef.current
|
||||
await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true })
|
||||
return
|
||||
@@ -266,38 +369,13 @@ const VisitorChat = ({
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
try {
|
||||
const res = await fetch(`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
const data = json.data
|
||||
const sid = data.session_id
|
||||
const token = data.visitor_token
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
if (data.session_status === 'ended' || data.session_status === 'archived') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
lastSeqRef.current = 0
|
||||
await loadMessages(sid, token, { full: true })
|
||||
} else {
|
||||
setSendError(json.message || '初始化会话失败')
|
||||
}
|
||||
await bootstrapNewSession()
|
||||
} catch (e) {
|
||||
console.error('Init failed:', e)
|
||||
setSendError('连接客服失败,请稍后重试')
|
||||
setSendError(e instanceof Error ? e.message : '连接客服失败,请稍后重试')
|
||||
initRef.current = false
|
||||
}
|
||||
}, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey])
|
||||
}, [sessionId, visitorToken, loadMessages, bootstrapNewSession])
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId
|
||||
@@ -622,24 +700,38 @@ const VisitorChat = ({
|
||||
notifyHost('kefu-widget-minimize')
|
||||
}
|
||||
|
||||
const submitRating = async (score: number) => {
|
||||
if (!sessionId || !visitorToken || rated) return
|
||||
const submitRating = async () => {
|
||||
if (!sessionId || !visitorToken || rated || ratingSubmitting) return
|
||||
if (ratingScore < 1 || ratingScore > 5) {
|
||||
setSendError('请先点击星星选择评分')
|
||||
return
|
||||
}
|
||||
setRatingSubmitting(true)
|
||||
setSendError('')
|
||||
try {
|
||||
const res = await fetch('/api/widget/rating', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, score, text: ratingText }),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
score: ratingScore,
|
||||
text: ratingText.trim(),
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
setRated(true)
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setRatingText('')
|
||||
setSendError('')
|
||||
} else {
|
||||
setSendError(json.message || '评价提交失败')
|
||||
}
|
||||
} catch {
|
||||
setSendError('评价提交失败,请稍后重试')
|
||||
} finally {
|
||||
setRatingSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,8 +867,10 @@ const VisitorChat = ({
|
||||
<footer className="shrink-0 px-4 py-3 bg-white border-t border-neutral-200">
|
||||
{sendError && <div className="mb-2 text-xs text-red-500">{sendError}</div>}
|
||||
|
||||
{sessionEnded && !rated && (
|
||||
<div className="mx-4 mb-2 rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||||
{sessionEnded && (
|
||||
<div className="mb-3 space-y-2">
|
||||
{!rated && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-amber-800">会话已结束,欢迎为本次服务打分</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -787,6 +881,17 @@ const VisitorChat = ({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void startNewSession() }}
|
||||
disabled={sending}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-50 cursor-pointer text-white text-sm font-medium"
|
||||
>
|
||||
{sending ? '正在接入...' : '重新咨询'}
|
||||
</button>
|
||||
<p className="m-0 text-center text-[11px] text-neutral-400">将开启新会话,历史消息仅保留在本页展示到重新接入前</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!agentsOnline && !sessionEnded && (
|
||||
<div className="mb-3 p-3 rounded-lg border border-amber-100 bg-amber-50 space-y-2">
|
||||
@@ -820,7 +925,7 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingImage && agentsOnline && (
|
||||
{!sessionEnded && pendingImage && agentsOnline && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||||
@@ -838,20 +943,20 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentsOnline && (
|
||||
{!sessionEnded && agentsOnline && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
disabled={!sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
onSelect={insertEmoji}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
disabled={!sessionId || sending}
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
placement="topLeft"
|
||||
/>
|
||||
@@ -865,14 +970,13 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={textInputRef}
|
||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||
placeholder={
|
||||
sessionEnded
|
||||
? '会话已结束'
|
||||
: !sessionId
|
||||
!sessionId
|
||||
? '正在连接...'
|
||||
: agentsOnline
|
||||
? '输入消息...'
|
||||
@@ -893,13 +997,13 @@ const VisitorChat = ({
|
||||
if (agentsOnline) sendMessage(input)
|
||||
}
|
||||
}}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
disabled={sending || !sessionId}
|
||||
/>
|
||||
{agentsOnline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || sending || !sessionId || sessionEnded}
|
||||
disabled={!input.trim() || sending || !sessionId}
|
||||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||||
aria-label="发送"
|
||||
>
|
||||
@@ -909,43 +1013,103 @@ const VisitorChat = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLeaveMessage}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
disabled={sending || !sessionId}
|
||||
className="h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
|
||||
>
|
||||
{leaveSent ? '再留言' : '提交留言'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{showRating && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
||||
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
|
||||
<div className="bg-white rounded-xl p-5 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
||||
<div className="flex justify-center gap-1.5 mb-4">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<div className="text-sm text-neutral-400 mb-3">先点亮星级,可补充感受后再提交</div>
|
||||
<div className="flex justify-center gap-1.5 mb-1">
|
||||
{[1, 2, 3, 4, 5].map(star => {
|
||||
const active = star <= (hoverStar || ratingScore)
|
||||
return (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }}
|
||||
style={{ color: active ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => setHoverStar(star)}
|
||||
onMouseLeave={() => setHoverStar(0)}
|
||||
onClick={() => submitRating(star)}
|
||||
onClick={() => {
|
||||
setRatingScore(star)
|
||||
setSendError('')
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mb-3 h-4">
|
||||
{ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center mb-3">
|
||||
{ratingPresets.map(preset => {
|
||||
const selected = ratingText === preset
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setRatingText(prev => (prev === preset ? '' : preset))}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-[#2563eb] border-[#2563eb] text-white'
|
||||
: 'bg-neutral-50 border-neutral-200 text-neutral-600 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none"
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none text-left"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:写下您的服务感受"
|
||||
placeholder="可选:点击上方标签快速填入,或自行输入"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
disabled={ratingSubmitting || ratingScore < 1}
|
||||
onClick={() => { void submitRating() }}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer text-white text-sm font-medium mb-2"
|
||||
>
|
||||
{ratingSubmitting ? '提交中...' : '提交评价'}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
}}
|
||||
className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending || ratingSubmitting}
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
void startNewSession()
|
||||
}}
|
||||
className="text-sm text-[#2563eb] hover:text-[#1d4ed8] border-0 bg-transparent cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
跳过并重新咨询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user