diff --git a/server/internal/handler/channel.go b/server/internal/handler/channel.go new file mode 100644 index 0000000..08649ca --- /dev/null +++ b/server/internal/handler/channel.go @@ -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(``, 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)) +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 9ff44b3..9c61019 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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) diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 1caabca..d3bef9e 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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: ``, + } + 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()) + } +} diff --git a/web/src/pages/agent/ChatHistory.tsx b/web/src/pages/agent/ChatHistory.tsx index 1ac9859..53388b0 100644 --- a/web/src/pages/agent/ChatHistory.tsx +++ b/web/src/pages/agent/ChatHistory.tsx @@ -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 = { active: 'blue', ended: 'green', archived: 'default' } -const statusLabels: Record = { active: '进行中', ended: '已结束', waiting: '等待中' } +const statusColors: Record = { + active: 'blue', waiting: 'orange', ended: 'green', archived: 'default', +} +const statusLabels: Record = { + active: '进行中', ended: '已结束', waiting: '等待中', archived: '已归档', +} +const priorityLabels: Record = { urgent: '紧急', normal: '普通' } const ChatHistory = () => { - const [sessions, setSessions] = useState([]) + const [sessions, setSessions] = useState([]) + const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState() + const [priorityFilter, setPriorityFilter] = useState() const [selectedId, setSelectedId] = useState(null) - const [messages, setMessages] = useState<{ id: number; sender_type: string; content: string; sent_at: string }[]>([]) + const [messages, setMessages] = useState([]) + const [events, setEvents] = useState([]) + const [selectedSession, setSelectedSession] = useState(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 (
- } placeholder="搜索访客..." value={search} onChange={e => setSearch(e.target.value)} allowClear size="small" /> - } + placeholder="搜索客户/会话/消息" + value={search} + onChange={e => setSearch(e.target.value)} + allowClear + size="small" + /> +
+ ({ value: k, label: v }))} + /> +
- {loading ?
: - sessions.filter(s => !search || s.status.includes(search) || String(s.customer_id).includes(search)).map(s => ( -
setSelectedId(s.id)}> -
-
-
-
客户{s.customer_id}
ID: {s.id}
+ {loading ? ( +
+ ) : filtered.map(s => { + const name = customers[s.customer_id]?.name || `客户${s.customer_id}` + return ( + + ) + })} + {!loading && filtered.length === 0 && ( +
+ )}
-
+
{selected ? (
-
-
-
-

会话 {selected.id}

- {statusLabels[selected.status] || selected.status} -
-
客户ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}
-
- {selected.satisfaction_score && ( -
-
{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}
-
满意度评分
-
- )} -
-
- {detailLoading ?
: messages.length === 0 ? : messages.map(message => ( -
-
-
{message.sender_type === 'agent' ? '客服' : '访客'}
-
{message.content}
-
{new Date(message.sent_at).toLocaleString('zh-CN')}
+
+
+
+
+

+ {customer?.name || `客户${selected.customer_id}`} +

+ {statusLabels[selected.status] || selected.status} + {selected.priority === 'urgent' && 紧急} +
+
+ 会话 #{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')}` : ''}
- ))} + {selected.satisfaction_score != null && ( +
+
+ {'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(Math.max(0, 5 - selected.satisfaction_score))} +
+
满意度评分
+ {selected.satisfaction_text && ( +
{selected.satisfaction_text}
+ )} +
+ )} +
+ +
+
消息记录
+
+ {detailLoading ? ( +
+ ) : messages.length === 0 ? ( + + ) : messages.map(message => { + const isAgent = message.sender_type === 'agent' + return ( +
+
+
+ {isAgent ? '客服' : '访客'} +
+ {message.type === 'image' ? ( + 图片 + ) : ( +
{message.content}
+ )} +
+ {new Date(message.sent_at).toLocaleString('zh-CN')} +
+
+
+ ) + })} +
+
+ + {events.length > 0 && ( +
+
操作记录
+
+ {events.slice().reverse().map(ev => ( +
+ + {new Date(ev.created_at).toLocaleString('zh-CN')} + + {ev.action} + {ev.detail} +
+ ))} +
+
+ )}
- ) :
选择一个对话查看详情
} + ) : ( +
+ +
选择一个对话查看详情
+
+ )}
) diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx index 1088755..71e3004 100644 --- a/web/src/pages/agent/Customers.tsx +++ b/web/src/pages/agent/Customers.tsx @@ -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 = { online: { color: 'green', text: '在线' }, @@ -9,19 +16,36 @@ const statusMap: Record = { busy: { color: 'orange', text: '忙碌' }, } +const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户'] const tagColors: Record = { '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([]) const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [search, setSearch] = useState('') - const [statusFilter, setStatusFilter] = useState([]) + const [statusFilter, setStatusFilter] = useState() const [selectedCustomer, setSelectedCustomer] = useState(null) + const [editingId, setEditingId] = useState(null) + const [historySessions, setHistorySessions] = useState([]) 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) => ( - { setSelectedCustomer(record); setDrawerOpen(true) }}>{text} - )}, - { title: '联系方式', key: 'contact', render: (_: unknown, record: Customer) => ( -
- {record.phone &&
{record.phone}
} - {record.email &&
{record.email}
} -
- )}, - { title: '标签', dataIndex: 'tags', key: 'tags', render: (tags: string) => ( - {parseTags(tags).map((t: string) => {t})} - )}, - { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => }, - { title: '来源', dataIndex: 'source', key: 'source', render: (t: string) => {t} }, + { + title: '客户名称', dataIndex: 'name', key: 'name', + render: (text: string, record: Customer) => ( + openDetail(record)}>{text} + ), + }, + { + title: '联系方式', key: 'contact', + render: (_: unknown, record: Customer) => ( +
+ {record.phone &&
{record.phone}
} + {record.email &&
{record.email}
} + {!record.phone && !record.email && } +
+ ), + }, + { + title: '标签', dataIndex: 'tags', key: 'tags', + render: (tags: string) => ( + + {parseTags(tags).map((t: string) => {t})} + + ), + }, + { + title: '状态', dataIndex: 'status', key: 'status', + render: (s: string) => , + }, + { + title: '来源', dataIndex: 'source', key: 'source', + render: (t: string) => {t || '—'}, + }, { title: '对话次数', dataIndex: 'conversation_count', key: 'conversation_count', align: 'center' as const }, - { title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at', render: (t: string) => {t || '-'} }, + { + title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at', + render: (t: string) => {t ? new Date(t).toLocaleString('zh-CN') : '—'}, + }, + { + title: '操作', key: 'actions', width: 140, + render: (_: unknown, record: Customer) => ( + + + {canDelete && ( + { e?.stopPropagation(); handleDelete(record.id) }}> + + + )} + + ), + }, ] return (

客户管理

- +
- } placeholder="搜索客户名称、手机号、邮箱" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-64" allowClear /> - } + placeholder="搜索客户名称、手机号、邮箱" + value={search} + onChange={e => { setSearch(e.target.value); setPage(1) }} + className="w-64" + allowClear + /> + + + + + + + + + + + + + ({ value: t, label: t }))} placeholder="选择或输入标签" /> + + +
) } diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx index 2273882..06a4ab7 100644 --- a/web/src/pages/agent/Knowledge.tsx +++ b/web/src/pages/agent/Knowledge.tsx @@ -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([]) const [loading, setLoading] = useState(true) const [selectedCategory, setSelectedCategory] = useState('') const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState() const [modalOpen, setModalOpen] = useState(false) + const [categoryModalOpen, setCategoryModalOpen] = useState(false) const [editingEntry, setEditingEntry] = useState(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) => {t} }, - { title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {s === 'published' ? '已发布' : '草稿'} }, - { title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 200, render: (c: number) => ( -
{c}次
- )}, - { title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120, render: (t: string) => {t ? new Date(t).toLocaleDateString() : '-'} }, - { title: '操作', key: 'actions', width: 100, render: () => }, + { + title: '标题', dataIndex: 'title', key: 'title', + render: (t: string) => {t}, + }, + { + title: '状态', dataIndex: 'status', key: 'status', width: 90, + render: (s: string) => {s === 'published' ? '已发布' : '草稿'}, + }, + { + title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 180, + render: (c: number) => ( +
+ + {c}次 +
+ ), + }, + { + title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120, + render: (t: string) => {t ? new Date(t).toLocaleDateString() : '—'}, + }, + { + title: '操作', key: 'actions', width: 140, + render: (_: unknown, record: KnowledgeEntry) => canManage ? ( + + + handleDelete(record.id)}> + + + + ) : 只读, + }, ] return (
-
-
- - 知识分类 +
+
+
+ + 知识分类 +
+ {canManage && ( +
+ 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" />
+
-
-
-

知识库

- } placeholder="搜索..." value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-48" size="small" allowClear /> +
+
+

知识库

+ } + placeholder="搜索标题或内容" + value={search} + onChange={e => { setSearch(e.target.value); setPage(1) }} + className="w-48" + size="small" + allowClear + /> + - - + + + + + + + + setCategoryModalOpen(false)} + onOk={() => categoryForm.submit()} + destroyOnClose + > +
+ + +
diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index 7bc7577..c5dc586 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -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: , label: '网页聊天', status: true, code: 'WK_8a3f2e', script: '' }, - { key: 'wechat', icon: , label: '微信公众号', status: true, code: 'WX_c7b9d1', script: '' }, - { key: 'app', icon: , label: 'APP 内嵌', status: false, code: '', script: '' }, - { key: 'phone', icon: , label: '电话客服', status: false, code: '', script: '' }, - { key: 'email', icon: , label: '邮件工单', status: true, code: 'EM_f2a8c3', script: '' }, -] +const typeMeta: Record = { + web: { icon: , label: '网页聊天' }, + wechat: { icon: , label: '微信公众号' }, + app: { icon: , label: 'APP 内嵌' }, + phone: { icon: , label: '电话客服' }, + email: { icon: , 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([]) + const [loadingChannels, setLoadingChannels] = useState(false) + const [togglingId, setTogglingId] = useState(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 (
- {/* 左侧导航 */}
系统设置 @@ -35,7 +95,11 @@ const Settings = () => { {tabItems.map(item => (
setActiveTab(item.key)} > {item.label} @@ -43,9 +107,7 @@ const Settings = () => { ))}
- {/* 右侧内容 */}
- {/* 基本设置 */} {activeTab === 'basic' && (
@@ -62,164 +124,208 @@ const Settings = () => { ({ value: v, label: v }))} /> -
- ))} + +

工作时段配置将在后续版本接通,当前默认全天可接待。

+ {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => ( +
+ {day} + -
- -
+ ))} )} - {/* 通知设置 */} {activeTab === 'notify' && ( -
- {[ { label: '新会话提醒', desc: '有新访客发起会话时桌面通知' }, - { label: '消息声音', desc: '收到新消息时播放提示音' }, - { label: '离线消息通知', desc: '非工作时间收到的留言邮件通知' }, - { label: '日报推送', desc: '每日客服数据汇总邮件' }, - { label: '周报推送', desc: '每周客服数据汇总邮件' }, - ].map((item, i) => ( -
+
+ {[ + { title: '新会话提醒', desc: '有新访客进线时桌面通知' }, + { title: '离线留言通知', desc: '访客提交离线留言时提醒' }, + { title: '日报推送', desc: '每日服务数据摘要' }, + ].map(item => ( +
-
{item.label}
+
{item.title}
{item.desc}
- +
))} +
通知推送配置将在后续版本接通。
)} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f86fa2f..85acafd 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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(`/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('/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(`/customers?${search}`) } +export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`) +export const createCustomer = (data: Partial) => post('/customers', data) +export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data) +export const deleteCustomer = (id: number) => del(`/customers/${id}`) // Knowledge -export const getKnowledgeCategories = () => get('/knowledge/categories') -export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; page?: number }) => { +export const getKnowledgeCategories = () => get('/knowledge/categories') +export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post('/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(`/knowledge/entries?${search}`) } +export const createKnowledgeEntry = (data: { title: string; content: string; category_id: number; status?: string }) => + post('/knowledge/entries', data) +export const updateKnowledgeEntry = (id: number, data: Partial) => + put(`/knowledge/entries/${id}`, data) +export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`) + +// Channels +export const getChannels = () => get('/channels') +export const createChannel = (data: { type: string; name?: string }) => post('/channels', data) +export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data) // Statistics export const getKPIs = () => get('/statistics/kpi')