实现 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() knowledge := NewKnowledgeHandler()
stats := NewStatisticsHandler() stats := NewStatisticsHandler()
admin := NewAdminHandler() admin := NewAdminHandler()
channel := NewChannelHandler()
ws := NewWsHandler() ws := NewWsHandler()
widget := NewWidgetHandler() widget := NewWidgetHandler()
@@ -68,6 +69,12 @@ func SetupRoutes(r *gin.Engine) {
kb.PUT("/entries/:id", knowledge.UpdateEntry) kb.PUT("/entries/:id", knowledge.UpdateEntry)
kb.DELETE("/entries/:id", knowledge.DeleteEntry) 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 := authRequired.Group("/statistics")
statistics.GET("/kpi", stats.KPIs) statistics.GET("/kpi", stats.KPIs)
@@ -540,3 +540,86 @@ func TestWidgetAutoAssignAndOfflineLeave(t *testing.T) {
t.Fatalf("未记录自动分配事件: %v", err) 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())
}
}
+206 -57
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 { Input, Select, Tag, Empty, Spin } from 'antd'
import { SearchOutlined, UserOutlined } from '@ant-design/icons' 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 statusColors: Record<string, string> = {
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' } 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 ChatHistory = () => {
const [sessions, setSessions] = useState<SessionType[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>() const [statusFilter, setStatusFilter] = useState<string>()
const [priorityFilter, setPriorityFilter] = useState<string>()
const [selectedId, setSelectedId] = useState<number | null>(null) 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) const [detailLoading, setDetailLoading] = useState(false)
useEffect(() => { loadSessions() }, [statusFilter]) useEffect(() => {
loadSessions()
}, [statusFilter, priorityFilter])
const loadSessions = async () => { const loadSessions = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getSessions({ status: statusFilter }) const [sessionRes, customerRes] = await Promise.all([
setSessions(res.list) getSessions({ status: statusFilter, priority: priorityFilter, page: 1, pageSize: 100 }),
if (res.list.length > 0 && !selectedId) setSelectedId(res.list[0].id) getCustomers({ page: 1, pageSize: 200 }),
} catch { setSessions([]) } finally { setLoading(false) } ])
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(() => { useEffect(() => {
if (!selectedId) { if (!selectedId) {
setMessages([]) setMessages([])
setEvents([])
setSelectedSession(null)
return return
} }
setDetailLoading(true) setDetailLoading(true)
getSession(selectedId).then(res => { getSession(selectedId).then(res => {
const detail = res.data as { messages?: { id: number; sender_type: string; content: string; sent_at: string }[] } setMessages(res.data.messages || [])
setMessages(detail.messages || []) setEvents(res.data.events || [])
}).catch(() => setMessages([])).finally(() => setDetailLoading(false)) setSelectedSession(res.data.session || sessions.find(s => s.id === selectedId) || null)
}).catch(() => {
setMessages([])
setEvents([])
}).finally(() => setDetailLoading(false))
}, [selectedId]) }, [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 ( return (
<div className="h-full flex"> <div className="h-full flex">
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col"> <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"> <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" /> <Input
<Select placeholder="状态" value={statusFilter} onChange={setStatusFilter} allowClear size="small" className="w-full" options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))} /> 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>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? <div className="flex justify-center py-10"><Spin /></div> : {loading ? (
sessions.filter(s => !search || s.status.includes(search) || String(s.customer_id).includes(search)).map(s => ( <div className="flex justify-center py-10"><Spin /></div>
<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' : ''}`} ) : filtered.map(s => {
onClick={() => setSelectedId(s.id)}> const name = customers[s.customer_id]?.name || `客户${s.customer_id}`
<div className="flex items-center justify-between"> return (
<div className="flex items-center gap-2"> <button
<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> key={s.id}
<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> 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> </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>
<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> <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>
</div> </button>
))} )
{!loading && sessions.length === 0 && <div className="flex items-center justify-center h-full"><Empty description="暂无对话记录" /></div>} })}
{!loading && filtered.length === 0 && (
<div className="flex items-center justify-center h-40"><Empty description="暂无对话记录" /></div>
)}
</div> </div>
</div> </div>
<div className="flex-1 bg-white overflow-auto"> <div className="flex-1 bg-neutral-50 overflow-auto">
{selected ? ( {selected ? (
<div className="p-6 max-w-3xl mx-auto"> <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 className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2"> <div className="min-w-0">
<h3 className="text-lg font-semibold text-neutral-800"> {selected.id}</h3> <div className="flex items-center gap-2 flex-wrap">
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag> <h3 className="text-lg font-semibold text-neutral-800 m-0 truncate">
</div> {customer?.name || `客户${selected.customer_id}`}
<div className="text-sm text-neutral-400 mt-1">ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}</div> </h3>
</div> <Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
{selected.satisfaction_score && ( {selected.priority === 'urgent' && <Tag color="red"></Tag>}
<div className="text-right"> </div>
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}</div> <div className="text-sm text-neutral-400 mt-1">
<div className="text-xs text-neutral-400"></div> #{selected.id}
</div> {customer?.source ? ` · ${customer.source}` : ''}
)} {' · '}
</div> {new Date(selected.created_at).toLocaleString('zh-CN')}
<div className="space-y-4"> {selected.ended_at ? ` ~ ${new Date(selected.ended_at).toLocaleString('zh-CN')}` : ''}
{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>
</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>
<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>
) : <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>
</div> </div>
) )
+254 -32
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty, message } from 'antd' import {
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty,
import { getCustomers, type Customer } from '@/services/api' 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 }> = { const statusMap: Record<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' }, online: { color: 'green', text: '在线' },
@@ -9,19 +16,36 @@ const statusMap: Record<string, { color: string; text: string }> = {
busy: { color: 'orange', text: '忙碌' }, busy: { color: 'orange', text: '忙碌' },
} }
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
const tagColors: Record<string, string> = { const tagColors: Record<string, string> = {
'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple', '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 Customers = () => {
const { user } = useAuth()
const canDelete = user?.role === 'admin' || user?.role === 'supervisor'
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string[]>([]) const [statusFilter, setStatusFilter] = useState<string>()
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null) 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 [drawerOpen, setDrawerOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
useEffect(() => { useEffect(() => {
loadCustomers() loadCustomers()
@@ -30,7 +54,7 @@ const Customers = () => {
const loadCustomers = async () => { const loadCustomers = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getCustomers({ search, status: statusFilter[0], page }) const res = await getCustomers({ search, status: statusFilter, page, pageSize: 10 })
setCustomers(res.list) setCustomers(res.list)
setTotal(res.total) setTotal(res.total)
} catch { } catch {
@@ -40,39 +64,164 @@ const Customers = () => {
} }
} }
const parseTags = (tagsStr: string): string[] => { const openDetail = async (record: Customer) => {
try { return JSON.parse(tagsStr) } catch { return [] } 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 = [ 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: '客户名称', dataIndex: 'name', key: 'name',
)}, render: (text: string, record: Customer) => (
{ title: '联系方式', key: 'contact', render: (_: unknown, record: Customer) => ( <span className="text-sm font-medium text-neutral-800 cursor-pointer hover:text-blue-500" onClick={() => openDetail(record)}>{text}</span>
<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: '联系方式', key: 'contact',
)}, render: (_: unknown, record: Customer) => (
{ title: '标签', dataIndex: 'tags', key: 'tags', render: (tags: string) => ( <div className="space-y-0.5">
<Space size={4} wrap>{parseTags(tags).map((t: string) => <Tag key={t} color={tagColors[t] || 'default'} className="text-xs">{t}</Tag>)}</Space> {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>}
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Badge color={statusMap[s]?.color} text={statusMap[s]?.text} /> }, {!record.phone && !record.email && <span className="text-xs text-neutral-300"></span>}
{ title: '来源', dataIndex: 'source', key: 'source', render: (t: string) => <span className="text-xs text-neutral-500">{t}</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: '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 ( return (
<div className="h-full flex flex-col p-6"> <div className="h-full flex flex-col p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-800"></h2> <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>
<div className="flex gap-3 mb-3 flex-wrap"> <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 /> <Input
<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 /> 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>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden"> <div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
@@ -84,28 +233,101 @@ const Customers = () => {
loading={loading} loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个客户`, onChange: p => setPage(p) }} pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个客户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无客户数据" /> }} locale={{ emptyText: <Empty description="暂无客户数据" /> }}
onRow={record => ({ onClick: () => { setSelectedCustomer(record); setDrawerOpen(true) }, style: { cursor: 'pointer' } })} onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
/> />
</div> </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 && ( {selectedCustomer && (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-neutral-100"> <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>
<div className="text-base font-medium text-neutral-800">{selectedCustomer.name}</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>
</div> </div>
<Descriptions column={1} size="small" colon={false}> <Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="手机号">{selectedCustomer.phone || '-'}</Descriptions.Item> <Descriptions.Item label="手机号">{selectedCustomer.phone || ''}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedCustomer.email || '-'}</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="状态">
<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> </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> </div>
)} )}
</Drawer> </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> </div>
) )
} }
+226 -33
View File
@@ -1,17 +1,34 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress } from 'antd' import {
import { SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons' Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress,
import { getKnowledgeCategories, getKnowledgeEntries, type KnowledgeEntry } from '@/services/api' 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 Knowledge = () => {
const { user } = useAuth()
const canManage = user?.role === 'admin' || user?.role === 'supervisor'
const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([]) const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([])
const [entries, setEntries] = useState<KnowledgeEntry[]>([]) const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('') const [selectedCategory, setSelectedCategory] = useState<string>('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null) const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [categoryForm] = Form.useForm()
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -21,68 +38,244 @@ const Knowledge = () => {
useEffect(() => { useEffect(() => {
loadEntries() loadEntries()
}, [selectedCategory, search, page]) }, [selectedCategory, search, page, statusFilter])
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const res = await getKnowledgeCategories() const res = await getKnowledgeCategories()
setCategories((res.data as any[]).map((c: any) => ({ key: String(c.id), title: c.name, id: c.id }))) const list = Array.isArray(res.data) ? res.data : []
} catch { /* fallback */ } setCategories(list.map(c => ({ key: String(c.id), title: c.name, id: c.id })))
} catch {
setCategories([])
}
} }
const loadEntries = async () => { const loadEntries = async () => {
setLoading(true) setLoading(true)
try { 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) setEntries(res.list)
setTotal(res.total) 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 = [ 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: 'title', key: 'title',
{ title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 200, render: (c: number) => ( render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span>,
<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: '状态', dataIndex: 'status', key: 'status', width: 90,
{ title: '操作', key: 'actions', width: 100, render: () => <Button type="link" size="small" icon={<EditOutlined />}></Button> }, 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 ( return (
<div className="h-full flex"> <div className="h-full flex">
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4"> <div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center justify-between mb-3">
<FileTextOutlined className="text-blue-500" /> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-700"></span> <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> </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 <Tree
treeData={categories as any} treeData={categories as any}
defaultExpandAll
selectedKeys={selectedCategory ? [selectedCategory] : []} 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 blockNode
className="flex-1 overflow-auto"
/> />
</div> </div>
<div className="flex-1 flex flex-col p-6"> <div className="flex-1 flex flex-col p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-neutral-800"></h2> <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 /> <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> </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>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden"> <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) }} pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t}`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无知识条目" /> }} /> locale={{ emptyText: <Empty description="暂无知识条目" /> }}
/>
</div> </div>
</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"> <Modal
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={100} /></Form.Item> title={editingEntry ? '编辑知识条目' : '新建知识条目'}
<Form.Item name="content" label="内容" rules={[{ required: true }]}><Input.TextArea rows={5} maxLength={5000} showCount /></Form.Item> open={modalOpen}
<Form.Item name="status" label="状态"><Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} /></Form.Item> 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> </Form>
</Modal> </Modal>
</div> </div>
+219 -113
View File
@@ -1,19 +1,25 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Form, Input, Switch, Select, Button, Card, Table, Tag, message } from 'antd' import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty } from 'antd'
import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined } from '@ant-design/icons' import {
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
} from '@ant-design/icons'
import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api'
const channelTypes = [ const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
{ key: 'web', icon: <LinkOutlined />, label: '网页聊天', status: true, code: 'WK_8a3f2e', script: '<script src="https://cs.example.com/widget.js" data-id="WK_8a3f2e"></script>' }, web: { icon: <LinkOutlined />, label: '网页聊天' },
{ key: 'wechat', icon: <WechatOutlined />, label: '微信公众号', status: true, code: 'WX_c7b9d1', script: '' }, wechat: { icon: <WechatOutlined />, label: '微信公众号' },
{ key: 'app', icon: <MobileOutlined />, label: 'APP 内嵌', status: false, code: '', script: '' }, app: { icon: <MobileOutlined />, label: 'APP 内嵌' },
{ key: 'phone', icon: <PhoneOutlined />, label: '电话客服', status: false, code: '', script: '' }, phone: { icon: <PhoneOutlined />, label: '电话客服' },
{ key: 'email', icon: <MailOutlined />, label: '邮件工单', status: true, code: 'EM_f2a8c3', script: '' }, email: { icon: <MailOutlined />, label: '邮件工单' },
] }
const Settings = () => { const Settings = () => {
const [activeTab, setActiveTab] = useState('basic') const [activeTab, setActiveTab] = useState('channels')
const [basicForm] = Form.useForm() const [basicForm] = Form.useForm()
const [autoReplyForm] = 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 = [ const tabItems = [
{ key: 'basic', label: '基本设置' }, { key: 'basic', label: '基本设置' },
@@ -25,9 +31,63 @@ const Settings = () => {
{ key: 'notify', label: '通知设置' }, { 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 ( return (
<div className="h-full flex"> <div className="h-full flex">
{/* 左侧导航 */}
<div className="w-[200px] flex-shrink-0 bg-white border-r border-neutral-200 py-4"> <div className="w-[200px] flex-shrink-0 bg-white border-r border-neutral-200 py-4">
<div className="px-4 mb-3"> <div className="px-4 mb-3">
<span className="text-xs text-neutral-400 font-medium"></span> <span className="text-xs text-neutral-400 font-medium"></span>
@@ -35,7 +95,11 @@ const Settings = () => {
{tabItems.map(item => ( {tabItems.map(item => (
<div <div
key={item.key} 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)} onClick={() => setActiveTab(item.key)}
> >
{item.label} {item.label}
@@ -43,9 +107,7 @@ const Settings = () => {
))} ))}
</div> </div>
{/* 右侧内容 */}
<div className="flex-1 overflow-auto p-6"> <div className="flex-1 overflow-auto p-6">
{/* 基本设置 */}
{activeTab === 'basic' && ( {activeTab === 'basic' && (
<Card title="基本设置" className="max-w-2xl"> <Card title="基本设置" className="max-w-2xl">
<Form form={basicForm} layout="vertical" initialValues={{ name: '示例公司', nickname: '客服小助手', timezone: 'Asia/Shanghai', language: 'zh-CN' }}> <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 /> <Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" onClick={() => message.success('保存成功')}></Button> <Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}></Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
)} )}
{/* 渠道管理 */}
{activeTab === 'channels' && ( {activeTab === 'channels' && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <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>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{channelTypes.map(ch => ( {loadingChannels ? (
<Card key={ch.key} size="small" className="!rounded-lg" title={ <div className="py-16 text-center"><Spin /></div>
<div className="flex items-center gap-2"> ) : channels.length === 0 ? (
<span className="text-blue-500">{ch.icon}</span> <Empty description="暂无渠道,请先开通网页渠道">
<span className="text-sm font-medium">{ch.label}</span> <Button type="primary" onClick={ensureWebChannel}></Button>
<Tag color={ch.status ? 'green' : 'default'} className="ml-2">{ch.status ? '已启用' : '未启用'}</Tag> </Empty>
</div> ) : (
}> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3"> {channels.map(ch => {
{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>} const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type }
{ch.script && ( const enabled = ch.status === 'enabled'
<div> return (
<div className="text-sm text-neutral-400 mb-1"></div> <Card
<div className="bg-neutral-50 border border-neutral-200 rounded p-2 flex items-center justify-between"> key={ch.id}
<code className="text-xs text-neutral-600 break-all">{ch.script}</code> size="small"
<CopyOutlined className="text-neutral-400 cursor-pointer hover:text-blue-500 ml-2 flex-shrink-0" onClick={() => message.success('已复制')} /> 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> </div>
)} </Card>
<div className="flex justify-between items-center"> )
<span className="text-sm text-neutral-400"></span> })}
<Switch defaultChecked={ch.status} size="small" /> </div>
</div> )}
</div>
</Card> {missingTypes.length > 0 && channels.length > 0 && (
))} <div className="mt-4">
</div> <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> </div>
)} )}
{/* 分配规则 */}
{activeTab === 'assignment' && ( {activeTab === 'assignment' && (
<Card title="客服分配规则" className="max-w-2xl"> <Card title="客服分配规则" className="max-w-2xl">
<div className="space-y-4"> <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 className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-medium text-neutral-800"></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> </div>
<Switch defaultChecked /> <Switch disabled />
</div> </div>
</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>
<Switch />
</div>
</div>
<div className="p-4 border border-neutral-200 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-medium text-neutral-800"></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> </div>
<Switch defaultChecked /> <Switch disabled />
</div> </div>
</div> </div>
<Button type="primary" onClick={() => message.success('保存成功')}></Button>
</div> </div>
</Card> </Card>
)} )}
{/* 权限管理 */}
{activeTab === 'permission' && ( {activeTab === 'permission' && (
<Card title="权限管理" className="max-w-3xl"> <Card title="权限管理" className="max-w-2xl">
<Table <p className="text-sm text-neutral-500 m-0"> / / / 线</p>
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> </Card>
)} )}
{/* 自动回复 */}
{activeTab === 'autoreply' && ( {activeTab === 'autoreply' && (
<Card title="自动回复设置" className="max-w-2xl"> <Card title="自动回复" className="max-w-2xl">
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎来到客服云,请问有什么可以帮您', offline: '当前无客服在线,请留下联系方式,我们会尽快回复您。' }}> <Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎咨询,请问有什么可以帮您?', offline: '当前无客服在线,请留言并留下联系方式。' }}>
<Form.Item name="welcome" label="欢迎语"> <Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="访客打开聊天窗口后自动发送的欢迎语" maxLength={500} showCount /> <Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item> </Form.Item>
<Form.Item name="offline" label="离线留言提示"> <Form.Item name="offline" label="离线留言提示">
<Input.TextArea rows={3} placeholder="无客服在线时自动回复的提示语" maxLength={500} showCount /> <Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" onClick={() => message.success('保存成功')}></Button> <Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}></Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
)} )}
{/* 工作时间 */}
{activeTab === 'worktime' && ( {activeTab === 'worktime' && (
<Card title="工作时间设置" className="max-w-2xl"> <Card title="工作时间" className="max-w-2xl">
<div className="space-y-4"> <p className="text-sm text-neutral-500 mb-4"></p>
<div> {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
<div className="text-sm font-medium text-neutral-700 mb-2"></div> <div key={day} className="flex items-center justify-between py-2 border-b border-neutral-50">
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => ( <span className="text-sm text-neutral-700 w-12">{day}</span>
<div key={day} className="flex items-center gap-4 py-2 border-b border-neutral-50 last:border-0"> <Select
<span className="text-sm text-neutral-600 w-12">{day}</span> defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'}
<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 }))} /> placeholder="休息"
</div> 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> </Card>
)} )}
{/* 通知设置 */}
{activeTab === 'notify' && ( {activeTab === 'notify' && (
<Card title="通知设置" className="max-w-2xl"> <Card title="通知设置" className="max-w-2xl">
<div className="space-y-4"> <div className="space-y-3">
{[ { label: '新会话提醒', desc: '有新访客发起会话时桌面通知' }, {[
{ label: '消息声音', desc: '收到新消息时播放提示音' }, { title: '新会话提醒', desc: '有新访客进线时桌面通知' },
{ label: '离线消息通知', desc: '非工作时间收到的留言邮件通知' }, { title: '离线留言通知', desc: '访客提交离线留言时提醒' },
{ label: '日报推送', desc: '每日客服数据汇总邮件' }, { title: '日报推送', desc: '每日服务数据摘要' },
{ label: '周报推送', desc: '每周客服数据汇总邮件' }, ].map(item => (
].map((item, i) => ( <div key={item.title} className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
<div key={i} className="flex items-center justify-between py-2 border-b border-neutral-50 last:border-0">
<div> <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 className="text-xs text-neutral-400">{item.desc}</div>
</div> </div>
<Switch defaultChecked={i < 3} /> <Switch disabled defaultChecked={item.title !== '日报推送'} />
</div> </div>
))} ))}
<div className="text-xs text-neutral-400"></div>
</div> </div>
</Card> </Card>
)} )}
+40 -12
View File
@@ -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 LoginParams { username: string; password: string }
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string } export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
export interface Session { export interface Session {
id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | 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 status: string; priority: string; unread_count: number; satisfaction_score: number | null
last_message?: string; last_message_at?: string | null last_message?: string; last_message_at?: string | null
created_at: string; ended_at: string | null satisfaction_text?: string
created_at: string; ended_at: string | null
} }
export interface Message { export interface Message {
@@ -26,11 +27,20 @@ export interface Customer {
source: string; status: string; conversation_count: number; last_contact_at: string 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 { export interface KnowledgeEntry {
id: number; title: string; content: string; status: string; usage_count: number id: number; title: string; content: string; status: string; usage_count: number
category_id: number; updated_at: string 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 { export interface Tenant {
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
contact_name: string; contact_phone: string; contact_email: 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() const search = new URLSearchParams()
if (params?.status) search.set('status', params.status) if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority) if (params?.priority) search.set('priority', params.priority)
if (params?.page) search.set('page', String(params.page)) if (params?.page) search.set('page', String(params.page))
if (params?.pageSize) search.set('pageSize', String(params.pageSize)) if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList<Session>(`/sessions?${search}`) return getList<Session>(`/sessions?${search}`)
} }
export const getSession = (id: number) => get<{ session: Session; messages: Message[]; events: SessionEvent[]; pending_count: number }>(`/sessions/${id}`) 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') export const getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
// Customers // 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() const search = new URLSearchParams()
if (params?.search) search.set('search', params.search) if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status) if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1)) if (params?.source) search.set('source', params.source)
if (params?.pageSize) search.set('pageSize', String(params.pageSize)) if (params?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList<Customer>(`/customers?${search}`) 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 // Knowledge
export const getKnowledgeCategories = () => get<unknown[]>('/knowledge/categories') export const getKnowledgeCategories = () => get<KnowledgeCategory[]>('/knowledge/categories')
export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; page?: number }) => { 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() const search = new URLSearchParams()
if (params?.category_id) search.set('category_id', params.category_id) if (params?.category_id) search.set('category_id', params.category_id)
if (params?.search) search.set('search', params.search) 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?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
return getList<KnowledgeEntry>(`/knowledge/entries?${search}`) 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 // Statistics
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi') export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')