实现 P1 客户/知识库/对话记录与渠道设置

- 客户管理接通创建、编辑、删除与详情历史会话
- 知识库接通分类/条目 CRUD,按角色控制写权限
- 对话记录增强筛选、客户名、消息与操作时间线
- 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
yml2213
2026-07-15 11:18:01 +08:00
parent 9485c28abd
commit 65014d80de
8 changed files with 1232 additions and 247 deletions
+197
View File
@@ -0,0 +1,197 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
type ChannelHandler struct{}
func NewChannelHandler() *ChannelHandler { return &ChannelHandler{} }
var scriptIDPattern = regexp.MustCompile(`data-id="([A-Za-z0-9_-]+)"`)
type channelView struct {
ID uint `json:"id"`
TenantID uint `json:"tenant_id"`
Type string `json:"type"`
Name string `json:"name"`
Status string `json:"status"`
Config string `json:"config"`
ScriptCode string `json:"script_code"`
ChannelKey string `json:"channel_key"`
}
func extractChannelKey(script string) string {
m := scriptIDPattern.FindStringSubmatch(script)
if len(m) == 2 {
return m[1]
}
return ""
}
func buildWebScript(channelKey string) string {
return fmt.Sprintf(`<script src="/widget.js" data-id="%s"></script>`, channelKey)
}
func newChannelKey(prefix string) (string, error) {
buf := make([]byte, 4)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(buf)), nil
}
func toChannelView(ch model.Channel) channelView {
return channelView{
ID: ch.ID, TenantID: ch.TenantID, Type: ch.Type, Name: ch.Name,
Status: ch.Status, Config: ch.Config, ScriptCode: ch.ScriptCode,
ChannelKey: extractChannelKey(ch.ScriptCode),
}
}
func requireTenantAdmin(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin") {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"})
return false
}
func (h *ChannelHandler) List(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
var channels []model.Channel
if err := model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&channels).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道失败"})
return
}
views := make([]channelView, 0, len(channels))
for _, ch := range channels {
views = append(views, toChannelView(ch))
}
middleware.JSON(c, views)
}
type updateChannelReq struct {
Name *string `json:"name"`
Status *string `json:"status"`
}
func (h *ChannelHandler) Update(c *gin.Context) {
if !requireTenantAdmin(c) {
return
}
tenantID := middleware.GetTenantID(c)
id := c.Param("id")
var channel model.Channel
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&channel).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在"})
return
}
var req updateChannelReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
updates := map[string]interface{}{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" || len([]rune(name)) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道名称无效"})
return
}
updates["name"] = name
}
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if status != "enabled" && status != "disabled" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态仅支持 enabled/disabled"})
return
}
updates["status"] = status
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(&channel).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
model.DB.First(&channel, channel.ID)
middleware.JSON(c, toChannelView(channel))
}
type createChannelReq struct {
Type string `json:"type" binding:"required"`
Name string `json:"name"`
}
func (h *ChannelHandler) Create(c *gin.Context) {
if !requireTenantAdmin(c) {
return
}
var req createChannelReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
channelType := strings.TrimSpace(req.Type)
allowed := map[string]string{
"web": "网页聊天", "wechat": "微信公众号", "app": "APP 内嵌",
"phone": "电话客服", "email": "邮件工单",
}
defaultName, ok := allowed[channelType]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的渠道类型"})
return
}
tenantID := middleware.GetTenantID(c)
var exists int64
model.DB.Model(&model.Channel{}).Where("tenant_id = ? AND type = ?", tenantID, channelType).Count(&exists)
if exists > 0 {
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该类型渠道已存在"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = defaultName
}
prefix := map[string]string{"web": "WK", "wechat": "WX", "app": "AP", "phone": "PH", "email": "EM"}[channelType]
key, err := newChannelKey(prefix)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成渠道标识失败"})
return
}
channel := model.Channel{
TenantID: tenantID,
Type: channelType,
Name: name,
Status: "disabled",
}
if channelType == "web" {
channel.Status = "enabled"
channel.ScriptCode = buildWebScript(key)
} else {
channel.ScriptCode = fmt.Sprintf(`data-id="%s"`, key)
}
if err := model.DB.Create(&channel).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
middleware.JSON(c, toChannelView(channel))
}
+7
View File
@@ -12,6 +12,7 @@ func SetupRoutes(r *gin.Engine) {
knowledge := NewKnowledgeHandler()
stats := NewStatisticsHandler()
admin := NewAdminHandler()
channel := NewChannelHandler()
ws := NewWsHandler()
widget := NewWidgetHandler()
@@ -68,6 +69,12 @@ func SetupRoutes(r *gin.Engine) {
kb.PUT("/entries/:id", knowledge.UpdateEntry)
kb.DELETE("/entries/:id", knowledge.DeleteEntry)
// 渠道设置(租户级)
channels := authRequired.Group("/channels")
channels.GET("", channel.List)
channels.POST("", channel.Create)
channels.PUT("/:id", channel.Update)
// 统计
statistics := authRequired.Group("/statistics")
statistics.GET("/kpi", stats.KPIs)
@@ -540,3 +540,86 @@ func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
t.Fatalf("未记录自动分配事件: %v", err)
}
}
func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "渠道租户", "normal")
admin := createUser(t, tenant.ID, "ch-admin", "admin")
agent := createUser(t, tenant.ID, "ch-agent", "agent")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
ScriptCode: `<script src="/widget.js" data-id="WK_ch_001"></script>`,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/channels", nil, agent))
if listRecorder.Code != http.StatusOK {
t.Fatalf("客服查看渠道列表失败: %s", listRecorder.Body.String())
}
denyRecorder := httptest.NewRecorder()
router.ServeHTTP(denyRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), agent))
if denyRecorder.Code != http.StatusForbidden {
t.Fatalf("客服禁用渠道应 403,实际 %d %s", denyRecorder.Code, denyRecorder.Body.String())
}
okRecorder := httptest.NewRecorder()
router.ServeHTTP(okRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/channels/%d", channel.ID), []byte(`{"status":"disabled"}`), admin))
if okRecorder.Code != http.StatusOK {
t.Fatalf("管理员禁用渠道失败: %s", okRecorder.Body.String())
}
var updated model.Channel
if err := model.DB.First(&updated, channel.ID).Error; err != nil || updated.Status != "disabled" {
t.Fatalf("渠道状态未更新: %+v err=%v", updated, err)
}
}
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "业务CRUD租户", "normal")
admin := createUser(t, tenant.ID, "biz-admin", "admin")
createCustomerRec := httptest.NewRecorder()
router.ServeHTTP(createCustomerRec, bearerRequest(t, http.MethodPost, "/api/customers", []byte(`{"name":"测试客户甲","phone":"13900001111","tags":"[\"新客户\"]","status":"offline","source":"手动"}`), admin))
if createCustomerRec.Code != http.StatusOK {
t.Fatalf("创建客户失败: %s", createCustomerRec.Body.String())
}
var createCustomerResp struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(createCustomerRec.Body.Bytes(), &createCustomerResp); err != nil || createCustomerResp.Data.ID == 0 {
t.Fatalf("解析客户创建响应失败: %v body=%s", err, createCustomerRec.Body.String())
}
updateCustomerRec := httptest.NewRecorder()
router.ServeHTTP(updateCustomerRec, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/customers/%d", createCustomerResp.Data.ID), []byte(`{"tags":"[\"VIP客户\",\"活跃\"]"}`), admin))
if updateCustomerRec.Code != http.StatusOK {
t.Fatalf("更新客户失败: %s", updateCustomerRec.Body.String())
}
createCatRec := httptest.NewRecorder()
router.ServeHTTP(createCatRec, bearerRequest(t, http.MethodPost, "/api/knowledge/categories", []byte(`{"name":"产品FAQ"}`), admin))
if createCatRec.Code != http.StatusOK {
t.Fatalf("创建分类失败: %s", createCatRec.Body.String())
}
var catResp struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(createCatRec.Body.Bytes(), &catResp); err != nil || catResp.Data.ID == 0 {
t.Fatalf("解析分类响应失败: %v", err)
}
createEntryRec := httptest.NewRecorder()
body := fmt.Sprintf(`{"title":"如何退货","content":"7天无理由退货","category_id":%d,"status":"published"}`, catResp.Data.ID)
router.ServeHTTP(createEntryRec, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", []byte(body), admin))
if createEntryRec.Code != http.StatusOK {
t.Fatalf("创建知识条目失败: %s", createEntryRec.Body.String())
}
}
+198 -49
View File
@@ -1,105 +1,254 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Input, Select, Tag, Empty, Spin } from 'antd'
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
import { getSession, getSessions, type Session as SessionType } from '@/services/api'
import {
getCustomers, getSession, getSessions,
type Customer, type Message, type Session, type SessionEvent,
} from '@/services/api'
const statusColors: Record<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' }
const statusColors: Record<string, string> = {
active: 'blue', waiting: 'orange', ended: 'green', archived: 'default',
}
const statusLabels: Record<string, string> = {
active: '进行中', ended: '已结束', waiting: '等待中', archived: '已归档',
}
const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
const ChatHistory = () => {
const [sessions, setSessions] = useState<SessionType[]>([])
const [sessions, setSessions] = useState<Session[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [priorityFilter, setPriorityFilter] = useState<string>()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [messages, setMessages] = useState<{ id: number; sender_type: string; content: string; sent_at: string }[]>([])
const [messages, setMessages] = useState<Message[]>([])
const [events, setEvents] = useState<SessionEvent[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
useEffect(() => { loadSessions() }, [statusFilter])
useEffect(() => {
loadSessions()
}, [statusFilter, priorityFilter])
const loadSessions = async () => {
setLoading(true)
try {
const res = await getSessions({ status: statusFilter })
setSessions(res.list)
if (res.list.length > 0 && !selectedId) setSelectedId(res.list[0].id)
} catch { setSessions([]) } finally { setLoading(false) }
const [sessionRes, customerRes] = await Promise.all([
getSessions({ status: statusFilter, priority: priorityFilter, page: 1, pageSize: 100 }),
getCustomers({ page: 1, pageSize: 200 }),
])
const list = Array.isArray(sessionRes.list) ? sessionRes.list : []
setSessions(list)
const map = Object.fromEntries((customerRes.list || []).map(c => [c.id, c]))
setCustomers(map)
if (list.length > 0) {
const still = selectedId && list.some(s => s.id === selectedId)
if (!still) setSelectedId(list[0].id)
} else {
setSelectedId(null)
}
} catch {
setSessions([])
} finally {
setLoading(false)
}
}
const selected = sessions.find(s => s.id === selectedId)
useEffect(() => {
if (!selectedId) {
setMessages([])
setEvents([])
setSelectedSession(null)
return
}
setDetailLoading(true)
getSession(selectedId).then(res => {
const detail = res.data as { messages?: { id: number; sender_type: string; content: string; sent_at: string }[] }
setMessages(detail.messages || [])
}).catch(() => setMessages([])).finally(() => setDetailLoading(false))
setMessages(res.data.messages || [])
setEvents(res.data.events || [])
setSelectedSession(res.data.session || sessions.find(s => s.id === selectedId) || null)
}).catch(() => {
setMessages([])
setEvents([])
}).finally(() => setDetailLoading(false))
}, [selectedId])
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase()
return sessions.filter(s => {
if (!keyword) return true
const name = customers[s.customer_id]?.name || ''
return name.toLowerCase().includes(keyword)
|| String(s.id).includes(keyword)
|| String(s.customer_id).includes(keyword)
|| (s.last_message || '').toLowerCase().includes(keyword)
})
}, [sessions, customers, search])
const selected = selectedSession || sessions.find(s => s.id === selectedId)
const customer = selected ? customers[selected.customer_id] : null
return (
<div className="h-full flex">
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
<div className="p-3 border-b border-neutral-100 space-y-2">
<Input prefix={<SearchOutlined />} placeholder="搜索访客..." value={search} onChange={e => setSearch(e.target.value)} allowClear size="small" />
<Select placeholder="状态" value={statusFilter} onChange={setStatusFilter} allowClear size="small" className="w-full" options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))} />
<Input
prefix={<SearchOutlined />}
placeholder="搜索客户/会话/消息"
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
size="small"
/>
<div className="flex gap-2">
<Select
placeholder="状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
<Select
placeholder="优先级"
value={priorityFilter}
onChange={setPriorityFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(priorityLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? <div className="flex justify-center py-10"><Spin /></div> :
sessions.filter(s => !search || s.status.includes(search) || String(s.customer_id).includes(search)).map(s => (
<div key={s.id} className={`px-3 py-3 border-b border-neutral-50 cursor-pointer hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.id)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0"><UserOutlined className="text-neutral-400 text-sm" /></div>
<div><div className="text-sm font-medium text-neutral-800">{s.customer_id}</div><div className="text-xs text-neutral-400">ID: {s.id}</div></div>
{loading ? (
<div className="flex justify-center py-10"><Spin /></div>
) : filtered.map(s => {
const name = customers[s.customer_id]?.name || `客户${s.customer_id}`
return (
<button
key={s.id}
type="button"
className={`w-full text-left px-3 py-3 border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0 text-xs font-semibold text-neutral-500">
{name.slice(0, 1)}
</div>
<Tag color={statusColors[s.status]} className="text-xs">{statusLabels[s.status] || s.status}</Tag>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{name}</div>
<div className="text-xs text-neutral-400 truncate">{s.last_message || `会话 #${s.id}`}</div>
</div>
<div className="flex items-center justify-between mt-1">
</div>
<Tag color={statusColors[s.status]} className="text-xs m-0 shrink-0">{statusLabels[s.status] || s.status}</Tag>
</div>
<div className="flex items-center justify-between mt-1.5 pl-10">
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleString('zh-CN')}</span>
{s.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span>}
{s.priority === 'urgent' && <Tag color="red" className="text-xs m-0"></Tag>}
{s.satisfaction_score ? <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span> : null}
</div>
</div>
))}
{!loading && sessions.length === 0 && <div className="flex items-center justify-center h-full"><Empty description="暂无对话记录" /></div>}
</button>
)
})}
{!loading && filtered.length === 0 && (
<div className="flex items-center justify-center h-40"><Empty description="暂无对话记录" /></div>
)}
</div>
</div>
<div className="flex-1 bg-white overflow-auto">
<div className="flex-1 bg-neutral-50 overflow-auto">
{selected ? (
<div className="p-6 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6 pb-4 border-b border-neutral-100">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-neutral-800"> {selected.id}</h3>
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold text-neutral-800 m-0 truncate">
{customer?.name || `客户${selected.customer_id}`}
</h3>
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
{selected.priority === 'urgent' && <Tag color="red"></Tag>}
</div>
<div className="text-sm text-neutral-400 mt-1">ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}</div>
<div className="text-sm text-neutral-400 mt-1">
#{selected.id}
{customer?.source ? ` · ${customer.source}` : ''}
{' · '}
{new Date(selected.created_at).toLocaleString('zh-CN')}
{selected.ended_at ? ` ~ ${new Date(selected.ended_at).toLocaleString('zh-CN')}` : ''}
</div>
</div>
{selected.satisfaction_score != null && (
<div className="text-right shrink-0">
<div className="text-xl text-yellow-500">
{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(Math.max(0, 5 - selected.satisfaction_score))}
</div>
{selected.satisfaction_score && (
<div className="text-right">
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}</div>
<div className="text-xs text-neutral-400"></div>
{selected.satisfaction_text && (
<div className="text-xs text-neutral-500 mt-1 max-w-[180px]">{selected.satisfaction_text}</div>
)}
</div>
)}
</div>
<div className="space-y-4">
{detailLoading ? <div className="flex justify-center py-10"><Spin /></div> : messages.length === 0 ? <Empty description="暂无消息记录" /> : messages.map(message => (
<div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
<div className="text-xs mb-1 opacity-70">{message.sender_type === 'agent' ? '客服' : '访客'}</div>
<div>{message.content}</div>
<div className="text-xs mt-1 opacity-60">{new Date(message.sent_at).toLocaleString('zh-CN')}</div>
</div>
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div className="text-sm font-medium text-neutral-700 mb-4"></div>
<div className="space-y-4">
{detailLoading ? (
<div className="flex justify-center py-10"><Spin /></div>
) : messages.length === 0 ? (
<Empty description="暂无消息记录" />
) : messages.map(message => {
const isAgent = message.sender_type === 'agent'
return (
<div key={message.id} className={`flex ${isAgent ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-xl px-3.5 py-2.5 text-sm ${
isAgent ? 'bg-[#2563eb] text-white rounded-tr-sm' : 'bg-neutral-100 text-neutral-700 rounded-tl-sm'
}`}>
<div className={`text-xs mb-1 ${isAgent ? 'text-white/70' : 'text-neutral-400'}`}>
{isAgent ? '客服' : '访客'}
</div>
{message.type === 'image' ? (
<img src={message.content} alt="图片" className="max-w-56 max-h-56 rounded-lg" />
) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div>
)}
<div className={`text-xs mt-1 ${isAgent ? 'text-white/60' : 'text-neutral-400'}`}>
{new Date(message.sent_at).toLocaleString('zh-CN')}
</div>
</div>
</div>
)
})}
</div>
</div>
{events.length > 0 && (
<div className="bg-white rounded-xl border border-neutral-200 p-5">
<div className="text-sm font-medium text-neutral-700 mb-3"></div>
<div className="space-y-2">
{events.slice().reverse().map(ev => (
<div key={ev.id} className="flex gap-3 text-xs text-neutral-500 border-b border-neutral-50 pb-2">
<span className="shrink-0 text-neutral-400 w-36">
{new Date(ev.created_at).toLocaleString('zh-CN')}
</span>
<Tag className="m-0 text-xs">{ev.action}</Tag>
<span className="flex-1">{ev.detail}</span>
</div>
))}
</div>
</div>
) : <div className="h-full flex items-center justify-center text-neutral-400"></div>}
)}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-neutral-400 gap-2">
<UserOutlined className="text-2xl" />
<div className="text-sm"></div>
</div>
)}
</div>
</div>
)
+250 -28
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from 'react'
import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty, message } from 'antd'
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { getCustomers, type Customer } from '@/services/api'
import {
Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty,
message, Modal, Form, Popconfirm,
} from 'antd'
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'
import {
createCustomer, deleteCustomer, getCustomer, getCustomers, updateCustomer,
type Customer, type Session,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
@@ -9,19 +16,36 @@ const statusMap: Record<string, { color: string; text: string }> = {
busy: { color: 'orange', text: '忙碌' },
}
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
const tagColors: Record<string, string> = {
'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple',
}
function parseTags(tagsStr: string): string[] {
try {
const parsed = JSON.parse(tagsStr)
return Array.isArray(parsed) ? parsed : []
} catch {
return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []
}
}
const Customers = () => {
const { user } = useAuth()
const canDelete = user?.role === 'admin' || user?.role === 'supervisor'
const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string[]>([])
const [statusFilter, setStatusFilter] = useState<string>()
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [historySessions, setHistorySessions] = useState<Session[]>([])
const [drawerOpen, setDrawerOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
useEffect(() => {
loadCustomers()
@@ -30,7 +54,7 @@ const Customers = () => {
const loadCustomers = async () => {
setLoading(true)
try {
const res = await getCustomers({ search, status: statusFilter[0], page })
const res = await getCustomers({ search, status: statusFilter, page, pageSize: 10 })
setCustomers(res.list)
setTotal(res.total)
} catch {
@@ -40,39 +64,164 @@ const Customers = () => {
}
}
const parseTags = (tagsStr: string): string[] => {
try { return JSON.parse(tagsStr) } catch { return [] }
const openDetail = async (record: Customer) => {
setSelectedCustomer(record)
setDrawerOpen(true)
setHistorySessions([])
try {
const res = await getCustomer(record.id)
setSelectedCustomer(res.data.customer)
setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
} catch {
// keep list snapshot
}
}
const openCreate = () => {
setEditingId(null)
form.resetFields()
form.setFieldsValue({ status: 'offline', source: '手动录入', tags: [] })
setEditOpen(true)
}
const openEdit = (customer: Customer) => {
setEditingId(customer.id)
form.setFieldsValue({
name: customer.name,
phone: customer.phone,
email: customer.email,
source: customer.source,
status: customer.status,
tags: parseTags(customer.tags),
})
setEditOpen(true)
}
const handleSave = async (values: {
name: string; phone?: string; email?: string; source?: string; status?: string; tags?: string[]
}) => {
setSaving(true)
try {
const payload = {
name: values.name.trim(),
phone: values.phone?.trim() || '',
email: values.email?.trim() || '',
source: values.source?.trim() || '手动录入',
status: values.status || 'offline',
tags: JSON.stringify(values.tags || []),
}
if (editingId) {
const res = await updateCustomer(editingId, payload)
message.success('客户已更新')
setSelectedCustomer(res.data)
setEditOpen(false)
await loadCustomers()
if (drawerOpen) await openDetail(res.data)
} else {
await createCustomer(payload)
message.success('客户已创建')
setEditOpen(false)
setPage(1)
await loadCustomers()
}
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
try {
await deleteCustomer(id)
message.success('已删除')
if (selectedCustomer?.id === id) {
setDrawerOpen(false)
setSelectedCustomer(null)
}
await loadCustomers()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const columns = [
{ title: '客户名称', dataIndex: 'name', key: 'name', render: (text: string, record: Customer) => (
<span className="text-sm font-medium text-neutral-800 cursor-pointer hover:text-blue-500" onClick={() => { setSelectedCustomer(record); setDrawerOpen(true) }}>{text}</span>
)},
{ title: '联系方式', key: 'contact', render: (_: unknown, record: Customer) => (
{
title: '客户名称', dataIndex: 'name', key: 'name',
render: (text: string, record: Customer) => (
<span className="text-sm font-medium text-neutral-800 cursor-pointer hover:text-blue-500" onClick={() => openDetail(record)}>{text}</span>
),
},
{
title: '联系方式', key: 'contact',
render: (_: unknown, record: Customer) => (
<div className="space-y-0.5">
{record.phone && <div className="text-xs text-neutral-500"><PhoneOutlined className="mr-1" />{record.phone}</div>}
{record.email && <div className="text-xs text-neutral-500"><MailOutlined className="mr-1" />{record.email}</div>}
{!record.phone && !record.email && <span className="text-xs text-neutral-300"></span>}
</div>
)},
{ title: '标签', dataIndex: 'tags', key: 'tags', render: (tags: string) => (
<Space size={4} wrap>{parseTags(tags).map((t: string) => <Tag key={t} color={tagColors[t] || 'default'} className="text-xs">{t}</Tag>)}</Space>
)},
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Badge color={statusMap[s]?.color} text={statusMap[s]?.text} /> },
{ title: '来源', dataIndex: 'source', key: 'source', render: (t: string) => <span className="text-xs text-neutral-500">{t}</span> },
),
},
{
title: '标签', dataIndex: 'tags', key: 'tags',
render: (tags: string) => (
<Space size={4} wrap>
{parseTags(tags).map((t: string) => <Tag key={t} color={tagColors[t] || 'default'} className="text-xs">{t}</Tag>)}
</Space>
),
},
{
title: '状态', dataIndex: 'status', key: 'status',
render: (s: string) => <Badge color={statusMap[s]?.color} text={statusMap[s]?.text || s} />,
},
{
title: '来源', dataIndex: 'source', key: 'source',
render: (t: string) => <span className="text-xs text-neutral-500">{t || '—'}</span>,
},
{ title: '对话次数', dataIndex: 'conversation_count', key: 'conversation_count', align: 'center' as const },
{ title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at', render: (t: string) => <span className="text-xs text-neutral-400">{t || '-'}</span> },
{
title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at',
render: (t: string) => <span className="text-xs text-neutral-400">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
},
{
title: '操作', key: 'actions', width: 140,
render: (_: unknown, record: Customer) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={e => { e.stopPropagation(); openEdit(record) }}></Button>
{canDelete && (
<Popconfirm title="确认删除该客户?" onConfirm={e => { e?.stopPropagation(); handleDelete(record.id) }}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()}></Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<div className="h-full flex flex-col p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-800"></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => message.info('新建客户')}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
<div className="flex gap-3 mb-3 flex-wrap">
<Input prefix={<SearchOutlined />} placeholder="搜索客户名称、手机号、邮箱" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-64" allowClear />
<Select mode="multiple" placeholder="状态筛选" value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1) }} className="min-w-28" options={['online', 'offline', 'busy'].map(v => ({ value: v, label: statusMap[v].text }))} allowClear />
<Input
prefix={<SearchOutlined />}
placeholder="搜索客户名称、手机号、邮箱"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="w-64"
allowClear
/>
<Select
placeholder="状态筛选"
value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }}
className="min-w-28"
options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))}
allowClear
/>
</div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
@@ -84,28 +233,101 @@ const Customers = () => {
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个客户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无客户数据" /> }}
onRow={record => ({ onClick: () => { setSelectedCustomer(record); setDrawerOpen(true) }, style: { cursor: 'pointer' } })}
onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
/>
</div>
<Drawer title="客户详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={400} extra={<Button type="primary" icon={<EditOutlined />} size="small"></Button>}>
<Drawer
title="客户详情"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={420}
extra={
selectedCustomer && (
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => openEdit(selectedCustomer)}>
</Button>
)
}
>
{selectedCustomer && (
<div className="space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-neutral-100">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 text-lg font-semibold">{selectedCustomer.name[0]}</div>
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 text-lg font-semibold">
{selectedCustomer.name[0]}
</div>
<div>
<div className="text-base font-medium text-neutral-800">{selectedCustomer.name}</div>
<div className="text-xs text-neutral-400">{selectedCustomer.source}</div>
<div className="text-xs text-neutral-400">{selectedCustomer.source || '未知来源'}</div>
</div>
</div>
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="手机号">{selectedCustomer.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedCustomer.email || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><Badge color={statusMap[selectedCustomer.status]?.color} text={statusMap[selectedCustomer.status]?.text} /></Descriptions.Item>
<Descriptions.Item label="手机号">{selectedCustomer.phone || ''}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedCustomer.email || ''}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge color={statusMap[selectedCustomer.status]?.color} text={statusMap[selectedCustomer.status]?.text} />
</Descriptions.Item>
<Descriptions.Item label="对话次数">{selectedCustomer.conversation_count}</Descriptions.Item>
<Descriptions.Item label="标签">
<Space size={4} wrap>
{parseTags(selectedCustomer.tags).length === 0
? '—'
: parseTags(selectedCustomer.tags).map(t => <Tag key={t} color={tagColors[t] || 'default'}>{t}</Tag>)}
</Space>
</Descriptions.Item>
</Descriptions>
<div>
<div className="text-xs font-semibold text-neutral-500 mb-2"></div>
<div className="space-y-2 max-h-64 overflow-auto">
{historySessions.length === 0 ? (
<div className="text-xs text-neutral-400"></div>
) : historySessions.map(s => (
<div key={s.id} className="rounded-lg border border-neutral-100 bg-neutral-50 px-3 py-2">
<div className="flex justify-between text-sm">
<span className="font-medium text-neutral-700"> #{s.id}</span>
<Tag className="text-xs m-0">{s.status}</Tag>
</div>
<div className="text-xs text-neutral-400 mt-1 line-clamp-1">
{s.last_message || new Date(s.created_at).toLocaleString('zh-CN')}
</div>
</div>
))}
</div>
</div>
</div>
)}
</Drawer>
<Modal
title={editingId ? '编辑客户' : '新增客户'}
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-2" onFinish={handleSave}>
<Form.Item name="name" label="客户名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 50, message: '2-50 个字符' }]}>
<Input maxLength={50} />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}>
<Input maxLength={20} placeholder="可选" />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
<Input maxLength={100} placeholder="可选" />
</Form.Item>
<Form.Item name="source" label="来源">
<Input maxLength={30} placeholder="如:网页、微信" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+224 -31
View File
@@ -1,17 +1,34 @@
import { useState, useEffect } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons'
import { getKnowledgeCategories, getKnowledgeEntries, type KnowledgeEntry } from '@/services/api'
import {
Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress,
message, Popconfirm, Space,
} from 'antd'
import {
SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined, DeleteOutlined, FolderAddOutlined,
} from '@ant-design/icons'
import {
createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry,
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry,
type KnowledgeEntry,
} from '@/services/api'
import { useAuth } from '@/stores/auth'
const Knowledge = () => {
const { user } = useAuth()
const canManage = user?.role === 'admin' || user?.role === 'supervisor'
const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([])
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [modalOpen, setModalOpen] = useState(false)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
const [categoryForm] = Form.useForm()
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
@@ -21,68 +38,244 @@ const Knowledge = () => {
useEffect(() => {
loadEntries()
}, [selectedCategory, search, page])
}, [selectedCategory, search, page, statusFilter])
const loadCategories = async () => {
try {
const res = await getKnowledgeCategories()
setCategories((res.data as any[]).map((c: any) => ({ key: String(c.id), title: c.name, id: c.id })))
} catch { /* fallback */ }
const list = Array.isArray(res.data) ? res.data : []
setCategories(list.map(c => ({ key: String(c.id), title: c.name, id: c.id })))
} catch {
setCategories([])
}
}
const loadEntries = async () => {
setLoading(true)
try {
const res = await getKnowledgeEntries({ category_id: selectedCategory, search, page })
const res = await getKnowledgeEntries({
category_id: selectedCategory,
search,
status: statusFilter,
page,
pageSize: 10,
})
setEntries(res.list)
setTotal(res.total)
} catch { setEntries([]) } finally { setLoading(false) }
} catch {
setEntries([])
} finally {
setLoading(false)
}
}
const openCreate = () => {
setEditingEntry(null)
form.resetFields()
form.setFieldsValue({
status: 'published',
category_id: selectedCategory ? Number(selectedCategory) : undefined,
})
setModalOpen(true)
}
const openEdit = (entry: KnowledgeEntry) => {
setEditingEntry(entry)
form.setFieldsValue({
title: entry.title,
content: entry.content,
status: entry.status,
category_id: entry.category_id,
})
setModalOpen(true)
}
const handleSave = async (values: { title: string; content: string; status: string; category_id: number }) => {
setSaving(true)
try {
if (editingEntry) {
await updateKnowledgeEntry(editingEntry.id, values)
message.success('条目已更新')
} else {
await createKnowledgeEntry(values)
message.success('条目已创建')
}
setModalOpen(false)
await loadEntries()
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
try {
await deleteKnowledgeEntry(id)
message.success('已删除')
await loadEntries()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const handleCreateCategory = async (values: { name: string }) => {
try {
await createKnowledgeCategory({ name: values.name.trim() })
message.success('分类已创建')
setCategoryModalOpen(false)
categoryForm.resetFields()
await loadCategories()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建分类失败')
}
}
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag> },
{ title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 200, render: (c: number) => (
<div className="flex items-center gap-2"><Progress percent={Math.min(c / 2, 100)} size="small" showInfo={false} strokeColor="#2563eb" className="flex-1 max-w-32" /><span className="text-xs text-neutral-400">{c}</span></div>
)},
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120, render: (t: string) => <span className="text-xs text-neutral-400">{t ? new Date(t).toLocaleDateString() : '-'}</span> },
{ title: '操作', key: 'actions', width: 100, render: () => <Button type="link" size="small" icon={<EditOutlined />}></Button> },
{
title: '标题', dataIndex: 'title', key: 'title',
render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag>,
},
{
title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 180,
render: (c: number) => (
<div className="flex items-center gap-2">
<Progress percent={Math.min(c / 2, 100)} size="small" showInfo={false} strokeColor="#2563eb" className="flex-1 max-w-28" />
<span className="text-xs text-neutral-400">{c}</span>
</div>
),
},
{
title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120,
render: (t: string) => <span className="text-xs text-neutral-400">{t ? new Date(t).toLocaleDateString() : '—'}</span>,
},
{
title: '操作', key: 'actions', width: 140,
render: (_: unknown, record: KnowledgeEntry) => canManage ? (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}></Button>
<Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
) : <span className="text-xs text-neutral-400"></span>,
},
]
return (
<div className="h-full flex">
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FileTextOutlined className="text-blue-500" />
<span className="text-sm font-semibold text-neutral-700"></span>
</div>
{canManage && (
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={() => setCategoryModalOpen(true)} />
)}
</div>
<button
type="button"
className={`w-full text-left text-sm px-2 py-1.5 rounded mb-1 ${!selectedCategory ? 'bg-blue-50 text-blue-600' : 'text-neutral-600 hover:bg-neutral-50'}`}
onClick={() => { setSelectedCategory(''); setPage(1) }}
>
</button>
<Tree
treeData={categories as any}
defaultExpandAll
selectedKeys={selectedCategory ? [selectedCategory] : []}
onSelect={keys => setSelectedCategory(keys.length > 0 ? (keys[0] as string) : '')}
onSelect={keys => { setSelectedCategory(keys.length > 0 ? String(keys[0]) : ''); setPage(1) }}
blockNode
className="flex-1 overflow-auto"
/>
</div>
<div className="flex-1 flex flex-col p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-neutral-800"></h2>
<Input prefix={<SearchOutlined />} placeholder="搜索..." value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-48" size="small" allowClear />
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-neutral-800 m-0"></h2>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题或内容"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="w-48"
size="small"
allowClear
/>
<Select
allowClear
size="small"
placeholder="状态"
className="w-28"
value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }}
options={[
{ value: 'published', label: '已发布' },
{ value: 'draft', label: '草稿' },
]}
/>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingEntry(null); form.resetFields(); setModalOpen(true) }}></Button>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
)}
</div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table dataSource={entries} columns={columns} rowKey="id" size="middle" loading={loading}
<Table
dataSource={entries}
columns={columns}
rowKey="id"
size="middle"
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t}`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无知识条目" /> }} />
locale={{ emptyText: <Empty description="暂无知识条目" /> }}
/>
</div>
</div>
<Modal title={editingEntry ? '编辑' : '新建'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} width={640}>
<Form form={form} layout="vertical" onFinish={() => setModalOpen(false)} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={100} /></Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }]}><Input.TextArea rows={5} maxLength={5000} showCount /></Form.Item>
<Form.Item name="status" label="状态"><Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} /></Form.Item>
<Modal
title={editingEntry ? '编辑知识条目' : '新建知识条目'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true }, { min: 2, max: 100 }]}>
<Input maxLength={100} />
</Form.Item>
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select
options={categories.map(c => ({ value: c.id, label: c.title }))}
placeholder={categories.length ? '选择分类' : '请先创建分类'}
/>
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }, { max: 5000 }]}>
<Input.TextArea rows={6} maxLength={5000} showCount />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="新建分类"
open={categoryModalOpen}
onCancel={() => setCategoryModalOpen(false)}
onOk={() => categoryForm.submit()}
destroyOnClose
>
<Form form={categoryForm} layout="vertical" onFinish={handleCreateCategory} className="mt-2">
<Form.Item name="name" label="分类名称" rules={[{ required: true }, { min: 2, max: 30 }]}>
<Input maxLength={30} placeholder="如:售后服务" />
</Form.Item>
</Form>
</Modal>
</div>
+203 -97
View File
@@ -1,19 +1,25 @@
import { useState } from 'react'
import { Form, Input, Switch, Select, Button, Card, Table, Tag, message } from 'antd'
import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined } from '@ant-design/icons'
import { useEffect, useState } from 'react'
import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty } from 'antd'
import {
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
} from '@ant-design/icons'
import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api'
const channelTypes = [
{ key: 'web', icon: <LinkOutlined />, label: '网页聊天', status: true, code: 'WK_8a3f2e', script: '<script src="https://cs.example.com/widget.js" data-id="WK_8a3f2e"></script>' },
{ key: 'wechat', icon: <WechatOutlined />, label: '微信公众号', status: true, code: 'WX_c7b9d1', script: '' },
{ key: 'app', icon: <MobileOutlined />, label: 'APP 内嵌', status: false, code: '', script: '' },
{ key: 'phone', icon: <PhoneOutlined />, label: '电话客服', status: false, code: '', script: '' },
{ key: 'email', icon: <MailOutlined />, label: '邮件工单', status: true, code: 'EM_f2a8c3', script: '' },
]
const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
web: { icon: <LinkOutlined />, label: '网页聊天' },
wechat: { icon: <WechatOutlined />, label: '微信公众号' },
app: { icon: <MobileOutlined />, label: 'APP 内嵌' },
phone: { icon: <PhoneOutlined />, label: '电话客服' },
email: { icon: <MailOutlined />, label: '邮件工单' },
}
const Settings = () => {
const [activeTab, setActiveTab] = useState('basic')
const [activeTab, setActiveTab] = useState('channels')
const [basicForm] = Form.useForm()
const [autoReplyForm] = Form.useForm()
const [channels, setChannels] = useState<Channel[]>([])
const [loadingChannels, setLoadingChannels] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const tabItems = [
{ key: 'basic', label: '基本设置' },
@@ -25,9 +31,63 @@ const Settings = () => {
{ key: 'notify', label: '通知设置' },
]
const loadChannels = async () => {
setLoadingChannels(true)
try {
const res = await getChannels()
setChannels(Array.isArray(res.data) ? res.data : [])
} catch {
setChannels([])
message.error('加载渠道失败')
} finally {
setLoadingChannels(false)
}
}
useEffect(() => {
if (activeTab === 'channels') loadChannels()
}, [activeTab])
const copyScript = async (script: string) => {
try {
const origin = window.location.origin
const text = script.includes('src="/widget.js"')
? script.replace('src="/widget.js"', `src="${origin}/widget.js"`)
: script
await navigator.clipboard.writeText(text)
message.success('已复制接入代码')
} catch {
message.error('复制失败')
}
}
const toggleChannel = async (ch: Channel, enabled: boolean) => {
setTogglingId(ch.id)
try {
const res = await updateChannel(ch.id, { status: enabled ? 'enabled' : 'disabled' })
setChannels(prev => prev.map(c => c.id === ch.id ? res.data : c))
message.success(enabled ? '渠道已启用' : '渠道已停用')
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
} finally {
setTogglingId(null)
}
}
const ensureWebChannel = async () => {
try {
await createChannel({ type: 'web', name: '网页聊天' })
message.success('已创建网页渠道')
await loadChannels()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
}
}
const missingTypes = Object.keys(typeMeta).filter(t => !channels.some(c => c.type === t))
return (
<div className="h-full flex">
{/* 左侧导航 */}
<div className="w-[200px] flex-shrink-0 bg-white border-r border-neutral-200 py-4">
<div className="px-4 mb-3">
<span className="text-xs text-neutral-400 font-medium"></span>
@@ -35,7 +95,11 @@ const Settings = () => {
{tabItems.map(item => (
<div
key={item.key}
className={`px-4 py-2 text-sm cursor-pointer transition-colors ${activeTab === item.key ? 'bg-blue-50 text-blue-600 font-medium border-r-2 border-blue-500' : 'text-neutral-600 hover:bg-neutral-50'}`}
className={`px-4 py-2 text-sm cursor-pointer transition-colors ${
activeTab === item.key
? 'bg-blue-50 text-blue-600 font-medium border-r-2 border-blue-500'
: 'text-neutral-600 hover:bg-neutral-50'
}`}
onClick={() => setActiveTab(item.key)}
>
{item.label}
@@ -43,9 +107,7 @@ const Settings = () => {
))}
</div>
{/* 右侧内容 */}
<div className="flex-1 overflow-auto p-6">
{/* 基本设置 */}
{activeTab === 'basic' && (
<Card title="基本设置" className="max-w-2xl">
<Form form={basicForm} layout="vertical" initialValues={{ name: '示例公司', nickname: '客服小助手', timezone: 'Asia/Shanghai', language: 'zh-CN' }}>
@@ -62,164 +124,208 @@ const Settings = () => {
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
<Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}></Button>
</Form.Item>
</Form>
</Card>
)}
{/* 渠道管理 */}
{activeTab === 'channels' && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-neutral-800"></h3>
<div>
<h3 className="text-base font-semibold text-neutral-800 m-0"></h3>
<p className="text-xs text-neutral-400 mt-1 mb-0"></p>
</div>
{missingTypes.includes('web') && (
<Button type="primary" icon={<PlusOutlined />} onClick={ensureWebChannel}></Button>
)}
</div>
{loadingChannels ? (
<div className="py-16 text-center"><Spin /></div>
) : channels.length === 0 ? (
<Empty description="暂无渠道,请先开通网页渠道">
<Button type="primary" onClick={ensureWebChannel}></Button>
</Empty>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{channelTypes.map(ch => (
<Card key={ch.key} size="small" className="!rounded-lg" title={
{channels.map(ch => {
const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type }
const enabled = ch.status === 'enabled'
return (
<Card
key={ch.id}
size="small"
className="!rounded-lg"
title={(
<div className="flex items-center gap-2">
<span className="text-blue-500">{ch.icon}</span>
<span className="text-sm font-medium">{ch.label}</span>
<Tag color={ch.status ? 'green' : 'default'} className="ml-2">{ch.status ? '已启用' : '未启用'}</Tag>
<span className="text-blue-500">{meta.icon}</span>
<span className="text-sm font-medium">{ch.name || meta.label}</span>
<Tag color={enabled ? 'green' : 'default'} className="ml-1">{enabled ? '已启用' : '未启用'}</Tag>
</div>
}>
)}
>
<div className="space-y-3">
{ch.code && <div className="flex items-center justify-between text-sm"><span className="text-neutral-400">ID</span><code className="text-xs bg-neutral-100 px-2 py-0.5 rounded">{ch.code}</code></div>}
{ch.script && (
{ch.channel_key && (
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-400"> ID</span>
<code className="text-xs bg-neutral-100 px-2 py-0.5 rounded">{ch.channel_key}</code>
</div>
)}
{ch.type === 'web' && ch.script_code && (
<div>
<div className="text-sm text-neutral-400 mb-1"></div>
<div className="bg-neutral-50 border border-neutral-200 rounded p-2 flex items-center justify-between">
<code className="text-xs text-neutral-600 break-all">{ch.script}</code>
<CopyOutlined className="text-neutral-400 cursor-pointer hover:text-blue-500 ml-2 flex-shrink-0" onClick={() => message.success('已复制')} />
<div className="bg-neutral-50 border border-neutral-200 rounded p-2 flex items-start justify-between gap-2">
<code className="text-xs text-neutral-600 break-all">
{ch.script_code.replace('src="/widget.js"', `src="${window.location.origin}/widget.js"`)}
</code>
<CopyOutlined
className="text-neutral-400 cursor-pointer hover:text-blue-500 flex-shrink-0 mt-0.5"
onClick={() => copyScript(ch.script_code)}
/>
</div>
</div>
)}
{ch.type !== 'web' && (
<div className="text-xs text-neutral-400">
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-neutral-400"></span>
<Switch defaultChecked={ch.status} size="small" />
<Switch
checked={enabled}
size="small"
loading={togglingId === ch.id}
onChange={v => toggleChannel(ch, v)}
/>
</div>
</div>
</Card>
)
})}
</div>
)}
{missingTypes.length > 0 && channels.length > 0 && (
<div className="mt-4">
<div className="text-xs text-neutral-400 mb-2"></div>
<div className="flex flex-wrap gap-2">
{missingTypes.map(t => (
<Button
key={t}
size="small"
icon={<PlusOutlined />}
onClick={async () => {
try {
await createChannel({ type: t, name: typeMeta[t].label })
message.success('已添加')
await loadChannels()
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败')
}
}}
>
{typeMeta[t].label}
</Button>
))}
</div>
</div>
)}
</div>
)}
{/* 分配规则 */}
{activeTab === 'assignment' && (
<Card title="客服分配规则" className="max-w-2xl">
<div className="space-y-4">
<div className="p-4 border border-neutral-200 rounded-lg">
<div className="p-4 border border-blue-100 bg-blue-50 rounded-lg">
<div className="text-sm font-medium text-neutral-800"></div>
<div className="text-xs text-neutral-500 mt-1">
线线线
</div>
</div>
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-neutral-800"></div>
<div className="text-xs text-neutral-400 mt-1">线</div>
<div className="text-xs text-neutral-400 mt-1"></div>
</div>
<Switch defaultChecked />
<Switch disabled />
</div>
</div>
<div className="p-4 border border-neutral-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-neutral-800"></div>
<div className="text-xs text-neutral-400 mt-1"></div>
</div>
<Switch />
</div>
</div>
<div className="p-4 border border-neutral-200 rounded-lg">
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-neutral-800"></div>
<div className="text-xs text-neutral-400 mt-1"></div>
<div className="text-xs text-neutral-400 mt-1"></div>
</div>
<Switch defaultChecked />
<Switch disabled />
</div>
</div>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
</div>
</Card>
)}
{/* 权限管理 */}
{activeTab === 'permission' && (
<Card title="权限管理" className="max-w-3xl">
<Table
dataSource={[
{ key: '1', role: '租户管理员', members: 2, permissions: '全部权限', status: true },
{ key: '2', role: '客服主管', members: 3, permissions: '会话管理、数据统计、知识库管理', status: true },
{ key: '3', role: '一线客服', members: 8, permissions: '会话接待、客户管理、知识库查看', status: true },
]}
columns={[
{ title: '角色', dataIndex: 'role', key: 'role' },
{ title: '成员数', dataIndex: 'members', key: 'members' },
{ title: '权限范围', dataIndex: 'permissions', key: 'permissions' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
{ title: '操作', key: 'op', render: () => <Button type="link" size="small"></Button> },
]}
pagination={false}
size="middle"
/>
<Card title="权限管理" className="max-w-2xl">
<p className="text-sm text-neutral-500 m-0"> / / / 线</p>
</Card>
)}
{/* 自动回复 */}
{activeTab === 'autoreply' && (
<Card title="自动回复设置" className="max-w-2xl">
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎来到客服云,请问有什么可以帮您', offline: '当前无客服在线,请留下联系方式,我们会尽快回复您。' }}>
<Card title="自动回复" className="max-w-2xl">
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎咨询,请问有什么可以帮您?', offline: '当前无客服在线,请留言并留下联系方式。' }}>
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="访客打开聊天窗口后自动发送的欢迎语" maxLength={500} showCount />
<Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item>
<Form.Item name="offline" label="离线留言提示">
<Input.TextArea rows={3} placeholder="无客服在线时自动回复的提示语" maxLength={500} showCount />
<Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
<Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}></Button>
</Form.Item>
</Form>
</Card>
)}
{/* 工作时间 */}
{activeTab === 'worktime' && (
<Card title="工作时间设置" className="max-w-2xl">
<div className="space-y-4">
<div>
<div className="text-sm font-medium text-neutral-700 mb-2"></div>
<Card title="工作时间" className="max-w-2xl">
<p className="text-sm text-neutral-500 mb-4"></p>
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
<div key={day} className="flex items-center gap-4 py-2 border-b border-neutral-50 last:border-0">
<span className="text-sm text-neutral-600 w-12">{day}</span>
<Select defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'} placeholder="休息" className="w-36" size="small" allowClear options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))} />
<div key={day} className="flex items-center justify-between py-2 border-b border-neutral-50">
<span className="text-sm text-neutral-700 w-12">{day}</span>
<Select
defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'}
placeholder="休息"
className="w-36"
size="small"
allowClear
disabled
options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))}
/>
</div>
))}
</div>
<div>
<div className="text-sm font-medium text-neutral-700 mb-2"></div>
<Input placeholder="当前非工作时间,我们将在工作时段尽快回复您" />
</div>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
</div>
</Card>
)}
{/* 通知设置 */}
{activeTab === 'notify' && (
<Card title="通知设置" className="max-w-2xl">
<div className="space-y-4">
{[ { label: '新会话提醒', desc: '有新访客发起会话时桌面通知' },
{ label: '消息声音', desc: '收到新消息时播放提示音' },
{ label: '离线消息通知', desc: '非工作时间收到的留言邮件通知' },
{ label: '日报推送', desc: '每日客服数据汇总邮件' },
{ label: '周报推送', desc: '每周客服数据汇总邮件' },
].map((item, i) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-neutral-50 last:border-0">
<div className="space-y-3">
{[
{ title: '新会话提醒', desc: '有新访客进线时桌面通知' },
{ title: '离线留言通知', desc: '访客提交离线留言时提醒' },
{ title: '日报推送', desc: '每日服务数据摘要' },
].map(item => (
<div key={item.title} className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
<div>
<div className="text-sm text-neutral-700">{item.label}</div>
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
<div className="text-xs text-neutral-400">{item.desc}</div>
</div>
<Switch defaultChecked={i < 3} />
<Switch disabled defaultChecked={item.title !== '日报推送'} />
</div>
))}
<div className="text-xs text-neutral-400"></div>
</div>
</Card>
)}
+32 -4
View File
@@ -1,4 +1,4 @@
import { get, post, put, getList } from './request'
import { get, post, put, del, getList } from './request'
export interface LoginParams { username: string; password: string }
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
@@ -7,6 +7,7 @@ export interface Session {
id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null
status: string; priority: string; unread_count: number; satisfaction_score: number | null
last_message?: string; last_message_at?: string | null
satisfaction_text?: string
created_at: string; ended_at: string | null
}
@@ -26,11 +27,20 @@ export interface Customer {
source: string; status: string; conversation_count: number; last_contact_at: string
}
export interface KnowledgeCategory {
id: number; tenant_id: number; parent_id: number | null; name: string
}
export interface KnowledgeEntry {
id: number; title: string; content: string; status: string; usage_count: number
category_id: number; updated_at: string
}
export interface Channel {
id: number; tenant_id: number; type: string; name: string; status: string
config: string; script_code: string; channel_key: string
}
export interface Tenant {
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
contact_name: string; contact_phone: string; contact_email: string
@@ -68,24 +78,42 @@ export const sendSessionMessage = (id: number, content: string, type: 'text' | '
export const getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
// Customers
export const getCustomers = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {
export const getCustomers = (params?: { search?: string; status?: string; source?: string; page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
if (params?.source) search.set('source', params.source)
if (params?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList<Customer>(`/customers?${search}`)
}
export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`)
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// Knowledge
export const getKnowledgeCategories = () => get<unknown[]>('/knowledge/categories')
export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; page?: number }) => {
export const getKnowledgeCategories = () => get<KnowledgeCategory[]>('/knowledge/categories')
export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post<KnowledgeCategory>('/knowledge/categories', data)
export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; status?: string; page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.category_id) search.set('category_id', params.category_id)
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
return getList<KnowledgeEntry>(`/knowledge/entries?${search}`)
}
export const createKnowledgeEntry = (data: { title: string; content: string; category_id: number; status?: string }) =>
post<KnowledgeEntry>('/knowledge/entries', data)
export const updateKnowledgeEntry = (id: number, data: Partial<KnowledgeEntry>) =>
put<KnowledgeEntry>(`/knowledge/entries/${id}`, data)
export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`)
// Channels
export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
// Statistics
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')