feat(chat): 新增后台客服会话工作台
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user