feat(messages): 新增 PC 消息中心与订单群聊入口

This commit is contained in:
yml2213
2026-10-30 14:08:16 +08:00
parent 4fc4e9236b
commit f2552fe523
13 changed files with 1258 additions and 11 deletions
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useRef, useState } from 'react'
import {
Alert,
App,
Avatar,
Button,
Card,
Image,
Input,
Skeleton,
Space,
Tag,
Typography,
Upload,
} from 'antd'
import {
ArrowLeftOutlined,
CloseOutlined,
CustomerServiceOutlined,
PictureOutlined,
SendOutlined,
UserOutlined,
} from '@ant-design/icons'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import {
ensureSupportConversation,
markConversationRead,
sendChatMessage,
useChatMessages,
useConversation,
} from '@/features/messages/api'
import type { ChatMessage } from '@/features/messages/types'
import { useSessionStore } from '@/stores/session'
import { uploadFile } from '@/shared/api/files'
interface PendingImage {
url: string
name: string
}
function dateLabel(value: string) {
const target = dayjs(value)
const now = dayjs()
if (target.isSame(now, 'day')) return '今天'
if (target.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
return target.format('MM月DD日')
}
function MessageBubble({ message }: { message: ChatMessage }) {
if (message.sender_type === 'system' || message.content_type === 'system') {
return <div className="pc-chat-system">{message.content}</div>
}
return (
<div className={`pc-chat-message${message.is_self ? ' pc-chat-message--self' : ''}`}>
{!message.is_self ? (
<Avatar
size={38}
className="pc-chat-message__avatar"
icon={<UserOutlined />}
style={{ background: message.sender_role === 'admin' ? '#b08a4a' : '#1e4d42' }}
/>
) : null}
<div className="pc-chat-message__body">
<div className="pc-chat-message__meta">
{!message.is_self ? <strong>{message.sender_name}</strong> : null}
<span>{dayjs(message.created_at).format('HH:mm')}</span>
{message.sender_role === 'admin' ? <Tag color="gold">客服</Tag> : null}
</div>
<div className="pc-chat-message__bubble">
{message.attachment_urls?.length ? (
<Image.PreviewGroup>
<div className="pc-chat-message__images">
{message.attachment_urls.map(url => (
<Image src={url} alt="聊天图片" key={url} width={168} />
))}
</div>
</Image.PreviewGroup>
) : null}
{message.content ? <p>{message.content}</p> : null}
</div>
</div>
</div>
)
}
export function ChatPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { message } = App.useApp()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const conversation = useConversation(id, Boolean(token))
const messagesQuery = useChatMessages(id, Boolean(token))
const [content, setContent] = useState('')
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
const [uploading, setUploading] = useState(false)
const [sending, setSending] = useState(false)
const [openingSupport, setOpeningSupport] = useState(false)
const uploadingRef = useRef(0)
const scrollRef = useRef<HTMLDivElement>(null)
const lastReadRef = useRef<number | null>(null)
const lastRenderedIdRef = useRef<number | null>(null)
const pages = messagesQuery.data?.pages ?? []
const messages = pages
.slice()
.reverse()
.flatMap(page => page.items)
const lastMessage = messages[messages.length - 1]
useEffect(() => {
const element = scrollRef.current
const lastId = lastMessage?.id ?? null
if (!element || !lastId || lastRenderedIdRef.current === lastId) return
lastRenderedIdRef.current = lastId
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' })
}, [lastMessage])
useEffect(() => {
if (!id || !lastMessage || lastReadRef.current === lastMessage.id) return
lastReadRef.current = lastMessage.id
markConversationRead(id)
.then(() => {
void queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
void queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] })
})
.catch(() => undefined)
}, [id, lastMessage, queryClient])
const uploadOne = async (file: File) => {
if (pendingImages.length >= 9) {
message.warning('最多发送 9 张图片')
return
}
if (!file.type.startsWith('image/')) {
message.warning('聊天附件仅支持图片')
return
}
if (file.size > 10 * 1024 * 1024) {
message.warning('单张图片不能超过 10MB')
return
}
uploadingRef.current += 1
setUploading(true)
try {
const uploaded = await uploadFile(file, 'chat')
setPendingImages(prev => {
if (prev.length >= 9) return prev
return [...prev, { url: uploaded.url, name: uploaded.filename }]
})
} catch (error) {
message.error(error instanceof Error ? error.message : '图片上传失败')
} finally {
uploadingRef.current -= 1
if (uploadingRef.current === 0) setUploading(false)
}
}
const openSupport = async () => {
if (openingSupport) return
setOpeningSupport(true)
try {
const support = await ensureSupportConversation()
navigate(`/messages/${support.id}`)
} catch {
// 全局响应拦截已提示,保留按钮可重试。
} finally {
setOpeningSupport(false)
}
}
const onSend = async () => {
const text = content.trim()
if (!id || (!text && pendingImages.length === 0) || sending || uploading) return
setSending(true)
try {
await sendChatMessage(
id,
text,
pendingImages.map(image => image.url)
)
setContent('')
setPendingImages([])
await messagesQuery.refetch()
await queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
await queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] })
} finally {
setSending(false)
}
}
if (!hydrated) {
return (
<div className="pc-chat-page">
<Card loading />
</div>
)
}
if (!token) {
return (
<div className="pc-chat-page">
<Card className="pc-messages-login">
<BrandLogo height={48} variant="seal" />
<Typography.Title level={3}>登录后进入会话</Typography.Title>
<Typography.Paragraph type="secondary">
订单群聊与客服会话只对当前账号开放。
</Typography.Paragraph>
<Button type="primary" onClick={() => navigate(`/login?redirect=/messages/${id}`)}>
去登录
</Button>
</Card>
</div>
)
}
const title = conversation.data?.title || '交易会话'
const subtitle =
conversation.data?.type === 'general_support'
? '平台客服'
: conversation.data?.type === 'order_group'
? `订单 ${conversation.data.latest_order_no || ''}`.trim()
: conversation.data?.type === 'listing_group'
? '商品咨询群'
: '交易会话'
let previousDate = ''
return (
<div className="pc-chat-page">
<div className="pc-chat-page__crumb">
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/messages')}>
返回消息中心
</Button>
<span>
<Link to="/messages">消息</Link> / {title}
</span>
</div>
<Card className="pc-chat-card">
<div className="pc-chat-card__head">
<div>
<Typography.Title level={3}>{title}</Typography.Title>
<Space size={6} wrap>
<Tag color={conversation.data?.type === 'general_support' ? 'gold' : 'green'}>
{subtitle}
</Tag>
{conversation.data?.latest_order_no && conversation.data?.type !== 'order_group' ? (
<Tag>订单 {conversation.data.latest_order_no}</Tag>
) : null}
</Space>
</div>
{conversation.data?.type !== 'general_support' ? (
<Button
icon={<CustomerServiceOutlined />}
loading={openingSupport}
onClick={() => void openSupport()}
>
需要客服介入
</Button>
) : null}
</div>
<div className="pc-chat-body" ref={scrollRef}>
{messagesQuery.hasNextPage ? (
<div className="pc-chat-earlier">
<Button
size="small"
loading={messagesQuery.isFetchingNextPage}
onClick={() => void messagesQuery.fetchNextPage()}
>
查看更早消息
</Button>
</div>
) : null}
{messagesQuery.isPending ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : messagesQuery.isError ? (
<Alert
type="error"
showIcon
message="会话没有加载出来"
action={
<Button size="small" onClick={() => void messagesQuery.refetch()}>
重试
</Button>
}
/>
) : messages.length === 0 ? (
<div className="pc-chat-empty">还没有消息,先打个招呼吧。</div>
) : (
messages.map(message => {
const label = dateLabel(message.created_at)
const showDate = label !== previousDate
previousDate = label
return (
<div key={message.id}>
{showDate ? <div className="pc-chat-date">{label}</div> : null}
<MessageBubble message={message} />
</div>
)
})
)}
</div>
{pendingImages.length > 0 ? (
<div className="pc-chat-pending">
{pendingImages.map(image => (
<div className="pc-chat-pending__item" key={image.url}>
<img src={image.url} alt={image.name} />
<button
type="button"
aria-label="移除图片"
onClick={() =>
setPendingImages(prev => prev.filter(item => item.url !== image.url))
}
>
<CloseOutlined />
</button>
</div>
))}
<span>{pendingImages.length}/9</span>
</div>
) : null}
<div className="pc-chat-composer">
<Upload
accept="image/jpeg,image/png,image/webp"
multiple
showUploadList={false}
beforeUpload={file => {
void uploadOne(file)
return false
}}
>
<Button icon={<PictureOutlined />} loading={uploading} disabled={pendingImages.length >= 9}>
图片
</Button>
</Upload>
<Input.TextArea
value={content}
autoSize={{ minRows: 2, maxRows: 5 }}
maxLength={1000}
placeholder="输入消息,Shift + Enter 换行"
onChange={event => setContent(event.target.value)}
onPressEnter={event => {
if (!event.shiftKey) {
event.preventDefault()
void onSend()
}
}}
/>
<Button
type="primary"
icon={<SendOutlined />}
loading={sending}
disabled={(!content.trim() && pendingImages.length === 0) || uploading}
onClick={() => void onSend()}
>
发送
</Button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,200 @@
import { useState } from 'react'
import {
Alert,
Avatar,
Badge,
Button,
Card,
Empty,
Skeleton,
Space,
Tag,
Typography,
} from 'antd'
import { ArrowRightOutlined, CustomerServiceOutlined, MessageOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { ensureSupportConversation, useConversations } from '@/features/messages/api'
import type { ChatConversation } from '@/features/messages/types'
import { useSessionStore } from '@/stores/session'
import { formatRelativeTime } from '@/shared/utils/format'
function conversationMeta(type: string) {
if (type === 'general_support') return { label: '平台客服', color: 'gold' }
if (type === 'order_group') return { label: '订单群聊', color: 'green' }
if (type === 'listing_group') return { label: '商品咨询', color: 'blue' }
return { label: '交易会话', color: 'default' }
}
function ConversationRow({ item }: { item: ChatConversation }) {
const meta = conversationMeta(item.type)
return (
<Link to={`/messages/${item.id}`} className="pc-chat-row">
<Avatar
size={46}
className="pc-chat-row__avatar"
icon={<MessageOutlined />}
style={{ background: item.type === 'general_support' ? '#b08a4a' : '#1e4d42' }}
/>
<div className="pc-chat-row__body">
<div className="pc-chat-row__top">
<strong>{item.title || '未命名会话'}</strong>
<span>{formatRelativeTime(item.last_message_at)}</span>
</div>
<div className="pc-chat-row__meta">
<Tag color={meta.color} bordered={false}>
{meta.label}
</Tag>
{item.latest_order_no ? <span>订单 {item.latest_order_no}</span> : null}
</div>
<p>{item.last_message_preview || '还没有消息,进入会话开始沟通。'}</p>
</div>
<div className="pc-chat-row__tail">
{item.unread_count > 0 ? <Badge count={item.unread_count} /> : null}
<ArrowRightOutlined />
</div>
</Link>
)
}
export function MessagesPage() {
const navigate = useNavigate()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const [openingSupport, setOpeningSupport] = useState(false)
const chats = useConversations(Boolean(token))
const items = chats.data?.pages.flatMap(page => page.items) ?? []
const unreadTotal = items.reduce((sum, item) => sum + item.unread_count, 0)
const openSupport = async () => {
setOpeningSupport(true)
try {
const conversation = await ensureSupportConversation()
navigate(`/messages/${conversation.id}`)
} catch {
// 全局响应拦截已提示,这里保留按钮可重试状态。
} finally {
setOpeningSupport(false)
}
}
if (!hydrated) {
return (
<div className="pc-messages-page">
<Card loading />
</div>
)
}
if (!token) {
return (
<div className="pc-messages-page">
<Card className="pc-messages-login">
<BrandLogo height={48} variant="seal" />
<Typography.Title level={3}>登录后查看消息</Typography.Title>
<Typography.Paragraph type="secondary">
订单群聊、客服回复与系统通知只对当前账号开放。
</Typography.Paragraph>
<Button
type="primary"
onClick={() => navigate('/login?redirect=/messages')}
>
去登录
</Button>
</Card>
</div>
)
}
return (
<div className="pc-messages-page">
<header className="pc-messages-hero">
<div>
<span className="pc-messages-hero__eyebrow">MESSAGES</span>
<Typography.Title level={2} className="brand-title">
消息中心
</Typography.Title>
<Typography.Paragraph type="secondary">
订单群聊会随下单自动建立,交接、结账与申诉都建议在群内留痕。
</Typography.Paragraph>
</div>
<Space wrap>
{unreadTotal > 0 ? <Tag color="red">{unreadTotal} 条未读</Tag> : <Tag>全部已读</Tag>}
<Button
type="primary"
icon={<CustomerServiceOutlined />}
loading={openingSupport}
onClick={() => void openSupport()}
>
联系平台客服
</Button>
</Space>
</header>
<div className="pc-messages-grid">
<main>
<Card
title="全部会话"
extra={items.length ? <Typography.Text type="secondary">{items.length} 个</Typography.Text> : null}
className="pc-messages-card"
>
{chats.isPending ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : chats.isError ? (
<Alert
type="error"
showIcon
message="会话没有加载出来"
action={
<Button size="small" onClick={() => void chats.refetch()}>
重试
</Button>
}
/>
) : items.length === 0 ? (
<Empty description="还没有会话">
<Button type="primary" loading={openingSupport} onClick={() => void openSupport()}>
联系平台客服
</Button>
</Empty>
) : (
<div className="pc-chat-list">
{items.map(item => (
<ConversationRow item={item} key={item.id} />
))}
{chats.hasNextPage ? (
<Button
block
loading={chats.isFetchingNextPage}
onClick={() => void chats.fetchNextPage()}
>
加载更多会话
</Button>
) : null}
</div>
)}
</Card>
</main>
<aside className="pc-messages-side">
<Card title="沟通建议" className="pc-messages-side__card">
<ul>
<li>交接说明写清账号、密码、区服与可登录时间。</li>
<li>结账前先在群内确认消耗品与押金扣除依据。</li>
<li>图片证据可直接上传,外部截图请保证可长期访问。</li>
</ul>
</Card>
<Card title="客服范围" className="pc-messages-side__card">
<Typography.Paragraph type="secondary">
账号异常、支付问题、申诉进度与退款审核都可以联系平台客服。
</Typography.Paragraph>
<Button block icon={<CustomerServiceOutlined />} onClick={() => void openSupport()}>
新建客服会话
</Button>
</Card>
</aside>
</div>
</div>
)
}
+12 -1
View File
@@ -88,13 +88,24 @@ export function useChatMessages(id?: string, enabled = true) {
})
}
export async function sendChatMessage(id: number | string, content: string) {
export async function sendChatMessage(
id: number | string,
content: string,
attachmentURLs: string[] = []
) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
content,
attachment_urls: attachmentURLs,
})
return data.data
}
export function fetchOrderConversation(orderId: number | string) {
return unwrapData<ChatConversation>(
apiClient.get<ApiResponse<ChatConversation>>(`/orders/${orderId}/chat`)
)
}
export async function markConversationRead(id: number | string) {
await apiClient.post(`/chats/${id}/read`)
}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Button, ErrorBlock, Input, Skeleton, Toast } from 'antd-mobile'
import { LeftOutline, SendOutline } from 'antd-mobile-icons'
import { CloseOutline, LeftOutline, PictureOutline, SendOutline } from 'antd-mobile-icons'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useNavigate, useParams } from 'react-router-dom'
@@ -12,6 +12,7 @@ import {
useConversation,
} from '@/features/messages/api'
import type { ChatMessage } from '@/features/messages/types'
import { uploadFile } from '@/shared/api/files'
import { useSessionStore } from '@/stores/session'
function messageTime(value: string) {
@@ -67,7 +68,10 @@ export function MobileChatPage() {
const conversation = useConversation(id, Boolean(token))
const messagesQuery = useChatMessages(id, Boolean(token))
const [content, setContent] = useState('')
const [pendingImages, setPendingImages] = useState<{ url: string; name: string }[]>([])
const [uploading, setUploading] = useState(false)
const [sending, setSending] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const lastMessageIdRef = useRef<number | null>(null)
const lastRenderedIdRef = useRef<number | null>(null)
@@ -99,13 +103,51 @@ export function MobileChatPage() {
.catch(() => undefined)
}, [id, lastMessage, queryClient])
const onPickImages = async (files: FileList | null) => {
if (!files?.length) return
const remaining = 9 - pendingImages.length
if (remaining <= 0) {
Toast.show({ content: '最多发送 9 张图片' })
return
}
const picked = Array.from(files)
.filter(file => file.type.startsWith('image/'))
.slice(0, remaining)
if (!picked.length) {
Toast.show({ content: '请选择图片文件' })
return
}
setUploading(true)
try {
const uploaded: { url: string; name: string }[] = []
for (const file of picked) {
const item = await uploadFile(file, 'chat')
uploaded.push({ url: item.url, name: item.filename })
}
setPendingImages(prev => [...prev, ...uploaded])
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '图片上传失败',
})
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const onSend = async () => {
const text = content.trim()
if (!id || !text || sending) return
if (!id || (!text && pendingImages.length === 0) || sending || uploading) return
setSending(true)
try {
await sendChatMessage(id, text)
await sendChatMessage(
id,
text,
pendingImages.map(image => image.url)
)
setContent('')
setPendingImages([])
await messagesQuery.refetch()
await queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
} catch (error) {
@@ -229,17 +271,54 @@ export function MobileChatPage() {
})}
</div>
{pendingImages.length > 0 ? (
<div className="mobile-chat__pending">
{pendingImages.map(image => (
<div className="mobile-chat__pending-item" key={image.url}>
<img src={image.url} alt={image.name} />
<button
type="button"
aria-label="移除图片"
onClick={() =>
setPendingImages(prev => prev.filter(item => item.url !== image.url))
}
>
<CloseOutline />
</button>
</div>
))}
<span className="mobile-chat__pending-count">{pendingImages.length}/9</span>
</div>
) : null}
<div className="mobile-chat__composer">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
onChange={event => void onPickImages(event.target.files)}
/>
<Button
fill="none"
className="mobile-chat__attach"
loading={uploading}
disabled={sending || pendingImages.length >= 9}
onClick={() => fileInputRef.current?.click()}
>
<PictureOutline />
</Button>
<Input
value={content}
placeholder="输入消息…"
placeholder={pendingImages.length > 0 ? '补充图片说明…' : '输入消息…'}
onChange={setContent}
onEnterPress={() => void onSend()}
/>
<Button
color="primary"
loading={sending}
disabled={!content.trim()}
disabled={(!content.trim() && pendingImages.length === 0) || uploading}
onClick={() => void onSend()}
>
<SendOutline />
@@ -1,11 +1,12 @@
import type { ReactNode } from 'react'
import { Button, ErrorBlock, List, Skeleton, Tag } from 'antd-mobile'
import { useState, type ReactNode } from 'react'
import { Button, ErrorBlock, List, Skeleton, Tag, Toast } from 'antd-mobile'
import { LeftOutline } from 'antd-mobile-icons'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { BrandLogo } from '@/components/BrandLogo'
import { useHandoffRecords, useMyOrder } from '@/features/orders/api'
import { fetchOrderConversation } from '@/features/messages/api'
import { MobileOrderActionPanel } from '@/features/mobile/MobileOrderActionPanel'
import {
checkoutStatusMeta,
@@ -42,6 +43,23 @@ export function MobileOrderDetailPage() {
const handoffQuery = useHandoffRecords(id, Boolean(token))
const order = query.data
const handoffRecords = handoffQuery.data ?? []
const [openingChat, setOpeningChat] = useState(false)
const openOrderChat = async () => {
if (!order || openingChat) return
setOpeningChat(true)
try {
const conversation = await fetchOrderConversation(order.id)
navigate(`/m/messages/${conversation.id}`)
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '订单群聊暂时无法打开',
})
} finally {
setOpeningChat(false)
}
}
if (!hydrated) {
return (
@@ -229,6 +247,15 @@ export function MobileOrderDetailPage() {
) : null}
<div className="mobile-order-detail__actions">
<Button
block
fill="outline"
color="primary"
loading={openingChat}
onClick={() => void openOrderChat()}
>
进入订单群聊
</Button>
<MobileOrderActionPanel
order={order}
onChanged={async () => {
@@ -1,5 +1,7 @@
import { useState } from 'react'
import {
Alert,
App,
Breadcrumb,
Button,
Card,
@@ -11,13 +13,14 @@ import {
Tag,
Typography,
} from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, MessageOutlined } from '@ant-design/icons'
import { useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { OrderActionPanel } from '@/features/orders/OrderActionPanel'
import { useHandoffRecords, useMyOrder } from '@/features/orders/api'
import { fetchOrderConversation } from '@/features/messages/api'
import {
checkoutStatusMeta,
handoffStatusMeta,
@@ -38,12 +41,27 @@ export function OrderDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { message } = App.useApp()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const query = useMyOrder(id, Boolean(token))
const handoffQuery = useHandoffRecords(id, Boolean(token))
const order = query.data
const records = handoffQuery.data ?? []
const [openingChat, setOpeningChat] = useState(false)
const openOrderChat = async () => {
if (!order || openingChat) return
setOpeningChat(true)
try {
const conversation = await fetchOrderConversation(order.id)
navigate(`/messages/${conversation.id}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '订单群聊暂时无法打开')
} finally {
setOpeningChat(false)
}
}
if (!hydrated) {
return (
@@ -294,6 +312,17 @@ export function OrderDetailPage() {
<Col xs={24} lg={8}>
<div className="pc-order-detail-side">
<Button
block
type="primary"
ghost
icon={<MessageOutlined />}
loading={openingChat}
className="pc-order-chat-button"
onClick={() => void openOrderChat()}
>
进入订单群聊
</Button>
<OrderActionPanel
order={order}
onChanged={async () => {