diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index e5de562..a17bf1b 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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` 已提交;移动订单与消息在后续批次提交。 --- diff --git a/docs/Phase2-移动端订单与消息验收.md b/docs/Phase2-移动端订单与消息验收.md new file mode 100644 index 0000000..d5dd53a --- /dev/null +++ b/docs/Phase2-移动端订单与消息验收.md @@ -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. 分销后台品牌轻改与首包拆分。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 5991543..6b0356d 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,6 +381,8 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天;PC 端与下单/支付/交接/结账操作待迁移。 + - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 - 卖家发布、编辑、提交审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index d2f9263..29b484b 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> - - } - /> - - } - /> + } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index fda0f31..a6731f4 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -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 + if (token) return 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 : '登录没有完成,请核对手机号和密码' diff --git a/frontend/src/features/messages/api.ts b/frontend/src/features/messages/api.ts new file mode 100644 index 0000000..72050d7 --- /dev/null +++ b/frontend/src/features/messages/api.ts @@ -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>>( + apiClient.get>>>('/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>>( + apiClient.get>>>('/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(apiClient.get>(`/chats/${id}`)), + enabled: Boolean(id) && enabled, + }) +} + +export function fetchChatMessages(id: number | string, page = 1) { + return unwrapData>>( + apiClient.get>>>(`/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>(`/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( + apiClient.post>('/chats/support', { scene: 'general' }) + ) +} + +export function useChatUnreadCount(enabled = true) { + return useQuery({ + queryKey: ['chat-unread-count'], + queryFn: () => + unwrapData<{ unread_count: number }>( + apiClient.get>( + '/chats/unread-count', + { skipErrorHandler: true } as never + ) + ) + .then(data => data.unread_count ?? 0) + .catch(() => 0), + enabled, + refetchInterval: 20_000, + }) +} diff --git a/frontend/src/features/messages/types.ts b/frontend/src/features/messages/types.ts new file mode 100644 index 0000000..3cbb159 --- /dev/null +++ b/frontend/src/features/messages/types.ts @@ -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 { + 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 +} diff --git a/frontend/src/features/mobile/MobileChatPage.tsx b/frontend/src/features/mobile/MobileChatPage.tsx new file mode 100644 index 0000000..4d2b398 --- /dev/null +++ b/frontend/src/features/mobile/MobileChatPage.tsx @@ -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 ( +
+ {message.content} +
+ ) + } + + return ( +
+ {!message.is_self ? ( + {(message.sender_name || '商').slice(0, 1)} + ) : null} +
+ {!message.is_self ? {message.sender_name} : null} +
+ {message.content_type === 'image' && message.attachment_urls?.length ? ( +
+ {message.attachment_urls.map(url => ( + 聊天图片 + ))} +
+ ) : null} + {message.content ?

{message.content}

: null} +
+ {messageTime(message.created_at)} +
+
+ ) +} + +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(null) + const lastMessageIdRef = useRef(null) + const lastRenderedIdRef = useRef(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 ( +
+ + +
+ ) + } + + if (!token) { + return ( +
+
+ +
+
+ + +
+
+ ) + } + + if (messagesQuery.isPending) { + return ( +
+ + +
+ ) + } + + if (messagesQuery.isError) { + return ( +
+
+ +
+ + + +
+ ) + } + + let previousDate = '' + + return ( +
+
+ +
+ {conversation.data?.title || '会话'} + + {conversation.data?.type === 'general_support' + ? '平台客服' + : conversation.data?.type === 'order_group' + ? `订单 ${conversation.data.latest_order_no || ''}`.trim() + : conversation.data?.type === 'listing_group' + ? '商品咨询群' + : '交易会话'} + +
+ +
+ +
+ {messagesQuery.hasNextPage ? ( +
+ +
+ ) : null} + + {messages.length === 0 ? ( +
还没有消息,打个招呼吧
+ ) : null} + + {messages.map(message => { + const label = dateLabel(message.created_at) + const showDate = label !== previousDate + previousDate = label + return ( +
+ {showDate ?
{label}
: null} + +
+ ) + })} +
+ +
+ void onSend()} + /> + +
+
+ ) +} diff --git a/frontend/src/features/mobile/MobileMessagesPage.tsx b/frontend/src/features/mobile/MobileMessagesPage.tsx new file mode 100644 index 0000000..0d64b0f --- /dev/null +++ b/frontend/src/features/mobile/MobileMessagesPage.tsx @@ -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 ( + + ) +} + +function NoticeRow({ item, onRead }: { item: AppNotification; onRead: (id: number) => void }) { + const unread = !item.read_at + + return ( + + ) +} + +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('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 ( +
+ + +
+ ) + } + + if (!token) { + return ( +
+
+ MESSAGES +

消息中心

+

登录后查看订单会话、客服回复与系统通知。

+
+
+ +

先登录,再看消息

+

订单群聊与客服会话只对交易双方开放。

+ +
+
+ ) + } + + return ( +
+
+ MESSAGES +

消息中心

+

订单会话、客服回复与系统通知都在这里。

+
+ + setTab(key as MessageTab)} + className="mobile-messages__tabs" + > + + + + + {tab === 'chats' ? ( +
+
+ +
+ + {chats.isPending ? ( + + ) : chats.isError ? ( + + + + ) : chatItems.length === 0 ? ( +
+ + 下单后会自动创建订单群聊,也可以先联系客服。 +
+ ) : ( +
+ {chatItems.map(item => ( + + ))} + {chats.hasNextPage ? ( + + ) : null} +
+ )} +
+ ) : ( +
+
+ {unreadNotices ? `${unreadNotices} 条未读` : '全部已读'} + {unreadNotices > 0 ? ( + + ) : null} +
+ + {notices.isPending ? ( + + ) : notices.isError ? ( + + + + ) : noticeItems.length === 0 ? ( +
+ +
+ ) : ( +
+ {noticeItems.map(item => ( + void readNotice(id)} /> + ))} + {notices.hasNextPage ? ( + + ) : null} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/features/mobile/MobileOrderDetailPage.tsx b/frontend/src/features/mobile/MobileOrderDetailPage.tsx new file mode 100644 index 0000000..b682bf5 --- /dev/null +++ b/frontend/src/features/mobile/MobileOrderDetailPage.tsx @@ -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 ( +
+ {label} + {children} +
+ ) +} + +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 ( +
+ + +
+ ) + } + + if (!token) { + return ( +
+
+ +
+
+ +

登录后查看订单详情

+

交接记录与结账明细只对交易双方可见。

+ +
+
+ ) + } + + if (query.isPending) { + return ( +
+ + +
+ ) + } + + if (query.isError || !order) { + return ( +
+
+ +
+ + + +
+ ) + } + + 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 ( +
+
+ +
+ +
+
+ {order.order_no} + + {status.label} + +
+

{order.title}

+
+ + + {handoff.label} + + + + + {settlement.label} + + +
+
+ {formatMoney(order.display_amount_cent)} + 押金 {formatMoney(order.deposit_amount_cent)} +
+
+ + + 商品编号 + 区服 + 登录平台 + + 预计租期 + + + + + 租客租金 + 号主租金 + 平台服务费 + 实收押金 + {order.deposit_waived_amount_cent > 0 ? ( + 押金减免 + ) : null} + {order.refund_amount_cent > 0 ? ( + 已退金额 + ) : null} + + + + 下单时间 + 起租时间 + 预计结束 + 支付截止 + + + {checkout ? ( + + {checkoutStatusMeta(checkout.status).label}}> + 结账状态 + + 协商轮次 + 消耗品费用 + 哈夫币消耗 + 其他费用 + 押金扣除 + 可退金额 + 号主到手 + {checkout.content ? ( + +
{checkout.content}
+
+ ) : null} +
+ ) : null} + + {order.active_dispute ? ( + + 申诉单号 + 类型 + 状态 + + ) : null} + +
+ {canCancel ? ( + + ) : null} + + 返回我的订单 + +
+
+ ) +} diff --git a/frontend/src/features/mobile/MobileOrdersPage.tsx b/frontend/src/features/mobile/MobileOrdersPage.tsx new file mode 100644 index 0000000..6efa78e --- /dev/null +++ b/frontend/src/features/mobile/MobileOrdersPage.tsx @@ -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 ( + + ) +} + +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 ( +
+ + +
+ ) + } + + if (!token) { + return ( +
+
+ MY ORDERS +

我的订单

+

登录后查看租用进度、交接与结账记录。

+
+
+ +

先登录,再看订单

+

订单、押金与结账明细只对交易双方可见。

+ +
+
+ ) + } + + return ( +
+
+ MY ORDERS +

我的订单

+

{query.data ? `共 ${total} 单 · 下拉可刷新` : '正在读取你的订单…'}

+
+ + {query.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) : query.isError ? ( + + + + ) : ( + query.refetch().then(() => undefined)}> +
+ {items.length === 0 ? ( +
+ + + 去逛逛商品 + +
+ ) : ( + items.map(item => ) + )} + + {items.length > 0 ? ( +
+ {query.hasNextPage ? ( + + ) : ( + 已经看到全部订单 + )} +
+ ) : null} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/orders/api.ts b/frontend/src/features/orders/api.ts new file mode 100644 index 0000000..979d920 --- /dev/null +++ b/frontend/src/features/orders/api.ts @@ -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>( + apiClient.get>>('/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(apiClient.get>(`/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`) +} diff --git a/frontend/src/features/orders/labels.ts b/frontend/src/features/orders/labels.ts new file mode 100644 index 0000000..2687a3d --- /dev/null +++ b/frontend/src/features/orders/labels.ts @@ -0,0 +1,88 @@ +export type MobileTone = 'default' | 'primary' | 'success' | 'warning' | 'danger' + +export interface MobileStatus { + label: string + tone: MobileTone +} + +const orderStatus: Record = { + 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 = { + 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 = { + 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 = { + submitted: { label: '等待确认', tone: 'warning' }, + countered: { label: '已修正', tone: 'warning' }, + accepted: { label: '已接受', tone: 'success' }, + disputed: { label: '争议中', tone: 'danger' }, + settled: { label: '仲裁结案', tone: 'success' }, +} + +function read(map: Record, 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 '订单' +} diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts new file mode 100644 index 0000000..30b4d3a --- /dev/null +++ b/frontend/src/features/orders/types.ts @@ -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 +} diff --git a/frontend/src/layouts/MobileLayout.tsx b/frontend/src/layouts/MobileLayout.tsx index 4253756..c4f3cbb 100644 --- a/frontend/src/layouts/MobileLayout.tsx +++ b/frontend/src/layouts/MobileLayout.tsx @@ -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 ( -
+
@@ -57,7 +63,12 @@ export function MobileLayout() { safeArea > {tabs.map(tab => ( - + ))}
diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts index ece36ea..9ee2820 100644 --- a/frontend/src/shared/utils/format.ts +++ b/frontend/src/shared/utils/format.ts @@ -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') diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 3bf825b..6132960 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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);