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
@@ -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 () => {