实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<Tag color={statusColors[s.status]} className="text-xs">{statusLabels[s.status] || s.status}</Tag>
|
||||
<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">
|
||||
<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>
|
||||
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</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>
|
||||
{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>
|
||||
</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 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">
|
||||
会话 #{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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
) : <div className="h-full flex items-center justify-center text-neutral-400">选择一个对话查看详情</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>
|
||||
)
|
||||
|
||||
@@ -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) => (
|
||||
<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>}
|
||||
</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: '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 || 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<FileTextOutlined className="text-blue-500" />
|
||||
<span className="text-sm font-semibold text-neutral-700">知识分类</span>
|
||||
<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>
|
||||
|
||||
+219
-113
@@ -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>
|
||||
<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={
|
||||
<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>
|
||||
</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 && (
|
||||
<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('已复制')} />
|
||||
|
||||
{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">
|
||||
{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">{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.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-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
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-400">启用状态</span>
|
||||
<Switch defaultChecked={ch.status} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].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>
|
||||
))}
|
||||
<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 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 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>
|
||||
)}
|
||||
|
||||
+40
-12
@@ -1,13 +1,14 @@
|
||||
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 }
|
||||
|
||||
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
|
||||
created_at: string; ended_at: string | null
|
||||
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
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -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
|
||||
@@ -52,8 +62,8 @@ export const getSessions = (params?: { status?: string; priority?: string; page?
|
||||
const search = new URLSearchParams()
|
||||
if (params?.status) search.set('status', params.status)
|
||||
if (params?.priority) search.set('priority', params.priority)
|
||||
if (params?.page) search.set('page', String(params.page))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
if (params?.page) search.set('page', String(params.page))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
return getList<Session>(`/sessions?${search}`)
|
||||
}
|
||||
export const getSession = (id: number) => get<{ session: Session; messages: Message[]; events: SessionEvent[]; pending_count: number }>(`/sessions/${id}`)
|
||||
@@ -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?.page) search.set('page', String(params.page || 1))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user