feat(chat): 新增后台客服会话工作台
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Phase 2 验收报告(客服会话工作台)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:后台客服会话列表、消息查看、回复、转接、备注、全部已读
|
||||
> 结论:**页面与路由已落地,tsc/build 通过,只读接口回归 200;发送与转接未在验收中执行**
|
||||
|
||||
---
|
||||
|
||||
## 1. 页面与路由
|
||||
|
||||
- 页面:`frontend/src/features/admin/AdminChatsPage.tsx`
|
||||
- 路由:`/console-jy8k3m/chats`
|
||||
- 权限:查看 `chat:view`,发送/转接/备注 `chat:send`
|
||||
- 样式:`frontend/src/styles/admin-chat.css`(已接入 `styles/global.css`)
|
||||
|
||||
## 2. 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/chats` | 会话列表(`filter` / `stage` / `keyword` / 分页) |
|
||||
| GET | `/api/admin/chats/counts` | 归属与阶段统计 |
|
||||
| GET | `/api/admin/chats/:id` | 会话详情 |
|
||||
| GET | `/api/admin/chats/:id/messages` | 消息列表 |
|
||||
| POST | `/api/admin/chats/:id/messages` | 发送消息 |
|
||||
| POST | `/api/admin/chats/:id/read` | 标记已读 |
|
||||
| POST | `/api/admin/chats/read-all` | 全部已读 |
|
||||
| GET | `/api/admin/chats/support-admins` | 可转接客服 |
|
||||
| POST | `/api/admin/chats/:id/transfer` | 转接会话 |
|
||||
| POST | `/api/admin/chats/:id/remark` | 保存后台备注 |
|
||||
|
||||
## 3. 能力
|
||||
|
||||
- 左侧会话列表:`filter`(全部 / 我负责的)、`stage`(全部 / 待处理 / 未进群 / 交接中 / 租用中 / 售后 / 已结束)、关键字搜索;未读 Badge、待回复标签、订单号标签、最近消息与相对时间,15 秒轮询。
|
||||
- 右侧会话面板:会话元信息(订单号、订单状态、客服场景)、消息气泡(本人消息右对齐、图片消息内联预览)、8 秒轮询,切换会话自动滚动到底部。
|
||||
- 回复区:快捷回复下拉插入(复用 `GET /admin/chats/quick-replies`)、文本输入(2000 字上限、Enter 发送 / Shift+Enter 换行)。
|
||||
- 会话操作:转接客服(下拉选择 + 二次点击确认)、后台备注保存、全部已读。
|
||||
- 选中未读会话时自动调用已读接口并刷新列表与详情。
|
||||
|
||||
## 4. 回归结果
|
||||
|
||||
```text
|
||||
前端 http://localhost:5289
|
||||
后端 http://127.0.0.1:18089
|
||||
Cookie /tmp/jysh-admin-cookie2.txt
|
||||
```
|
||||
|
||||
| 检查项 | 状态 |
|
||||
|---|---|
|
||||
| `GET /console-jy8k3m/chats` | 200 |
|
||||
| `GET /api/admin/chats?page=1&page_size=5` | 200 |
|
||||
| `GET /api/admin/chats/counts` | 200 |
|
||||
| `GET /api/admin/chats/support-admins` | 200 |
|
||||
|
||||
`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `498.83 kB` / gzip `157.03 kB`,无超大包告警。
|
||||
|
||||
## 5. 未覆盖
|
||||
|
||||
- 发送消息、转接、备注保存、已读均未执行真实写操作(开发库无真实会话)。
|
||||
- 实时推送依赖 SSE(`/admin/chats/events`),本页采用轮询兜底,未接入 SSE。
|
||||
- 自动欢迎语与商品群欢迎配置(`/admin/chats/auto-welcome`、`/listing-group-welcome`)尚未做页面。
|
||||
+1
-1
@@ -414,7 +414,7 @@ hfb_jysh/
|
||||
- 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。
|
||||
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
|
||||
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`),线下提号(`/pickup`、`/pickup/:id`,权限 `order:pickup`,口径统一为「线下提号」)。
|
||||
- 待落地:客服会话工作台 `/chats`、主站二维码池 `/qrcode-pool`。
|
||||
- 待落地:主站二维码池 `/qrcode-pool`、自动欢迎与商品群欢迎配置。
|
||||
- 分销站点、规则、订单、结算、二维码池、分销用户、公告。
|
||||
- 公告、邀请返利、推送、追踪链接。
|
||||
- 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。
|
||||
|
||||
@@ -241,6 +241,11 @@ const AdminPickupsPage = lazy(() =>
|
||||
default: module.AdminPickupsPage,
|
||||
}))
|
||||
)
|
||||
const AdminChatsPage = lazy(() =>
|
||||
import('@/features/admin/AdminChatsPage').then(module => ({
|
||||
default: module.AdminChatsPage,
|
||||
}))
|
||||
)
|
||||
const AdminPickupDetailPage = lazy(() =>
|
||||
import('@/features/admin/AdminPickupDetailPage').then(module => ({
|
||||
default: module.AdminPickupDetailPage,
|
||||
@@ -408,6 +413,7 @@ export function AppRouter() {
|
||||
<Route path="payments" element={<AdminPaymentsPage />} />
|
||||
<Route path="pickup" element={<AdminPickupsPage />} />
|
||||
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
|
||||
<Route path="chats" element={<AdminChatsPage />} />
|
||||
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
|
||||
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
|
||||
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { ReloadOutlined, SendOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
fetchAdminChatDetail,
|
||||
fetchAdminChatMessages,
|
||||
fetchAdminChats,
|
||||
fetchAdminChatSupportAdmins,
|
||||
fetchAdminQuickReplies,
|
||||
markAdminChatRead,
|
||||
markAllAdminChatsRead,
|
||||
sendAdminChatMessage,
|
||||
transferAdminChat,
|
||||
updateAdminChatRemark,
|
||||
} from '@/features/admin/api'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import type { AdminChatConversation } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatRelativeTime, formatText } from '@/shared/utils/format'
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: '全部会话' },
|
||||
{ value: 'mine', label: '我负责的' },
|
||||
]
|
||||
|
||||
const stageOptions = [
|
||||
{ value: 'all', label: '全部阶段' },
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'unjoined', label: '未进群' },
|
||||
{ value: 'handoff', label: '交接中' },
|
||||
{ value: 'renting', label: '租用中' },
|
||||
{ value: 'after_sale', label: '售后' },
|
||||
{ value: 'ended', label: '已结束' },
|
||||
]
|
||||
|
||||
function errorText(error: unknown) {
|
||||
const response = (error as { response?: { data?: { message?: string } } })?.response
|
||||
if (response?.data?.message) return response.data.message
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试'
|
||||
}
|
||||
|
||||
export function AdminChatsPage() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [stage, setStage] = useState('all')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [appliedKeyword, setAppliedKeyword] = useState('')
|
||||
const [selectedId, setSelectedId] = useState<number>()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [remarkDraft, setRemarkDraft] = useState('')
|
||||
const [remarkSaving, setRemarkSaving] = useState(false)
|
||||
const [transferTarget, setTransferTarget] = useState<number>()
|
||||
const [transferring, setTransferring] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('chat:view'))
|
||||
const canSend = Boolean(permissions?.includes('*') || permissions?.includes('chat:send'))
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-chats', filter, stage, appliedKeyword],
|
||||
queryFn: () =>
|
||||
fetchAdminChats({
|
||||
filter,
|
||||
stage,
|
||||
keyword: appliedKeyword || undefined,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
}),
|
||||
enabled: canView,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const conversations = listQuery.data?.items ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId || conversations.length === 0) return
|
||||
setSelectedId(conversations[0].id)
|
||||
}, [conversations, selectedId])
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['admin-chat', selectedId],
|
||||
queryFn: () => fetchAdminChatDetail(selectedId as number),
|
||||
enabled: canView && Boolean(selectedId),
|
||||
})
|
||||
|
||||
const messagesQuery = useQuery({
|
||||
queryKey: ['admin-chat-messages', selectedId],
|
||||
queryFn: () => fetchAdminChatMessages(selectedId as number, 1, 100),
|
||||
enabled: canView && Boolean(selectedId),
|
||||
refetchInterval: 8_000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const quickRepliesQuery = useQuery({
|
||||
queryKey: ['admin-quick-replies'],
|
||||
queryFn: fetchAdminQuickReplies,
|
||||
enabled: canSend,
|
||||
})
|
||||
|
||||
const supportAdminsQuery = useQuery({
|
||||
queryKey: ['admin-support-admins'],
|
||||
queryFn: fetchAdminChatSupportAdmins,
|
||||
enabled: canSend,
|
||||
})
|
||||
|
||||
const messages = messagesQuery.data?.items ?? []
|
||||
const conversation = detailQuery.data
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) return
|
||||
setRemarkDraft(conversation?.admin_remark ?? '')
|
||||
}, [conversation?.admin_remark, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages.length, selectedId])
|
||||
|
||||
const selectConversation = async (item: AdminChatConversation) => {
|
||||
setSelectedId(item.id)
|
||||
setConstraintReset()
|
||||
if (item.unread_count > 0) {
|
||||
try {
|
||||
await markAdminChatRead(item.id)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chat', item.id] }),
|
||||
])
|
||||
} catch {
|
||||
// 已读失败不阻塞会话浏览
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setConstraintReset = () => {
|
||||
setDraft('')
|
||||
setTransferTarget(undefined)
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!selectedId || !draft.trim()) return
|
||||
setSending(true)
|
||||
try {
|
||||
await sendAdminChatMessage(selectedId, { content: draft.trim() })
|
||||
setDraft('')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chat-messages', selectedId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveRemark = async () => {
|
||||
if (!selectedId) return
|
||||
setRemarkSaving(true)
|
||||
try {
|
||||
await updateAdminChatRemark(selectedId, remarkDraft.trim())
|
||||
message.success('备注已保存')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-chat', selectedId] })
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setRemarkSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const transfer = async () => {
|
||||
if (!selectedId || !transferTarget) return
|
||||
setTransferring(true)
|
||||
try {
|
||||
await transferAdminChat(selectedId, transferTarget)
|
||||
message.success('会话已转接')
|
||||
setTransferTarget(undefined)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chats'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-chat', selectedId] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
} finally {
|
||||
setTransferring(false)
|
||||
}
|
||||
}
|
||||
|
||||
const quickReplyOptions = useMemo(
|
||||
() =>
|
||||
(quickRepliesQuery.data ?? []).map(item => ({
|
||||
value: item.content,
|
||||
label: item.title,
|
||||
})),
|
||||
[quickRepliesQuery.data]
|
||||
)
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader eyebrow="JIEYOU OPS · 客服" title="客服会话" />
|
||||
<Alert type="warning" showIcon message="当前账号没有聊天查看权限(chat:view)" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page admin-chat-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 客服"
|
||||
title="客服会话"
|
||||
description="按阶段筛选会话、查看用户消息并直接回复,支持转接与备注。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {listQuery.data?.total ?? 0} 个会话</Tag>
|
||||
<Tag color="gold">
|
||||
未读 {conversations.reduce((sum, item) => sum + item.unread_count, 0)}
|
||||
</Tag>
|
||||
<Tag color="volcano">待回复 {conversations.filter(item => item.needs_reply).length}</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={listQuery.isFetching}
|
||||
onClick={() => void listQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await markAllAdminChatsRead()
|
||||
message.success('已全部标记为已读')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-chats'] })
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
全部已读
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="admin-chat-toolbar">
|
||||
<Select
|
||||
value={filter}
|
||||
options={filterOptions}
|
||||
style={{ width: 140 }}
|
||||
onChange={value => setFilter(value)}
|
||||
/>
|
||||
<Select
|
||||
value={stage}
|
||||
options={stageOptions}
|
||||
style={{ width: 150 }}
|
||||
onChange={value => setStage(value)}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keyword}
|
||||
placeholder="搜索会话标题 / 订单号"
|
||||
style={{ width: 280 }}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
onSearch={value => setAppliedKeyword(value.trim())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{listQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="会话列表加载失败"
|
||||
description={errorText(listQuery.error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="admin-chat-layout">
|
||||
<Card className="admin-chat-list" title="会话列表" styles={{ body: { padding: 0 } }}>
|
||||
{listQuery.isFetching && conversations.length === 0 ? (
|
||||
<div className="admin-chat-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<Empty description="没有符合条件的会话" style={{ padding: 32 }} />
|
||||
) : (
|
||||
<div className="admin-chat-list__items">
|
||||
{conversations.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`admin-chat-item${item.id === selectedId ? ' is-active' : ''}`}
|
||||
onClick={() => void selectConversation(item)}
|
||||
>
|
||||
<div className="admin-chat-item__top">
|
||||
<strong>{formatText(item.title)}</strong>
|
||||
<span className="admin-chat-item__time">
|
||||
{formatRelativeTime(item.last_message_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-chat-item__mid">
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{formatText(item.last_message_preview)}
|
||||
</Typography.Text>
|
||||
{item.unread_count > 0 ? <Badge count={item.unread_count} /> : null}
|
||||
</div>
|
||||
<Space size={4} wrap>
|
||||
<Tag bordered={false}>{item.type}</Tag>
|
||||
{item.latest_order_no ? (
|
||||
<Tag bordered={false} color="blue">
|
||||
{item.latest_order_no}
|
||||
</Tag>
|
||||
) : null}
|
||||
{item.needs_reply ? (
|
||||
<Tag bordered={false} color="volcano">
|
||||
待回复
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="admin-chat-panel" title={conversation ? conversation.title : '会话详情'}>
|
||||
{!selectedId ? (
|
||||
<Empty description="请选择左侧会话" />
|
||||
) : (
|
||||
<div className="admin-chat-panel__body">
|
||||
<div className="admin-chat-meta">
|
||||
<Space size={6} wrap>
|
||||
{conversation?.latest_order_no ? (
|
||||
<Tag bordered={false} color="blue">
|
||||
订单 {conversation.latest_order_no}
|
||||
</Tag>
|
||||
) : null}
|
||||
{conversation?.latest_order_status ? (
|
||||
<Tag bordered={false}>{conversation.latest_order_status}</Tag>
|
||||
) : null}
|
||||
{conversation?.support_scene ? (
|
||||
<Tag bordered={false}>{conversation.support_scene}</Tag>
|
||||
) : null}
|
||||
<span className="admin-chat-meta__time">
|
||||
{formatDateTime(conversation?.updated_at)}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="admin-chat-messages">
|
||||
{messagesQuery.isFetching && messages.length === 0 ? (
|
||||
<div className="admin-chat-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<Empty description="还没有消息" />
|
||||
) : (
|
||||
messages.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`admin-chat-bubble${item.sender_type === 'admin' ? ' is-admin' : ''}`}
|
||||
>
|
||||
<div className="admin-chat-bubble__head">
|
||||
<span>{formatText(item.sender_name || item.sender_role)}</span>
|
||||
<span className="admin-chat-bubble__time">
|
||||
{formatDateTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-chat-bubble__content">
|
||||
{item.content_type === 'image' && item.attachment_urls?.length ? (
|
||||
<Space size={6} wrap>
|
||||
{item.attachment_urls.map(url => (
|
||||
<a key={url} href={url} target="_blank" rel="noreferrer">
|
||||
<img src={url} alt="会话图片" className="admin-chat-image" />
|
||||
</a>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
formatText(item.content)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="admin-chat-composer">
|
||||
{quickReplyOptions.length ? (
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="插入快捷回复"
|
||||
style={{ width: '100%' }}
|
||||
options={quickReplyOptions}
|
||||
value={undefined}
|
||||
onChange={value => {
|
||||
if (value) setDraft(current => (current ? `${current}\n${value}` : value))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={draft}
|
||||
maxLength={2000}
|
||||
showCount
|
||||
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
|
||||
disabled={!canSend}
|
||||
onChange={event => setDraft(event.target.value)}
|
||||
onPressEnter={event => {
|
||||
if (event.shiftKey) return
|
||||
event.preventDefault()
|
||||
void send()
|
||||
}}
|
||||
/>
|
||||
<div className="admin-chat-actions">
|
||||
<Space size={8} wrap>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="转接客服"
|
||||
style={{ width: 200 }}
|
||||
value={transferTarget}
|
||||
loading={supportAdminsQuery.isFetching}
|
||||
options={(supportAdminsQuery.data ?? []).map(admin => ({
|
||||
value: admin.id,
|
||||
label: `${admin.nickname || admin.username}(${admin.support_status})`,
|
||||
}))}
|
||||
onChange={value => setTransferTarget(value)}
|
||||
/>
|
||||
<Button
|
||||
icon={<SwapOutlined />}
|
||||
disabled={!canSend || !transferTarget}
|
||||
loading={transferring}
|
||||
onClick={() => void transfer()}
|
||||
>
|
||||
转接
|
||||
</Button>
|
||||
<Input
|
||||
value={remarkDraft}
|
||||
placeholder="会话备注(仅后台可见)"
|
||||
style={{ width: 240 }}
|
||||
onChange={event => setRemarkDraft(event.target.value)}
|
||||
onPressEnter={() => void saveRemark()}
|
||||
/>
|
||||
<Button loading={remarkSaving} onClick={() => void saveRemark()}>
|
||||
保存备注
|
||||
</Button>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sending}
|
||||
disabled={!canSend || !draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -110,6 +110,11 @@ import type {
|
||||
AdminPickupProcessEvent,
|
||||
AdminPickupQuery,
|
||||
AdminPickupShop,
|
||||
AdminChatConversation,
|
||||
AdminChatCounts,
|
||||
AdminChatMessage,
|
||||
AdminChatMessagePage,
|
||||
AdminChatPage,
|
||||
ManualDisbursementPayload,
|
||||
OperatingExpensePage,
|
||||
OperatingExpensePayload,
|
||||
@@ -1554,4 +1559,86 @@ export async function settleAdminPickupFinancialAdjustment(
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChats(query: {
|
||||
filter?: string
|
||||
stage?: string
|
||||
keyword?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminChatPage>>>('/admin/chats', {
|
||||
params: compactParams(query),
|
||||
})
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminChatCounts(query: {
|
||||
filter?: string
|
||||
stage?: string
|
||||
keyword?: string
|
||||
}) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminChatCounts>>('/admin/chats/counts', {
|
||||
params: compactParams(query),
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChatDetail(id: number) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminChatConversation>>(`/admin/chats/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 50) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminChatMessagePage>>>(
|
||||
`/admin/chats/${id}/messages`,
|
||||
{ params: { page, page_size: pageSize } }
|
||||
)
|
||||
return {
|
||||
items: (data.data.items ?? []) as AdminChatMessage[],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? page,
|
||||
page_size: data.data.page_size ?? pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendAdminChatMessage(
|
||||
id: number,
|
||||
payload: { content: string; attachment_urls?: string[] }
|
||||
) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminChatMessage>>(
|
||||
`/admin/chats/${id}/messages`,
|
||||
payload
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function markAdminChatRead(id: number) {
|
||||
await adminClient.post(`/admin/chats/${id}/read`)
|
||||
}
|
||||
|
||||
export async function markAllAdminChatsRead() {
|
||||
await adminClient.post('/admin/chats/read-all')
|
||||
}
|
||||
|
||||
export async function transferAdminChat(id: number, toAdminId: number) {
|
||||
await adminClient.post(`/admin/chats/${id}/transfer`, { to_admin_id: toAdminId })
|
||||
}
|
||||
|
||||
export async function updateAdminChatRemark(id: number, remark: string) {
|
||||
await adminClient.post(`/admin/chats/${id}/remark`, { remark })
|
||||
}
|
||||
|
||||
export async function fetchAdminChatSupportAdmins() {
|
||||
const { data } = await adminClient.get<
|
||||
ApiResponse<Array<{ id: number; username?: string; nickname: string; support_status: string }>>
|
||||
>('/admin/chats/support-admins')
|
||||
return data.data ?? []
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -749,6 +749,84 @@ export interface AdminPickupFinancialAdjustment {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminChatParticipant {
|
||||
id: number
|
||||
conversation_id: number
|
||||
participant_type: string
|
||||
participant_id: number
|
||||
role: string
|
||||
remark: string
|
||||
display_name: string
|
||||
avatar_url: string
|
||||
last_read_at?: string | null
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export interface AdminChatConversation {
|
||||
id: number
|
||||
order_id?: number | null
|
||||
listing_id?: number | null
|
||||
distribution_site_id?: number | null
|
||||
latest_order_id?: number | null
|
||||
latest_order_no?: string
|
||||
latest_order_status?: string
|
||||
latest_handoff_status?: string
|
||||
latest_refund_status?: string
|
||||
type: string
|
||||
support_scene?: string
|
||||
title: string
|
||||
status: string
|
||||
role: string
|
||||
admin_remark?: string
|
||||
participants?: AdminChatParticipant[]
|
||||
last_message_id?: number | null
|
||||
last_message_preview: string
|
||||
last_message_at?: string | null
|
||||
last_sender_type?: string
|
||||
last_sender_id?: number
|
||||
last_sender_role?: string
|
||||
unread_count: number
|
||||
needs_reply: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminChatPage {
|
||||
items: AdminChatConversation[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminChatMessage {
|
||||
id: number
|
||||
conversation_id: number
|
||||
sender_type: string
|
||||
sender_id: number
|
||||
sender_role: string
|
||||
sender_name: string
|
||||
sender_avatar: string
|
||||
is_self: boolean
|
||||
is_read: boolean
|
||||
content_type: string
|
||||
content: string
|
||||
attachment_urls?: string[]
|
||||
admin_attention_type?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminChatMessagePage {
|
||||
items: AdminChatMessage[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminChatCounts {
|
||||
ownership: Record<string, number>
|
||||
stages: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AdminAnnouncementQuery {
|
||||
category?: string
|
||||
status?: string
|
||||
|
||||
@@ -142,6 +142,7 @@ const allNavGroups: NavGroup[] = [
|
||||
icon: <CustomerServiceOutlined />,
|
||||
children: [
|
||||
{ key: '/chat-support-groups', label: '客服群组', permission: 'chat:manage' },
|
||||
{ key: '/chats', label: '客服会话', permission: 'chat:view' },
|
||||
{ key: '/manual-entries', label: '手工录单', permission: 'cs_manual_entry:view' },
|
||||
{ key: '/chat-quick-replies', label: '快捷回复', permission: 'chat:view' },
|
||||
],
|
||||
@@ -207,6 +208,10 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
|
||||
title: '客服群组',
|
||||
description: '维护客服群与坐席分配,支撑会话分流。',
|
||||
},
|
||||
'/chats': {
|
||||
title: '客服会话',
|
||||
description: '按阶段筛选会话、回复用户并转接客服。',
|
||||
},
|
||||
'/system-configs': { title: '系统配置', description: '维护首页、发布、订单与支付配置。' },
|
||||
'/payment-configs': { title: '支付配置', description: '维护主站支付商户、渠道与回调参数。' },
|
||||
'/audit-logs': { title: '审计日志', description: '查看高风险操作与变更记录。' },
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* 后台客服会话工作台 */
|
||||
.admin-chat-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-chat-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.admin-chat-list,
|
||||
.admin-chat-panel {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-chat-list {
|
||||
max-height: 720px;
|
||||
}
|
||||
|
||||
.admin-chat-list__items {
|
||||
max-height: 660px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-chat-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.admin-chat-item:hover {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.admin-chat-item.is-active {
|
||||
background: #f2ece0;
|
||||
box-shadow: inset 3px 0 0 var(--brand-ink);
|
||||
}
|
||||
|
||||
.admin-chat-item__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-chat-item__top strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-chat-item__time {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.admin-chat-item__mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-chat-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.admin-chat-panel__body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.admin-chat-meta {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-chat-meta__time {
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.admin-chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.admin-chat-bubble {
|
||||
align-self: flex-start;
|
||||
max-width: 78%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.admin-chat-bubble.is-admin {
|
||||
align-self: flex-end;
|
||||
border-color: #d9e6e1;
|
||||
background: #eaf2ee;
|
||||
}
|
||||
|
||||
.admin-chat-bubble__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.admin-chat-bubble__content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.admin-chat-image {
|
||||
max-width: 200px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-chat-composer {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.admin-chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-chat-list {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.admin-chat-list__items {
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
@import './admin-login.css';
|
||||
@import './admin-shell.css';
|
||||
@import './admin-distribution.css';
|
||||
@import './admin-chat.css';
|
||||
@import './mobile-base.css';
|
||||
@import './mobile-listing.css';
|
||||
@import './mobile-profile-orders.css';
|
||||
|
||||
Reference in New Issue
Block a user