feat(mobile): 接入移动端订单与消息中心
This commit is contained in:
+2
-2
@@ -181,13 +181,13 @@ dist/assets/MobileListingDetailPage-*.js 27.31 kB │ gzip 10.70 kB
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单与消息页仍是占位,下单、客服会话尚未接入。
|
||||
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
|
||||
2. 后台除仪表盘、订单列表与订单详情外,其余页面仍为统一占位。
|
||||
3. 分销后台尚未做品牌轻改。
|
||||
4. 后台菜单权限码基于旧项目推断:订单页已核对为 `order:list`(列表)与 `order:view`(详情),其余页面仍需逐一核对。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 首屏包体仍偏大。
|
||||
7. 移动端批次已提交为 `ef01486`;后台订单列表与详情在后续批次提交。
|
||||
7. 移动端批次 `ef01486`、后台订单批次 `17b985a` 已提交;移动订单与消息在后续批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Phase 2 验收报告(移动端订单与消息)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/m/orders`、`/m/orders/:id`、`/m/messages`、`/m/messages/:id`
|
||||
> 结论:**本切片通过,移动端订单与消息已接真实接口**
|
||||
|
||||
---
|
||||
|
||||
## 1. 订单中心
|
||||
|
||||
### 1.1 接口
|
||||
|
||||
- `GET /api/orders?page=&page_size=`:我的订单分页(列表 DTO)
|
||||
- `GET /api/orders/:id`:订单详情(按角色脱敏后的 `OrderDTO`)
|
||||
- `POST /api/orders/:id/cancel`:租客取消待支付订单
|
||||
|
||||
### 1.2 页面
|
||||
|
||||
`frontend/src/features/mobile/MobileOrdersPage.tsx`
|
||||
|
||||
- 未登录展示登录引导,登录后跳回 `/m/orders`
|
||||
- 下拉刷新 + 分页「加载更多」(`useInfiniteQuery`,20 条/页)
|
||||
- 订单卡片:订单号、状态标签、商品标题、角色(我租的 / 我出租的)、区服、租期、金额、押金、下单时间
|
||||
- 空态、错误态、加载骨架
|
||||
- 请求在未登录时不发出,避免 401 触发刷新失败跳转
|
||||
|
||||
`frontend/src/features/mobile/MobileOrderDetailPage.tsx`
|
||||
|
||||
- 状态头:订单号、订单状态、交接状态、结算状态、金额与押金
|
||||
- 分段:商品信息、金额明细、时间、结账单、进行中申诉
|
||||
- 待支付且为租客时提供「取消订单」,二次确认后调用取消接口并刷新列表
|
||||
- 详情页隐藏底部 TabBar
|
||||
|
||||
## 2. 消息中心
|
||||
|
||||
### 2.1 接口
|
||||
|
||||
- `GET /api/chats` / `GET /api/chats/:id` / `GET /api/chats/:id/messages`
|
||||
- `POST /api/chats/:id/messages`、`POST /api/chats/:id/read`
|
||||
- `POST /api/chats/support`:创建/复用平台客服会话
|
||||
- `GET /api/chats/unread-count`:底部 TabBar 未读徽标(20 秒轮询)
|
||||
- `GET /api/notifications`、`POST /api/notifications/:id/read`、`PUT /api/notifications/read-all`
|
||||
|
||||
### 2.2 页面
|
||||
|
||||
`frontend/src/features/mobile/MobileMessagesPage.tsx`
|
||||
|
||||
- 「会话 / 通知」双 Tab
|
||||
- 会话行:类型印章(客服 / 订单 / 商品)、标题、最后一条预览、时间、未读数
|
||||
- 「联系客服」直接创建并进入客服会话
|
||||
- 通知行:未读标记、标题、内容摘要、时间;点击标记已读,支持全部已读
|
||||
|
||||
`frontend/src/features/mobile/MobileChatPage.tsx`
|
||||
|
||||
- 会话标题、类型说明、订单号(订单群聊)
|
||||
- 消息按日期分组;系统消息居中;自己/对方气泡区分
|
||||
- 图片消息支持展示附件
|
||||
- 输入框 + 发送按钮,发送后刷新消息与会话列表
|
||||
- 打开会话与收到新消息时自动标记已读
|
||||
- 6 秒轮询新消息;上拉加载更早消息
|
||||
- 底部输入区固定,页面高度按视口计算
|
||||
|
||||
## 3. 其它配套修改
|
||||
|
||||
- `LoginPage` 修复移动端登录后跳转:优先使用 `redirect` 参数,未登录用户从 `/m/orders`、`/m/messages` 进入登录后回到原页面。
|
||||
- `MobileLayout` 新增 `mobile-shell--no-tabbar`,详情页/聊天页不再预留 72px TabBar 空白。
|
||||
- 底部「消息」Tab 接入未读数徽标。
|
||||
|
||||
## 4. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(主包 888.74 kB,gzip 290.00 kB,仍有 >700 kB 警告)
|
||||
GET /m/orders 200
|
||||
GET /m/orders/1 200
|
||||
GET /m/messages 200
|
||||
GET /m/messages/2 200
|
||||
POST /api/auth/password/login(13800138000) ok
|
||||
GET /api/orders(用户 Bearer) ok,空列表
|
||||
GET /api/notifications ok,items/total/page/page_size
|
||||
GET /api/chats ok,返回 listing_group 会话
|
||||
POST /api/chats/support ok,创建 general_support 会话 #2
|
||||
GET /api/chats/2/messages ok,返回系统欢迎消息
|
||||
POST /api/chats/2/messages(文本) ok,返回 is_self=true 的消息
|
||||
POST /api/chats/2/read ok,{"read":true}
|
||||
GET /api/chats/unread-count ok,unread_count=0
|
||||
PUT /api/notifications/read-all ok,read_count=0
|
||||
```
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. 订单支付、交接、归还、结账、申诉等操作仍未接入移动端;当前只读 + 取消待支付订单。
|
||||
2. 聊天使用 6 秒轮询,尚未接入 `/api/chats/events` SSE;消息量增大后需要升级。
|
||||
3. 聊天暂不支持发送图片/附件,仅展示已存在的图片消息。
|
||||
4. 订单列表接口暂不支持按状态筛选,移动端采用分页加载而非状态 Tab。
|
||||
5. 主包体积仍偏大。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
1. 移动端下单、支付与交接/归还/结账操作流。
|
||||
2. 聊天 SSE 与附件上传。
|
||||
3. 后台商品管理/审核,复用 DataTable 与 DetailDrawer。
|
||||
4. 用户管理列表与 `orders?user_id=` 深链。
|
||||
5. 分销后台品牌轻改与首包拆分。
|
||||
@@ -381,6 +381,8 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天;PC 端与下单/支付/交接/结账操作待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
- 卖家发布、编辑、提交审核。
|
||||
|
||||
@@ -27,9 +27,24 @@ const MobileProfilePage = lazy(() =>
|
||||
default: module.MobileProfilePage,
|
||||
}))
|
||||
)
|
||||
const MobilePlaceholderPage = lazy(() =>
|
||||
import('@/features/mobile/MobilePlaceholderPage').then(module => ({
|
||||
default: module.MobilePlaceholderPage,
|
||||
const MobileOrdersPage = lazy(() =>
|
||||
import('@/features/mobile/MobileOrdersPage').then(module => ({
|
||||
default: module.MobileOrdersPage,
|
||||
}))
|
||||
)
|
||||
const MobileOrderDetailPage = lazy(() =>
|
||||
import('@/features/mobile/MobileOrderDetailPage').then(module => ({
|
||||
default: module.MobileOrderDetailPage,
|
||||
}))
|
||||
)
|
||||
const MobileMessagesPage = lazy(() =>
|
||||
import('@/features/mobile/MobileMessagesPage').then(module => ({
|
||||
default: module.MobileMessagesPage,
|
||||
}))
|
||||
)
|
||||
const MobileChatPage = lazy(() =>
|
||||
import('@/features/mobile/MobileChatPage').then(module => ({
|
||||
default: module.MobileChatPage,
|
||||
}))
|
||||
)
|
||||
const AdminLoginPage = lazy(() =>
|
||||
@@ -91,24 +106,10 @@ export function AppRouter() {
|
||||
<Route index element={<MobileHomePage />} />
|
||||
<Route path="listings" element={<MobileHomePage />} />
|
||||
<Route path="listings/:id" element={<MobileListingDetailPage />} />
|
||||
<Route
|
||||
path="orders"
|
||||
element={
|
||||
<MobilePlaceholderPage
|
||||
title="订单中心"
|
||||
description="订单列表、交接、归还和结账会在下一批迁移到这里。"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="messages"
|
||||
element={
|
||||
<MobilePlaceholderPage
|
||||
title="消息中心"
|
||||
description="订单群聊、客服会话和系统通知会在下一批迁移到这里。"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="orders" element={<MobileOrdersPage />} />
|
||||
<Route path="orders/:id" element={<MobileOrderDetailPage />} />
|
||||
<Route path="messages" element={<MobileMessagesPage />} />
|
||||
<Route path="messages/:id" element={<MobileChatPage />} />
|
||||
<Route path="profile" element={<MobileProfilePage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -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 : '登录没有完成,请核对手机号和密码'
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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 '订单'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AppOutline, MessageOutline, UnorderedListOutline, UserOutline } from 'a
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useChatUnreadCount } from '@/features/messages/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const tabs = [
|
||||
@@ -25,10 +26,15 @@ export function MobileLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const hideTabbar = /^\/m\/listings\/[^/]+$/.test(location.pathname)
|
||||
const chatUnread = useChatUnreadCount(Boolean(user))
|
||||
const unreadBadge = chatUnread.data && chatUnread.data > 0 ? chatUnread.data : undefined
|
||||
const hideTabbar =
|
||||
/^\/m\/listings\/[^/]+$/.test(location.pathname) ||
|
||||
/^\/m\/orders\/[^/]+$/.test(location.pathname) ||
|
||||
/^\/m\/messages\/[^/]+$/.test(location.pathname)
|
||||
|
||||
return (
|
||||
<div className="mobile-shell">
|
||||
<div className={`mobile-shell${hideTabbar ? ' mobile-shell--no-tabbar' : ''}`}>
|
||||
<header className="mobile-header">
|
||||
<Link to="/m" className="mobile-header__brand" aria-label="解忧商行移动端首页">
|
||||
<BrandLogo height={30} />
|
||||
@@ -57,7 +63,12 @@ export function MobileLayout() {
|
||||
safeArea
|
||||
>
|
||||
{tabs.map(tab => (
|
||||
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
|
||||
<TabBar.Item
|
||||
key={tab.key}
|
||||
icon={tab.icon}
|
||||
title={tab.title}
|
||||
badge={tab.key === '/m/messages' ? unreadBadge : undefined}
|
||||
/>
|
||||
))}
|
||||
</TabBar>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,16 @@ export function formatDate(value?: string | null) {
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value?: string | null) {
|
||||
if (!value) return EMPTY
|
||||
const target = dayjs(value)
|
||||
const now = dayjs()
|
||||
if (target.isSame(now, 'day')) return target.format('HH:mm')
|
||||
if (target.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
|
||||
if (target.isSame(now, 'year')) return target.format('MM-DD HH:mm')
|
||||
return target.format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number | null) {
|
||||
if (value == null || Number.isNaN(value)) return EMPTY
|
||||
return value.toLocaleString('zh-CN')
|
||||
|
||||
@@ -1567,6 +1567,10 @@ a {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-shell--no-tabbar {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -2182,6 +2186,684 @@ a {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.mobile-orders {
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.mobile-orders__eyebrow {
|
||||
color: var(--brand-seal);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.mobile-orders__head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mobile-orders__head h1 {
|
||||
margin: 6px 0;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-orders__head p {
|
||||
margin: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-orders__login {
|
||||
padding: 26px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-orders__login h2 {
|
||||
margin: 14px 0 6px;
|
||||
color: var(--brand-ink);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mobile-orders__login p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-orders__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mobile-orders__skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-orders__empty {
|
||||
padding: 36px 0 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-orders__empty-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
color: var(--brand-ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-orders__more {
|
||||
padding: 6px 0 10px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-order-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px 16px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
box-shadow: 0 6px 18px rgba(30, 77, 66, 0.04);
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.mobile-order-card:active {
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.mobile-order-card--skeleton {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mobile-order-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-order-card__no {
|
||||
color: var(--ink-muted);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-order-card__title {
|
||||
margin: 10px 0 8px;
|
||||
color: var(--brand-ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mobile-order-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-order-card__meta span {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-order-card__bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.mobile-order-card__bottom strong {
|
||||
display: block;
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mobile-order-card__bottom small {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-order-card__date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-order-detail {
|
||||
padding: 16px 16px 36px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__back button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero {
|
||||
margin-top: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
background: var(--brand-ink);
|
||||
color: #f0f4f2;
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-order-detail__no {
|
||||
color: #b9cbc3;
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero h1 {
|
||||
margin: 10px 0 12px;
|
||||
color: #f6f2e8;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mobile-order-detail__status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #b9cbc3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(240, 244, 242, 0.16);
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero-amount strong {
|
||||
color: #e8c9a6;
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__hero-amount span {
|
||||
color: #b9cbc3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__list {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
--border-inner: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mobile-order-detail__list .adm-list-header {
|
||||
margin: 0;
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #faf6ec;
|
||||
color: var(--brand-ink);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.mobile-order-detail__list .adm-list-body {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mobile-order-detail__checkout-content {
|
||||
padding: 6px 0;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mobile-order-detail__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.mobile-order-detail__back-link {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-messages {
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.mobile-messages__eyebrow {
|
||||
color: var(--brand-seal);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.mobile-messages__head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-messages__head h1 {
|
||||
margin: 6px 0;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-messages__head p {
|
||||
margin: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-messages__tabs {
|
||||
--title-font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-messages__panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mobile-chat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-chat-row__avatar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--brand-seal-line);
|
||||
border-radius: 10px;
|
||||
background: #fbf6ec;
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-chat-row__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-chat-row__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-chat-row__top strong {
|
||||
overflow: hidden;
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-chat-row__top small {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-chat-row__preview {
|
||||
overflow: hidden;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-chat-row__tail {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mobile-notice-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-notice-row--unread {
|
||||
border-color: var(--brand-seal-line);
|
||||
background: #fffaf2;
|
||||
}
|
||||
|
||||
.mobile-notice-row__dot {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mobile-notice-row--unread .mobile-notice-row__dot {
|
||||
background: var(--brand-seal);
|
||||
}
|
||||
|
||||
.mobile-notice-row__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-notice-row__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-notice-row__top strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mobile-notice-row__top small {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-notice-row__content {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.mobile-messages__empty {
|
||||
padding: 30px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-messages__empty span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 58px);
|
||||
height: calc(100dvh - 58px);
|
||||
}
|
||||
|
||||
.mobile-chat__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.mobile-chat__back {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--brand-ink);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mobile-chat__title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-chat__title strong {
|
||||
overflow: hidden;
|
||||
color: var(--brand-ink);
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-chat__title span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-chat__head-space {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.mobile-chat__body {
|
||||
flex: 1;
|
||||
padding: 14px 14px 18px;
|
||||
overflow-y: auto;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.mobile-chat__earlier {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-chat__empty {
|
||||
padding: 40px 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-chat__date {
|
||||
width: fit-content;
|
||||
margin: 8px auto 12px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #ece7dc;
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-chat__system {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-chat__system span {
|
||||
max-width: 85%;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
background: #ece7dc;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mobile-chat__message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-chat__message--self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.mobile-chat__avatar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-ink);
|
||||
color: #f6f2e8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-chat__bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-width: 76%;
|
||||
}
|
||||
|
||||
.mobile-chat__message--self .mobile-chat__bubble-wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.mobile-chat__sender {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mobile-chat__bubble {
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-top-left-radius: 3px;
|
||||
background: var(--paper-warm);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mobile-chat__bubble p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mobile-chat__message--self .mobile-chat__bubble {
|
||||
border-color: var(--brand-ink);
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 3px;
|
||||
background: var(--brand-ink);
|
||||
color: #f4f7f5;
|
||||
}
|
||||
|
||||
.mobile-chat__time {
|
||||
color: var(--ink-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.mobile-chat__images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.mobile-chat__images img {
|
||||
max-width: 180px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mobile-chat__composer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.mobile-chat__composer .adm-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.mobile-chat__composer .adm-button {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mobile-shell {
|
||||
border-right: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user