feat(mobile): 接入移动端订单与消息中心

This commit is contained in:
yml2213
2026-10-30 12:46:01 +08:00
parent 17b985a4a4
commit e8a20ebea7
17 changed files with 2153 additions and 30 deletions
+6 -4
View File
@@ -19,8 +19,12 @@ export function LoginPage() {
const token = useSessionStore(state => state.token)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const redirect = searchParams.get('redirect')
const isMobileEntry = window.location.pathname.startsWith('/m')
const fallbackPath = isMobileEntry ? '/m' : '/'
const targetPath = redirect && redirect.startsWith('/') ? redirect : fallbackPath
if (token) return <Navigate to="/" replace />
if (token) return <Navigate to={targetPath} replace />
const onFinish = async (values: LoginForm) => {
setSubmitting(true)
@@ -28,9 +32,7 @@ export function LoginPage() {
try {
await login(values.phone.trim(), values.password)
message.success('登录成功')
const redirect = searchParams.get('redirect')
const fallbackPath = window.location.pathname.startsWith('/m') ? '/m' : '/'
navigate(redirect && redirect.startsWith('/') ? redirect : fallbackPath, { replace: true })
navigate(targetPath, { replace: true })
} catch (err) {
const text =
err instanceof Error && err.message ? err.message : '登录没有完成,请核对手机号和密码'
+131
View File
@@ -0,0 +1,131 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type { AppNotification, ChatConversation, ChatMessage, ChatPage } from '@/features/messages/types'
const PAGE_SIZE = 20
export function fetchConversations(page = 1) {
return unwrapData<Partial<ChatPage<ChatConversation>>>(
apiClient.get<ApiResponse<Partial<ChatPage<ChatConversation>>>>('/chats', {
params: { page, page_size: PAGE_SIZE },
})
).then(data => ({
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? PAGE_SIZE,
}))
}
export function useConversations(enabled = true) {
return useInfiniteQuery({
queryKey: ['chat-conversations'],
queryFn: ({ pageParam }) => fetchConversations(pageParam),
initialPageParam: 1,
getNextPageParam: last =>
last.page * last.page_size < last.total ? last.page + 1 : undefined,
enabled,
})
}
export function fetchNotifications(page = 1) {
return unwrapData<Partial<ChatPage<AppNotification>>>(
apiClient.get<ApiResponse<Partial<ChatPage<AppNotification>>>>('/notifications', {
params: { page, page_size: PAGE_SIZE },
})
).then(data => ({
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? PAGE_SIZE,
}))
}
export function useNotifications(enabled = true) {
return useInfiniteQuery({
queryKey: ['notifications'],
queryFn: ({ pageParam }) => fetchNotifications(pageParam),
initialPageParam: 1,
getNextPageParam: last =>
last.page * last.page_size < last.total ? last.page + 1 : undefined,
enabled,
})
}
export function useConversation(id?: string, enabled = true) {
return useQuery({
queryKey: ['chat-conversation', id],
queryFn: () =>
unwrapData<ChatConversation>(apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)),
enabled: Boolean(id) && enabled,
})
}
export function fetchChatMessages(id: number | string, page = 1) {
return unwrapData<Partial<ChatPage<ChatMessage>>>(
apiClient.get<ApiResponse<Partial<ChatPage<ChatMessage>>>>(`/chats/${id}/messages`, {
params: { page, page_size: PAGE_SIZE },
})
).then(data => ({
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? PAGE_SIZE,
}))
}
export function useChatMessages(id?: string, enabled = true) {
return useInfiniteQuery({
queryKey: ['chat-messages', id],
queryFn: ({ pageParam }) => fetchChatMessages(id as string, pageParam),
initialPageParam: 1,
getNextPageParam: last =>
last.page * last.page_size < last.total ? last.page + 1 : undefined,
enabled: Boolean(id) && enabled,
refetchInterval: 6000,
})
}
export async function sendChatMessage(id: number | string, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
content,
})
return data.data
}
export async function markConversationRead(id: number | string) {
await apiClient.post(`/chats/${id}/read`)
}
export async function markNotificationRead(id: number | string) {
await apiClient.post(`/notifications/${id}/read`)
}
export async function markAllNotificationsRead() {
await apiClient.put('/notifications/read-all')
}
export async function ensureSupportConversation() {
return unwrapData<ChatConversation>(
apiClient.post<ApiResponse<ChatConversation>>('/chats/support', { scene: 'general' })
)
}
export function useChatUnreadCount(enabled = true) {
return useQuery({
queryKey: ['chat-unread-count'],
queryFn: () =>
unwrapData<{ unread_count: number }>(
apiClient.get<ApiResponse<{ unread_count: number }>>(
'/chats/unread-count',
{ skipErrorHandler: true } as never
)
)
.then(data => data.unread_count ?? 0)
.catch(() => 0),
enabled,
refetchInterval: 20_000,
})
}
+67
View File
@@ -0,0 +1,67 @@
export interface ChatParticipant {
id: number
participant_type: string
participant_id: number
role: string
display_name: string
avatar_url: string
last_read_at?: string | null
joined_at: string
}
export interface ChatConversation {
id: number
order_id?: number | null
listing_id?: number | null
latest_order_id?: number | null
latest_order_no?: string
latest_order_status?: string
type: string
support_scene?: string
title: string
status: string
role: string
participants?: ChatParticipant[]
last_message_id?: number | null
last_message_preview: string
last_message_at?: string | null
last_sender_type?: string
unread_count: number
needs_reply: boolean
created_at: string
updated_at: string
}
export interface ChatPage<T> {
items: T[]
total: number
page: number
page_size: number
}
export interface ChatMessage {
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[]
created_at: string
}
export interface AppNotification {
id: number
type: string
title: string
content: string
biz_type: string
biz_id?: number | null
read_at?: string | null
created_at: string
}
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState } from 'react'
import { Button, ErrorBlock, Input, Skeleton, Toast } from 'antd-mobile'
import { LeftOutline, SendOutline } from 'antd-mobile-icons'
import { useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useNavigate, useParams } from 'react-router-dom'
import {
markConversationRead,
sendChatMessage,
useChatMessages,
useConversation,
} from '@/features/messages/api'
import type { ChatMessage } from '@/features/messages/types'
import { useSessionStore } from '@/stores/session'
function messageTime(value: string) {
return dayjs(value).format('HH:mm')
}
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="mobile-chat__system">
<span>{message.content}</span>
</div>
)
}
return (
<div className={`mobile-chat__message${message.is_self ? ' mobile-chat__message--self' : ''}`}>
{!message.is_self ? (
<span className="mobile-chat__avatar">{(message.sender_name || '商').slice(0, 1)}</span>
) : null}
<div className="mobile-chat__bubble-wrap">
{!message.is_self ? <span className="mobile-chat__sender">{message.sender_name}</span> : null}
<div className="mobile-chat__bubble">
{message.content_type === 'image' && message.attachment_urls?.length ? (
<div className="mobile-chat__images">
{message.attachment_urls.map(url => (
<img src={url} alt="聊天图片" key={url} loading="lazy" />
))}
</div>
) : null}
{message.content ? <p>{message.content}</p> : null}
</div>
<span className="mobile-chat__time">{messageTime(message.created_at)}</span>
</div>
</div>
)
}
export function MobileChatPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
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 [sending, setSending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const lastMessageIdRef = 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 = messages[messages.length - 1]?.id ?? null
if (!element || !lastId || lastRenderedIdRef.current === lastId) return
lastRenderedIdRef.current = lastId
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' })
}, [messages])
useEffect(() => {
if (!id || !lastMessage || lastMessage.id === lastMessageIdRef.current) return
lastMessageIdRef.current = lastMessage.id
markConversationRead(id)
.then(() => {
void queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
void queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] })
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
})
.catch(() => undefined)
}, [id, lastMessage, queryClient])
const onSend = async () => {
const text = content.trim()
if (!id || !text || sending) return
setSending(true)
try {
await sendChatMessage(id, text)
setContent('')
await messagesQuery.refetch()
await queryClient.invalidateQueries({ queryKey: ['chat-conversations'] })
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '发送失败,请稍后再试',
})
} finally {
setSending(false)
}
}
if (!hydrated) {
return (
<div className="mobile-chat">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={8} animated />
</div>
)
}
if (!token) {
return (
<div className="mobile-chat">
<div className="mobile-chat__head">
<button type="button" onClick={() => navigate('/m/messages')}>
<LeftOutline /> 返回
</button>
</div>
<div className="mobile-messages__empty">
<ErrorBlock status="empty" title="登录后查看会话" />
<Button
block
color="primary"
onClick={() => navigate(`/m/login?redirect=/m/messages/${id}`)}
>
去登录
</Button>
</div>
</div>
)
}
if (messagesQuery.isPending) {
return (
<div className="mobile-chat">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={8} animated />
</div>
)
}
if (messagesQuery.isError) {
return (
<div className="mobile-chat">
<div className="mobile-chat__head">
<button type="button" onClick={() => navigate('/m/messages')}>
<LeftOutline /> 返回
</button>
</div>
<ErrorBlock status="disconnected" title="会话没有加载出来">
<Button size="small" onClick={() => void messagesQuery.refetch()}>
重新加载
</Button>
</ErrorBlock>
</div>
)
}
let previousDate = ''
return (
<div className="mobile-chat">
<div className="mobile-chat__head">
<button type="button" className="mobile-chat__back" onClick={() => navigate('/m/messages')}>
<LeftOutline />
</button>
<div className="mobile-chat__title">
<strong>{conversation.data?.title || '会话'}</strong>
<span>
{conversation.data?.type === 'general_support'
? '平台客服'
: conversation.data?.type === 'order_group'
? `订单 ${conversation.data.latest_order_no || ''}`.trim()
: conversation.data?.type === 'listing_group'
? '商品咨询群'
: '交易会话'}
</span>
</div>
<span className="mobile-chat__head-space" />
</div>
<div className="mobile-chat__body" ref={scrollRef}>
{messagesQuery.hasNextPage ? (
<div className="mobile-chat__earlier">
<Button
size="mini"
fill="outline"
loading={messagesQuery.isFetchingNextPage}
onClick={() => void messagesQuery.fetchNextPage()}
>
查看更早消息
</Button>
</div>
) : null}
{messages.length === 0 ? (
<div className="mobile-chat__empty">还没有消息,打个招呼吧</div>
) : null}
{messages.map(message => {
const label = dateLabel(message.created_at)
const showDate = label !== previousDate
previousDate = label
return (
<div key={message.id}>
{showDate ? <div className="mobile-chat__date">{label}</div> : null}
<MessageBubble message={message} />
</div>
)
})}
</div>
<div className="mobile-chat__composer">
<Input
value={content}
placeholder="输入消息…"
onChange={setContent}
onEnterPress={() => void onSend()}
/>
<Button
color="primary"
loading={sending}
disabled={!content.trim()}
onClick={() => void onSend()}
>
<SendOutline />
</Button>
</div>
</div>
)
}
@@ -0,0 +1,252 @@
import { useState } from 'react'
import { Badge, Button, Empty, ErrorBlock, Skeleton, Tabs, Tag, Toast } from 'antd-mobile'
import { RightOutline } from 'antd-mobile-icons'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import {
ensureSupportConversation,
markAllNotificationsRead,
markNotificationRead,
useConversations,
useNotifications,
} from '@/features/messages/api'
import type { AppNotification, ChatConversation } from '@/features/messages/types'
import { useSessionStore } from '@/stores/session'
import { formatRelativeTime } from '@/shared/utils/format'
type MessageTab = 'chats' | 'notices'
function conversationBadge(type: string) {
if (type === 'general_support') return '客服'
if (type === 'order_group') return '订单'
if (type === 'listing_group') return '商品'
return '商行'
}
function ChatRow({ item }: { item: ChatConversation }) {
const navigate = useNavigate()
return (
<button
type="button"
className="mobile-chat-row"
onClick={() => navigate(`/m/messages/${item.id}`)}
>
<span className="mobile-chat-row__avatar">{conversationBadge(item.type)}</span>
<span className="mobile-chat-row__body">
<span className="mobile-chat-row__top">
<strong>{item.title || '未命名会话'}</strong>
<small>{formatRelativeTime(item.last_message_at)}</small>
</span>
<span className="mobile-chat-row__preview">
{item.last_message_preview || '还没有消息'}
</span>
</span>
<span className="mobile-chat-row__tail">
{item.unread_count > 0 ? <Badge content={item.unread_count} /> : <RightOutline />}
</span>
</button>
)
}
function NoticeRow({ item, onRead }: { item: AppNotification; onRead: (id: number) => void }) {
const unread = !item.read_at
return (
<button
type="button"
className={`mobile-notice-row${unread ? ' mobile-notice-row--unread' : ''}`}
onClick={() => onRead(item.id)}
>
<span className="mobile-notice-row__dot" aria-hidden />
<span className="mobile-notice-row__body">
<span className="mobile-notice-row__top">
<strong>{item.title}</strong>
<small>{formatRelativeTime(item.created_at)}</small>
</span>
<span className="mobile-notice-row__content">{item.content}</span>
</span>
</button>
)
}
export function MobileMessagesPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const [tab, setTab] = useState<MessageTab>('chats')
const [openingSupport, setOpeningSupport] = useState(false)
const chats = useConversations(Boolean(token))
const notices = useNotifications(Boolean(token))
const chatItems = chats.data?.pages.flatMap(page => page.items) ?? []
const noticeItems = notices.data?.pages.flatMap(page => page.items) ?? []
const unreadNotices = noticeItems.filter(item => !item.read_at).length
const openSupport = async () => {
setOpeningSupport(true)
try {
const conversation = await ensureSupportConversation()
navigate(`/m/messages/${conversation.id}`)
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '客服会话暂时不可用',
})
} finally {
setOpeningSupport(false)
}
}
const readNotice = async (id: number) => {
try {
await markNotificationRead(id)
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
} catch {
Toast.show({ icon: 'fail', content: '标记已读失败,请稍后再试' })
}
}
const readAll = async () => {
try {
await markAllNotificationsRead()
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
Toast.show({ icon: 'success', content: '已全部标记为已读' })
} catch {
Toast.show({ icon: 'fail', content: '操作失败,请稍后再试' })
}
}
if (!hydrated) {
return (
<div className="mobile-messages">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={6} animated />
</div>
)
}
if (!token) {
return (
<div className="mobile-messages">
<section className="mobile-messages__head">
<span className="mobile-messages__eyebrow">MESSAGES</span>
<h1>消息中心</h1>
<p>登录后查看订单会话、客服回复与系统通知。</p>
</section>
<div className="mobile-orders__login">
<BrandLogo height={40} variant="seal" />
<h2>先登录,再看消息</h2>
<p>订单群聊与客服会话只对交易双方开放。</p>
<Button block color="primary" onClick={() => navigate('/m/login?redirect=/m/messages')}>
去登录
</Button>
</div>
</div>
)
}
return (
<div className="mobile-messages">
<section className="mobile-messages__head">
<span className="mobile-messages__eyebrow">MESSAGES</span>
<h1>消息中心</h1>
<p>订单会话、客服回复与系统通知都在这里。</p>
</section>
<Tabs
activeKey={tab}
onChange={key => setTab(key as MessageTab)}
className="mobile-messages__tabs"
>
<Tabs.Tab title={`会话${chatItems.length ? ` · ${chatItems.length}` : ''}`} key="chats" />
<Tabs.Tab title={`通知${unreadNotices ? ` · ${unreadNotices}` : ''}`} key="notices" />
</Tabs>
{tab === 'chats' ? (
<div className="mobile-messages__panel">
<div className="mobile-messages__panel-actions">
<Button size="small" fill="outline" loading={openingSupport} onClick={() => void openSupport()}>
联系客服
</Button>
</div>
{chats.isPending ? (
<Skeleton.Paragraph lineCount={6} animated />
) : chats.isError ? (
<ErrorBlock status="disconnected" title="会话没有加载出来">
<Button size="small" onClick={() => void chats.refetch()}>
重新加载
</Button>
</ErrorBlock>
) : chatItems.length === 0 ? (
<div className="mobile-messages__empty">
<Empty description="还没有会话" />
<span>下单后会自动创建订单群聊,也可以先联系客服。</span>
</div>
) : (
<div className="mobile-message-list">
{chatItems.map(item => (
<ChatRow item={item} key={item.id} />
))}
{chats.hasNextPage ? (
<Button
block
fill="outline"
loading={chats.isFetchingNextPage}
onClick={() => void chats.fetchNextPage()}
>
加载更多会话
</Button>
) : null}
</div>
)}
</div>
) : (
<div className="mobile-messages__panel">
<div className="mobile-messages__panel-actions">
<Tag color="default">{unreadNotices ? `${unreadNotices} 条未读` : '全部已读'}</Tag>
{unreadNotices > 0 ? (
<Button size="small" fill="outline" onClick={() => void readAll()}>
全部已读
</Button>
) : null}
</div>
{notices.isPending ? (
<Skeleton.Paragraph lineCount={6} animated />
) : notices.isError ? (
<ErrorBlock status="disconnected" title="通知没有加载出来">
<Button size="small" onClick={() => void notices.refetch()}>
重新加载
</Button>
</ErrorBlock>
) : noticeItems.length === 0 ? (
<div className="mobile-messages__empty">
<Empty description="暂时没有通知" />
</div>
) : (
<div className="mobile-message-list">
{noticeItems.map(item => (
<NoticeRow item={item} key={item.id} onRead={id => void readNotice(id)} />
))}
{notices.hasNextPage ? (
<Button
block
fill="outline"
loading={notices.isFetchingNextPage}
onClick={() => void notices.fetchNextPage()}
>
加载更多通知
</Button>
) : null}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,229 @@
import { useState, type ReactNode } from 'react'
import { Button, Dialog, ErrorBlock, List, Skeleton, Tag, Toast } from 'antd-mobile'
import { LeftOutline } from 'antd-mobile-icons'
import { useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { cancelMyOrder, useMyOrder } from '@/features/orders/api'
import {
checkoutStatusMeta,
handoffStatusMeta,
orderStatusMeta,
settlementStatusMeta,
} from '@/features/orders/labels'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
function StatusLine({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="mobile-order-detail__status-line">
<span>{label}</span>
{children}
</div>
)
}
export function MobileOrderDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const query = useMyOrder(id, Boolean(token))
const [cancelling, setCancelling] = useState(false)
const order = query.data
const onCancel = async () => {
if (!order) return
const confirmed = await Dialog.confirm({
content: '取消后订单会关闭,已冻结的押金将按原路退回。确定取消吗?',
confirmText: '取消订单',
cancelText: '再想想',
})
if (!confirmed) return
setCancelling(true)
try {
await cancelMyOrder(order.id)
Toast.show({ icon: 'success', content: '订单已取消' })
await queryClient.invalidateQueries({ queryKey: ['my-orders'] })
await query.refetch()
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '取消失败,请稍后再试',
})
} finally {
setCancelling(false)
}
}
if (!hydrated) {
return (
<div className="mobile-order-detail">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={8} animated />
</div>
)
}
if (!token) {
return (
<div className="mobile-order-detail">
<div className="mobile-order-detail__back">
<button type="button" onClick={() => navigate('/m/orders')}>
<LeftOutline /> 返回订单
</button>
</div>
<div className="mobile-orders__login">
<BrandLogo height={40} variant="seal" />
<h2>登录后查看订单详情</h2>
<p>交接记录与结账明细只对交易双方可见。</p>
<Button block color="primary" onClick={() => navigate(`/m/login?redirect=/m/orders/${id}`)}>
去登录
</Button>
</div>
</div>
)
}
if (query.isPending) {
return (
<div className="mobile-order-detail">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={8} animated />
</div>
)
}
if (query.isError || !order) {
return (
<div className="mobile-order-detail">
<div className="mobile-order-detail__back">
<button type="button" onClick={() => navigate('/m/orders')}>
<LeftOutline /> 返回订单
</button>
</div>
<ErrorBlock
status="disconnected"
title="订单详情没有加载出来"
description="订单可能已关闭,或当前账号无权查看。"
>
<Button color="primary" size="small" onClick={() => void query.refetch()}>
重新加载
</Button>
</ErrorBlock>
</div>
)
}
const status = orderStatusMeta(order.status)
const handoff = handoffStatusMeta(order.handoff_status)
const settlement = settlementStatusMeta(order.settlement_status)
const checkout = order.checkout
const canCancel = order.price_role === 'renter' && order.status === 'pending_payment'
return (
<div className="mobile-order-detail">
<div className="mobile-order-detail__back">
<button type="button" onClick={() => navigate('/m/orders')}>
<LeftOutline /> 返回订单
</button>
</div>
<section className="mobile-order-detail__hero">
<div className="mobile-order-detail__hero-top">
<span className="mobile-order-detail__no">{order.order_no}</span>
<Tag color={status.tone} fill="outline">
{status.label}
</Tag>
</div>
<h1>{order.title}</h1>
<div className="mobile-order-detail__status">
<StatusLine label="交接">
<Tag color={handoff.tone} fill="outline">
{handoff.label}
</Tag>
</StatusLine>
<StatusLine label="结算">
<Tag color={settlement.tone} fill="outline">
{settlement.label}
</Tag>
</StatusLine>
</div>
<div className="mobile-order-detail__hero-amount">
<strong>{formatMoney(order.display_amount_cent)}</strong>
<span>押金 {formatMoney(order.deposit_amount_cent)}</span>
</div>
</section>
<List header="商品信息" className="mobile-order-detail__list">
<List.Item extra={formatText(order.listing_no)}>商品编号</List.Item>
<List.Item extra={formatText(order.server_region)}>区服</List.Item>
<List.Item extra={formatText(order.login_platform)}>登录平台</List.Item>
<List.Item extra={order.estimated_duration_hours ? `${order.estimated_duration_hours} 小时` : '—'}>
预计租期
</List.Item>
</List>
<List header="金额明细" className="mobile-order-detail__list">
<List.Item extra={formatMoney(order.rent_amount_cent)}>租客租金</List.Item>
<List.Item extra={formatMoney(order.owner_rent_amount_cent)}>号主租金</List.Item>
<List.Item extra={formatMoney(order.platform_fee_cent)}>平台服务费</List.Item>
<List.Item extra={formatMoney(order.deposit_amount_cent)}>实收押金</List.Item>
{order.deposit_waived_amount_cent > 0 ? (
<List.Item extra={formatMoney(order.deposit_waived_amount_cent)}>押金减免</List.Item>
) : null}
{order.refund_amount_cent > 0 ? (
<List.Item extra={formatMoney(order.refund_amount_cent)}>已退金额</List.Item>
) : null}
</List>
<List header="时间" className="mobile-order-detail__list">
<List.Item extra={formatDateTime(order.created_at)}>下单时间</List.Item>
<List.Item extra={formatDateTime(order.rented_at)}>起租时间</List.Item>
<List.Item extra={formatDateTime(order.estimated_end_at)}>预计结束</List.Item>
<List.Item extra={formatDateTime(order.payment_deadline_at)}>支付截止</List.Item>
</List>
{checkout ? (
<List header="结账单" className="mobile-order-detail__list">
<List.Item extra={<Tag color={checkoutStatusMeta(checkout.status).tone}>{checkoutStatusMeta(checkout.status).label}</Tag>}>
结账状态
</List.Item>
<List.Item extra={`${checkout.round_count} / ${checkout.max_rounds}`}>协商轮次</List.Item>
<List.Item extra={formatMoney(checkout.consumable_amount_cent)}>消耗品费用</List.Item>
<List.Item extra={`${checkout.coin_consumed_m} M`}>哈夫币消耗</List.Item>
<List.Item extra={formatMoney(checkout.other_amount_cent)}>其他费用</List.Item>
<List.Item extra={formatMoney(checkout.deposit_deduct_amount_cent)}>押金扣除</List.Item>
<List.Item extra={formatMoney(checkout.renter_refund_amount_cent)}>可退金额</List.Item>
<List.Item extra={formatMoney(checkout.owner_income_amount_cent)}>号主到手</List.Item>
{checkout.content ? (
<List.Item>
<div className="mobile-order-detail__checkout-content">{checkout.content}</div>
</List.Item>
) : null}
</List>
) : null}
{order.active_dispute ? (
<List header="申诉" className="mobile-order-detail__list">
<List.Item extra={`#${order.active_dispute.id}`}>申诉单号</List.Item>
<List.Item extra={formatText(order.active_dispute.type)}>类型</List.Item>
<List.Item extra={formatText(order.active_dispute.status)}>状态</List.Item>
</List>
) : null}
<div className="mobile-order-detail__actions">
{canCancel ? (
<Button block color="danger" fill="outline" loading={cancelling} onClick={() => void onCancel()}>
取消订单
</Button>
) : null}
<Link to="/m/orders" className="mobile-order-detail__back-link">
返回我的订单
</Link>
</div>
</div>
)
}
@@ -0,0 +1,157 @@
import { Button, Empty, ErrorBlock, PullToRefresh, Skeleton, Tag } from 'antd-mobile'
import { RightOutline } from 'antd-mobile-icons'
import { Link, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useMyOrders } from '@/features/orders/api'
import { orderStatusMeta, roleLabel } from '@/features/orders/labels'
import type { UserOrderListItem } from '@/features/orders/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
function orderSummary(item: UserOrderListItem) {
return item.rental_days ? `${item.rental_days} 天` : formatText(item.login_platform)
}
function MobileOrderCard({ item }: { item: UserOrderListItem }) {
const navigate = useNavigate()
const status = orderStatusMeta(item.status)
return (
<button
type="button"
className="mobile-order-card"
onClick={() => navigate(`/m/orders/${item.id}`)}
>
<div className="mobile-order-card__top">
<span className="mobile-order-card__no">{item.order_no}</span>
<Tag color={status.tone} fill="outline">
{status.label}
</Tag>
</div>
<div className="mobile-order-card__title">{item.title}</div>
<div className="mobile-order-card__meta">
<span>{roleLabel(item.role)}</span>
<span>{formatText(item.server_region)}</span>
<span>{orderSummary(item)}</span>
</div>
<div className="mobile-order-card__bottom">
<div>
<strong>{formatMoney(item.display_amount_cent)}</strong>
<small>押金 {formatMoney(item.deposit_amount_cent)}</small>
</div>
<span className="mobile-order-card__date">
{formatDateTime(item.created_at)} <RightOutline />
</span>
</div>
</button>
)
}
export function MobileOrdersPage() {
const navigate = useNavigate()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const query = useMyOrders(Boolean(token))
const items = query.data?.pages.flatMap(page => page.items) ?? []
const total = query.data?.pages[0]?.total ?? 0
if (!hydrated) {
return (
<div className="mobile-orders">
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={6} animated />
</div>
)
}
if (!token) {
return (
<div className="mobile-orders">
<section className="mobile-orders__head">
<span className="mobile-orders__eyebrow">MY ORDERS</span>
<h1>我的订单</h1>
<p>登录后查看租用进度、交接与结账记录。</p>
</section>
<div className="mobile-orders__login">
<BrandLogo height={40} variant="seal" />
<h2>先登录,再看订单</h2>
<p>订单、押金与结账明细只对交易双方可见。</p>
<Button
block
color="primary"
onClick={() => navigate('/m/login?redirect=/m/orders')}
>
去登录
</Button>
</div>
</div>
)
}
return (
<div className="mobile-orders">
<section className="mobile-orders__head">
<span className="mobile-orders__eyebrow">MY ORDERS</span>
<h1>我的订单</h1>
<p>{query.data ? `共 ${total} 单 · 下拉可刷新` : '正在读取你的订单…'}</p>
</section>
{query.isPending ? (
<div className="mobile-orders__skeleton">
{Array.from({ length: 3 }).map((_, index) => (
<div className="mobile-order-card mobile-order-card--skeleton" key={index}>
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={3} animated />
</div>
))}
</div>
) : query.isError ? (
<ErrorBlock
status="disconnected"
title="订单没有加载出来"
description="检查网络后重试,或重新登录。"
>
<Button color="primary" size="small" onClick={() => void query.refetch()}>
重新加载
</Button>
</ErrorBlock>
) : (
<PullToRefresh onRefresh={() => query.refetch().then(() => undefined)}>
<div className="mobile-orders__list">
{items.length === 0 ? (
<div className="mobile-orders__empty">
<Empty description="还没有订单" />
<Link to="/m" className="mobile-orders__empty-link">
去逛逛商品 <RightOutline />
</Link>
</div>
) : (
items.map(item => <MobileOrderCard item={item} key={item.id} />)
)}
{items.length > 0 ? (
<div className="mobile-orders__more">
{query.hasNextPage ? (
<Button
block
fill="outline"
loading={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
加载更多
</Button>
) : (
<span>已经看到全部订单</span>
)}
</div>
) : null}
</div>
</PullToRefresh>
)}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type { UserOrderDetail, UserOrderPage } from '@/features/orders/types'
export const MY_ORDER_PAGE_SIZE = 20
export function fetchMyOrders(page = 1, pageSize = MY_ORDER_PAGE_SIZE) {
return unwrapData<Partial<UserOrderPage>>(
apiClient.get<ApiResponse<Partial<UserOrderPage>>>('/orders', {
params: { page, page_size: pageSize },
})
).then(data => ({
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function useMyOrders(enabled = true) {
return useInfiniteQuery({
queryKey: ['my-orders'],
queryFn: ({ pageParam }) => fetchMyOrders(pageParam, MY_ORDER_PAGE_SIZE),
initialPageParam: 1,
getNextPageParam: last =>
last.page * last.page_size < last.total ? last.page + 1 : undefined,
enabled,
})
}
export function fetchMyOrder(id: number | string) {
return unwrapData<UserOrderDetail>(apiClient.get<ApiResponse<UserOrderDetail>>(`/orders/${id}`))
}
export function useMyOrder(id?: string, enabled = true) {
return useQuery({
queryKey: ['my-order', id],
queryFn: () => fetchMyOrder(id as string),
enabled: Boolean(id) && enabled,
})
}
export async function cancelMyOrder(id: number | string) {
await apiClient.post(`/orders/${id}/cancel`)
}
+88
View File
@@ -0,0 +1,88 @@
export type MobileTone = 'default' | 'primary' | 'success' | 'warning' | 'danger'
export interface MobileStatus {
label: string
tone: MobileTone
}
const orderStatus: Record<string, MobileStatus> = {
pending_payment: { label: '待支付', tone: 'warning' },
pending_handoff: { label: '待交接', tone: 'primary' },
renting: { label: '使用中', tone: 'primary' },
overdue: { label: '已逾期', tone: 'danger' },
pending_checkout_confirm: { label: '待确认结账', tone: 'warning' },
pending_checkout_accept: { label: '待确认修正', tone: 'warning' },
checkout_disputing: { label: '结账争议', tone: 'danger' },
disputing: { label: '申诉中', tone: 'danger' },
completed: { label: '已完成', tone: 'success' },
cancelled: { label: '已取消', tone: 'default' },
closed: { label: '已关闭', tone: 'default' },
abnormal: { label: '客服处理中', tone: 'danger' },
}
const handoffStatus: Record<string, MobileStatus> = {
none: { label: '未开始', tone: 'default' },
pending_owner: { label: '待号主交接', tone: 'primary' },
owner_timeout: { label: '交接超时', tone: 'danger' },
pending_renter_confirm: { label: '待确认收号', tone: 'primary' },
received: { label: '已收号', tone: 'success' },
return_overdue: { label: '归还逾期', tone: 'danger' },
pending_owner_checkout: { label: '待号主结账', tone: 'warning' },
pending_renter_checkout: { label: '待确认修正', tone: 'warning' },
checkout_disputed: { label: '结账争议', tone: 'danger' },
returned: { label: '已归还', tone: 'success' },
renter_confirm_timeout: { label: '确认超时', tone: 'danger' },
owner_return_confirm_timeout: { label: '结账超时', tone: 'danger' },
owner_checkout_confirm_timeout: { label: '结账超时', tone: 'danger' },
cancelled: { label: '已取消', tone: 'default' },
admin_closed: { label: '已关闭', tone: 'default' },
admin_abnormal: { label: '客服处理中', tone: 'danger' },
arbitrated: { label: '已仲裁', tone: 'success' },
}
const settlementStatus: Record<string, MobileStatus> = {
unsettled: { label: '未结算', tone: 'default' },
pending: { label: '待结算', tone: 'warning' },
frozen: { label: '冻结中', tone: 'danger' },
settled: { label: '已结算', tone: 'success' },
refunded: { label: '已退款', tone: 'primary' },
cancelled: { label: '结算已取消', tone: 'default' },
closed: { label: '结算已关闭', tone: 'default' },
disputed: { label: '争议中', tone: 'danger' },
arbitrated: { label: '已仲裁', tone: 'success' },
}
const checkoutStatus: Record<string, MobileStatus> = {
submitted: { label: '等待确认', tone: 'warning' },
countered: { label: '已修正', tone: 'warning' },
accepted: { label: '已接受', tone: 'success' },
disputed: { label: '争议中', tone: 'danger' },
settled: { label: '仲裁结案', tone: 'success' },
}
function read(map: Record<string, MobileStatus>, value?: string | null): MobileStatus {
if (!value) return { label: '—', tone: 'default' }
return map[value] ?? { label: value, tone: 'default' }
}
export function orderStatusMeta(value?: string | null) {
return read(orderStatus, value)
}
export function handoffStatusMeta(value?: string | null) {
return read(handoffStatus, value)
}
export function settlementStatusMeta(value?: string | null) {
return read(settlementStatus, value)
}
export function checkoutStatusMeta(value?: string | null) {
return read(checkoutStatus, value)
}
export function roleLabel(role?: string | null) {
if (role === 'owner') return '我出租的'
if (role === 'renter') return '我租的'
return '订单'
}
+89
View File
@@ -0,0 +1,89 @@
export interface UserOrderListItem {
id: number
order_no: string
listing_id: number
listing_no: string
role: 'owner' | 'renter' | string
title: string
server_region: string
login_platform: string
rental_days?: number
daily_loss_m?: number
display_amount_cent: number
deposit_amount_cent: number
deposit_waived_amount_cent: number
status: string
handoff_status: string
can_hide?: boolean
payment_deadline_at?: string
distribution_site_id?: number | null
created_at: string
}
export interface UserOrderPage {
items: UserOrderListItem[]
total: number
page: number
page_size: number
}
export interface UserCheckout {
id: number
status: string
round_count: number
max_rounds: number
content: string
consumable_amount_cent: number
coin_consumed_m: number
other_amount_cent: number
deposit_deduct_amount_cent: number
renter_refund_amount_cent?: number | null
owner_income_amount_cent?: number | null
shortfall_cent: number
overshoot_amount_cent: number
owner_adjustment_reason?: string
renter_confirmed_at?: string | null
created_at: string
updated_at: string
}
export interface UserActiveDispute {
id: number
type: string
status: string
initiator_type: string
}
export interface UserOrderDetail {
id: number
order_no: string
listing_id: number
listing_no: string
owner_id: number
renter_id: number
title: string
server_region: string
login_platform: string
price_role?: string
rented_at?: string | null
estimated_duration_hours: number
estimated_end_at?: string | null
display_amount_cent: number
rent_amount_cent?: number | null
owner_rent_amount_cent?: number | null
deposit_amount_cent: number
deposit_waived_amount_cent: number
platform_fee_cent?: number | null
status: string
handoff_status: string
handoff_mode: string
settlement_mode: string
settlement_status: string
refund_status?: string
refund_amount_cent: number
payment_deadline_at?: string | null
active_dispute?: UserActiveDispute | null
checkout?: UserCheckout | null
created_at: string
updated_at: string
}