实现Widget访客端API:初始化会话、发送消息、消息轮询,前端Widget对接真实API
This commit is contained in:
@@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
stats := NewStatisticsHandler()
|
stats := NewStatisticsHandler()
|
||||||
admin := NewAdminHandler()
|
admin := NewAdminHandler()
|
||||||
ws := NewWsHandler()
|
ws := NewWsHandler()
|
||||||
|
widget := NewWidgetHandler()
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
|
|
||||||
@@ -20,10 +21,12 @@ func SetupRoutes(r *gin.Engine) {
|
|||||||
api.POST("/login", auth.Login)
|
api.POST("/login", auth.Login)
|
||||||
api.POST("/register", auth.Register)
|
api.POST("/register", auth.Register)
|
||||||
|
|
||||||
// widget 接口(通过 channel_id 鉴权,简化处理)
|
// widget 接口
|
||||||
widget := api.Group("/widget")
|
widgetApi := api.Group("/widget")
|
||||||
widget.POST("/init", func(c *gin.Context) { c.JSON(200, gin.H{"code": 0, "data": gin.H{"session_id": 1}}) })
|
widgetApi.POST("/init", widget.Init)
|
||||||
widget.POST("/message", func(c *gin.Context) { c.JSON(200, gin.H{"code": 0}) })
|
widgetApi.GET("/init", widget.Init)
|
||||||
|
widgetApi.POST("/message", widget.SendMessage)
|
||||||
|
widgetApi.GET("/messages", widget.GetMessages)
|
||||||
|
|
||||||
// 需要认证的接口
|
// 需要认证的接口
|
||||||
authRequired := api.Group("")
|
authRequired := api.Group("")
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WidgetHandler struct{}
|
||||||
|
|
||||||
|
func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} }
|
||||||
|
|
||||||
|
type WidgetInitReq struct {
|
||||||
|
ChannelKey string `json:"channel_key" form:"channel_key"`
|
||||||
|
VisitorName string `json:"visitor_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WidgetMessageReq struct {
|
||||||
|
SessionID uint `json:"session_id" binding:"required"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WidgetHandler) Init(c *gin.Context) {
|
||||||
|
var req WidgetInitReq
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil && c.ShouldBindJSON(&req) != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ChannelKey == "" {
|
||||||
|
req.ChannelKey = c.Query("channel_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
var channel model.Channel
|
||||||
|
if err := model.DB.Where("script_code LIKE ?", "%"+req.ChannelKey+"%").Or("script_code LIKE ?", "%"+req.ChannelKey+"%").First(&channel).Error; err != nil {
|
||||||
|
// 如果没有匹配的渠道,使用第一个启用的渠道
|
||||||
|
if err := model.DB.Where("type = ? AND status = ?", "web", "enabled").First(&channel).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name := req.VisitorName
|
||||||
|
if name == "" {
|
||||||
|
name = "访客"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建或查找客户
|
||||||
|
var customer model.Customer
|
||||||
|
model.DB.Where("tenant_id = ? AND name = ? AND phone = ''", channel.TenantID, name).First(&customer)
|
||||||
|
if customer.ID == 0 {
|
||||||
|
customer = model.Customer{
|
||||||
|
TenantID: channel.TenantID,
|
||||||
|
Name: name,
|
||||||
|
Source: "网页",
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
model.DB.Create(&customer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
session := model.Session{
|
||||||
|
TenantID: channel.TenantID,
|
||||||
|
ChannelID: channel.ID,
|
||||||
|
CustomerID: customer.ID,
|
||||||
|
Status: "waiting",
|
||||||
|
Priority: "normal",
|
||||||
|
}
|
||||||
|
model.DB.Create(&session)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 0,
|
||||||
|
"data": gin.H{
|
||||||
|
"session_id": session.ID,
|
||||||
|
"customer_id": customer.ID,
|
||||||
|
"channel_id": channel.ID,
|
||||||
|
"tenant_id": channel.TenantID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WidgetHandler) SendMessage(c *gin.Context) {
|
||||||
|
var req WidgetMessageReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
var session model.Session
|
||||||
|
if err := model.DB.First(&session, req.SessionID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是等待中的会话,更新为活跃
|
||||||
|
if session.Status == "waiting" {
|
||||||
|
model.DB.Model(&session).Update("status", "active")
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxSeq int
|
||||||
|
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq)
|
||||||
|
|
||||||
|
custID := session.CustomerID
|
||||||
|
msg := model.Message{
|
||||||
|
SessionID: session.ID,
|
||||||
|
SenderType: "visitor",
|
||||||
|
SenderID: &custID,
|
||||||
|
Content: req.Content,
|
||||||
|
Type: req.Type,
|
||||||
|
Seq: maxSeq + 1,
|
||||||
|
SentAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DB.Create(&msg).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WidgetHandler) GetMessages(c *gin.Context) {
|
||||||
|
sessionID := c.Query("session_id")
|
||||||
|
|
||||||
|
var messages []model.Message
|
||||||
|
model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages})
|
||||||
|
}
|
||||||
+101
-61
@@ -1,63 +1,123 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string
|
id: number
|
||||||
sender: 'visitor' | 'agent'
|
sender: 'visitor' | 'agent'
|
||||||
content: string
|
content: string
|
||||||
time: string
|
time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMessages: Message[] = [
|
|
||||||
{ id: '1', sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '10:30' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { CloseOutlined, MessageOutlined, SmileOutlined, PaperClipOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
|
||||||
|
|
||||||
const VisitorChat = () => {
|
const VisitorChat = () => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [messages, setMessages] = useState<Message[]>(initialMessages)
|
const [sessionId, setSessionId] = useState<number | null>(null)
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{ id: 0, sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '' },
|
||||||
|
])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [typing, setTyping] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [showRating, setShowRating] = useState(false)
|
const [showRating, setShowRating] = useState(false)
|
||||||
const [rated, setRated] = useState(false)
|
const [rated, setRated] = useState(false)
|
||||||
|
const pollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const sendMessage = (text: string) => {
|
const initSession = async () => {
|
||||||
if (!text.trim()) return
|
try {
|
||||||
const msg: Message = { id: Date.now().toString(), sender: 'visitor', content: text, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=测试访客`, { method: 'POST' })
|
||||||
setMessages(prev => [...prev, msg])
|
const json = await res.json()
|
||||||
setInput('')
|
if (json.code === 0) {
|
||||||
setTyping(true)
|
setSessionId(json.data.session_id)
|
||||||
setTimeout(() => {
|
}
|
||||||
setTyping(false)
|
} catch (e) {
|
||||||
const reply: Message = { id: (Date.now() + 1).toString(), sender: 'agent', content: '感谢您的咨询,客服正在为您处理中...', time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
console.error('Init session failed:', e)
|
||||||
setMessages(prev => [...prev, reply])
|
}
|
||||||
}, 1500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnd = () => {
|
useEffect(() => {
|
||||||
if (!rated) {
|
if (open && !sessionId) {
|
||||||
setShowRating(true)
|
initSession()
|
||||||
}
|
}
|
||||||
setOpen(false)
|
if (!open) {
|
||||||
|
setSessionId(null)
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId) {
|
||||||
|
pollRef.current = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/widget/messages?session_id=${sessionId}`)
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.code === 0 && json.data) {
|
||||||
|
const serverMsgs: Message[] = json.data.map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||||
|
content: m.content,
|
||||||
|
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}))
|
||||||
|
setMessages(prev => {
|
||||||
|
const existing = new Set(prev.map(m => m.id))
|
||||||
|
const welcome = prev[0]
|
||||||
|
// Filter out server messages that we already have; keep welcome
|
||||||
|
const serverOnly = serverMsgs.filter(m => !existing.has(m.id) && m.id > 0)
|
||||||
|
// Merge sorted
|
||||||
|
const merged = [welcome, ...serverOnly].sort((a, b) => a.id - b.id)
|
||||||
|
return merged.length > 1 ? merged : prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 2000)
|
||||||
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||||
|
}
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
if (!text.trim() || sending) return
|
||||||
|
const content = text.trim()
|
||||||
|
setInput('')
|
||||||
|
setSending(true)
|
||||||
|
|
||||||
|
// Add locally immediately
|
||||||
|
const localMsg: Message = { id: -Date.now(), sender: 'visitor', content, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
||||||
|
setMessages(prev => [...prev, localMsg])
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/widget/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.code !== 0) {
|
||||||
|
console.error('Send failed:', json.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Send error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
setShowRating(false)
|
||||||
|
setRated(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 悬浮按钮 */}
|
|
||||||
{!open && (
|
{!open && (
|
||||||
<button
|
<button onClick={handleOpen} className="fixed right-5 bottom-5 w-14 h-14 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow-lg flex items-center justify-center transition-all hover:scale-110 z-50">
|
||||||
onClick={() => { setOpen(true); setShowRating(false) }}
|
|
||||||
className="fixed right-5 bottom-5 w-14 h-14 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow-lg flex items-center justify-center transition-all hover:scale-110 z-50"
|
|
||||||
>
|
|
||||||
<MessageOutlined className="text-xl" />
|
<MessageOutlined className="text-xl" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 聊天窗口 */}
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="fixed right-5 bottom-5 w-[400px] h-[600px] bg-white rounded-xl shadow-2xl border border-neutral-200 flex flex-col z-50 overflow-hidden">
|
<div className="fixed right-5 bottom-5 w-[400px] h-[600px] bg-white rounded-xl shadow-2xl border border-neutral-200 flex flex-col z-50 overflow-hidden">
|
||||||
{/* 顶部 */}
|
|
||||||
<div className="h-14 px-4 flex items-center justify-between bg-gradient-to-r from-blue-500 to-blue-600 flex-shrink-0">
|
<div className="h-14 px-4 flex items-center justify-between bg-gradient-to-r from-blue-500 to-blue-600 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||||
@@ -65,65 +125,48 @@ const VisitorChat = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-white">客服云</div>
|
<div className="text-sm font-medium text-white">客服云</div>
|
||||||
<div className="text-xs text-white/70">{typing ? '正在输入...' : '在线'}</div>
|
<div className="text-xs text-white/70">在线</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={handleEnd} />
|
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={() => { setOpen(false); if (!rated) setShowRating(true) }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 消息区域 */}
|
|
||||||
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
<div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>
|
{msg.time && <div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{typing && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="bg-white rounded-lg px-3 py-2 border border-neutral-200">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷问题 */}
|
|
||||||
{messages.length <= 1 && (
|
{messages.length <= 1 && (
|
||||||
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{quickQuestions.map((q, i) => (
|
{quickQuestions.map((q, i) => (
|
||||||
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">
|
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">{q}</button>
|
||||||
{q}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
|
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-1.5">
|
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-1.5">
|
||||||
<SmileOutlined className="text-neutral-300 cursor-pointer hover:text-neutral-500" />
|
<SmileOutlined className="text-neutral-300" />
|
||||||
<PaperClipOutlined className="text-neutral-300 cursor-pointer hover:text-neutral-500" />
|
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
||||||
placeholder="输入您的问题..."
|
placeholder="输入您的问题..."
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { sendMessage(input) } }}
|
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||||
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
<SendOutlined className="text-blue-500 cursor-pointer hover:text-blue-600" onClick={() => sendMessage(input)} />
|
<SendOutlined className="text-blue-500 cursor-pointer hover:text-blue-600" onClick={() => sendMessage(input)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 满意度弹窗 */}
|
|
||||||
{showRating && (
|
{showRating && (
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
<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">
|
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center">
|
||||||
@@ -131,11 +174,8 @@ const VisitorChat = () => {
|
|||||||
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
||||||
<div className="flex justify-center gap-1.5 mb-4">
|
<div className="flex justify-center gap-1.5 mb-4">
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
<StarFilled
|
<StarFilled key={star} className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
||||||
key={star}
|
onClick={() => { setRated(true); setShowRating(false) }} />
|
||||||
className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
|
||||||
onClick={() => { setRated(true); setShowRating(false) }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user