From f2552fe52302b681626d61361f0ee27b8a2689e9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 14:08:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(messages):=20=E6=96=B0=E5=A2=9E=20PC=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83=E4=B8=8E=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E7=BE=A4=E8=81=8A=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-文件上传与消息中心验收.md | 77 ++++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 8 + frontend/src/features/messages/ChatPage.tsx | 373 +++++++++++++++ .../src/features/messages/MessagesPage.tsx | 200 ++++++++ frontend/src/features/messages/api.ts | 13 +- .../src/features/mobile/MobileChatPage.tsx | 89 +++- .../features/mobile/MobileOrderDetailPage.tsx | 31 +- .../src/features/orders/OrderDetailPage.tsx | 31 +- frontend/src/layouts/StorefrontLayout.tsx | 14 +- frontend/src/styles/global.css | 1 + frontend/src/styles/pc-messages.css | 426 ++++++++++++++++++ frontend/src/styles/storefront-listings.css | 4 + 13 files changed, 1258 insertions(+), 11 deletions(-) create mode 100644 docs/Phase2-文件上传与消息中心验收.md create mode 100644 frontend/src/features/messages/ChatPage.tsx create mode 100644 frontend/src/features/messages/MessagesPage.tsx create mode 100644 frontend/src/styles/pc-messages.css diff --git a/docs/Phase2-文件上传与消息中心验收.md b/docs/Phase2-文件上传与消息中心验收.md new file mode 100644 index 0000000..62fd011 --- /dev/null +++ b/docs/Phase2-文件上传与消息中心验收.md @@ -0,0 +1,77 @@ +# Phase 2 文件上传与消息中心验收 + +> 日期:2026-10-30 +> 范围:通用文件上传、订单证据图片、移动端聊天图片、订单群聊入口、PC 消息中心与 PC 聊天页。 + +## 1. 交付内容 + +### 1.1 通用文件上传 + +- 新增 `frontend/src/shared/api/files.ts` + - `uploadFile(file, scene)`:multipart 字段 `file` + `scene`,调用 `POST /api/files/upload`。 + - `isImageFileURL(url)`:识别站内对象地址与常见图片扩展名。 +- 新增 `frontend/src/shared/utils/imageUpload.ts` + - 上传前按场景限制最长边并转 WebP;透明图保留 alpha。 + - 尺寸合适且小于 1.5MB 时保留原图,减少二次压缩。 +- 上传限制与后端保持一致:单文件 10MB,聊天场景仅图片。 + +### 1.2 订单证据上传 + +- 新增 `frontend/src/components/EvidenceUpload.tsx` + - 支持多图上传、缩略图预览、单条删除、外部链接添加。 + - 图片走 `handoff` / `dispute` 场景,链接与上传图片统一写入 `evidence_urls`。 +- `OrderActionPanel` 的结账、还价、申诉弹层接入证据上传。 +- 保留原「外部证据链接(每行一条)」输入,兼容历史使用方式。 + +### 1.3 二次确认修复 + +- 新增 `frontend/src/shared/utils/confirm.ts` 的 `confirmModal()`。 +- 修复 AntD v5 `modal.confirm` 返回值不是 Promise 导致「取消仍继续执行」的问题。 +- 已替换确认收号、提交结账、确认结账、同意结账、发起申诉、取消申诉、取消订单共 7 处确认。 + +### 1.4 聊天图片与订单群聊 + +- `sendChatMessage` 支持第三个参数 `attachmentURLs`,请求体写入 `attachment_urls`。 +- 移动端聊天页新增图片选择、上传、预览、移除,最多 9 张;纯图片消息也可发送。 +- 新增 `fetchOrderConversation(orderId)`:`GET /api/orders/:id/chat`。 +- 移动端订单详情新增「进入订单群聊」,跳转 `/m/messages/:id`。 +- PC 订单详情新增「进入订单群聊」,跳转 `/messages/:id`。 + +### 1.5 PC 消息中心 + +- 新增 `frontend/src/features/messages/MessagesPage.tsx` + - 会话列表、未读数、订单号、最近消息、分页加载。 + - 一键新建平台客服会话。 + - 未登录守卫与沟通建议侧栏。 +- 新增 `frontend/src/features/messages/ChatPage.tsx` + - 会话详情、消息分页、图片消息预览、图片上传、发送。 + - 已读上报、会话与未读数刷新。 +- 新增路由 `/messages`、`/messages/:id`。 +- 商城导航新增「消息」入口与未读角标,用户菜单新增「我的消息」。 + +### 1.6 样式维护 + +- 原 `global.css` 3793 行,按页面域拆分为 16 个文件: + - `base`、`storefront-*`、`auth`、`admin-*`、`mobile-*`、`pc-messages`、`components`。 +- `global.css` 仅保留 `@import` 汇总,导入顺序即原级联顺序。 +- 拆分前后内容 SHA-1 校验一致:`e438b4b54dac281fb683d4f5b1b64ae74103306e`。 + +## 2. 验证记录 + +| 检查 | 结果 | +|---|---| +| `cd frontend && npx tsc --noEmit` | 通过 | +| `cd frontend && npm run build` | 通过,主入口 516.17 kB / gzip 166.39 kB | +| `GET http://localhost:5289/messages` | 200 | +| `GET http://localhost:5289/messages/1` | 200 | +| `GET /api/chats?page=1&page_size=5` | 200,返回客服会话与商品群会话 | +| `GET /api/chats/2/messages` | 200,消息结构与附件字段正常 | +| `POST /api/files/upload`(scene=chat,PNG) | 201,返回 `object_key` 与 `/api/files/object?key=...` | +| `GET /api/orders/1/chat` | 404(开发库无订单 1,路由已注册) | + +## 3. 约束与后续 + +- 聊天仍为 6 秒轮询;后端 `GET /api/chats/events` 已存在,但浏览器 `EventSource` 无法附加 Bearer,需要改用 fetch 流或短期票据后再接。 +- PC 消息中心当前只有会话,不含通知 Tab;移动端已有「会话 / 通知」双 Tab。 +- 文件上传暂无删除接口,联调上传的测试对象会留在开发 MinIO 中。 +- 真实支付、交接、结账仍未回归:开发库无可用订单,且商品 1 的号主就是联调用户。 diff --git a/docs/项目计划.md b/docs/项目计划.md index b000619..87db7a3 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情、下单、订单中心与操作页已接通,移动端实名入口、订单群聊与证据上传待迁移。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传与订单群聊;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,移动端实名入口、卖家发布链路待迁移。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 37f7d89..05bd76f 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -19,6 +19,12 @@ const OrderListPage = lazy(() => const OrderDetailPage = lazy(() => import('@/features/orders/OrderDetailPage').then(module => ({ default: module.OrderDetailPage })) ) +const MessagesPage = lazy(() => + import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage })) +) +const ChatPage = lazy(() => + import('@/features/messages/ChatPage').then(module => ({ default: module.ChatPage })) +) const LoginPage = lazy(() => import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage })) ) @@ -157,6 +163,8 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/features/messages/ChatPage.tsx b/frontend/src/features/messages/ChatPage.tsx new file mode 100644 index 0000000..3a81b58 --- /dev/null +++ b/frontend/src/features/messages/ChatPage.tsx @@ -0,0 +1,373 @@ +import { useEffect, useRef, useState } from 'react' +import { + Alert, + App, + Avatar, + Button, + Card, + Image, + Input, + Skeleton, + Space, + Tag, + Typography, + Upload, +} from 'antd' +import { + ArrowLeftOutlined, + CloseOutlined, + CustomerServiceOutlined, + PictureOutlined, + SendOutlined, + UserOutlined, +} from '@ant-design/icons' +import { useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { Link, useNavigate, useParams } from 'react-router-dom' + +import { BrandLogo } from '@/components/BrandLogo' +import { + ensureSupportConversation, + markConversationRead, + sendChatMessage, + useChatMessages, + useConversation, +} from '@/features/messages/api' +import type { ChatMessage } from '@/features/messages/types' +import { useSessionStore } from '@/stores/session' +import { uploadFile } from '@/shared/api/files' + +interface PendingImage { + url: string + name: string +} + +function dateLabel(value: string) { + const target = dayjs(value) + const now = dayjs() + if (target.isSame(now, 'day')) return '今天' + if (target.isSame(now.subtract(1, 'day'), 'day')) return '昨天' + return target.format('MM月DD日') +} + +function MessageBubble({ message }: { message: ChatMessage }) { + if (message.sender_type === 'system' || message.content_type === 'system') { + return
{message.content}
+ } + + return ( +
+ {!message.is_self ? ( + } + style={{ background: message.sender_role === 'admin' ? '#b08a4a' : '#1e4d42' }} + /> + ) : null} +
+
+ {!message.is_self ? {message.sender_name} : null} + {dayjs(message.created_at).format('HH:mm')} + {message.sender_role === 'admin' ? 客服 : null} +
+
+ {message.attachment_urls?.length ? ( + +
+ {message.attachment_urls.map(url => ( + 聊天图片 + ))} +
+
+ ) : null} + {message.content ?

{message.content}

: null} +
+
+
+ ) +} + +export function ChatPage() { + const { id } = useParams() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { message } = App.useApp() + const token = useSessionStore(state => state.token) + const hydrated = useSessionStore(state => state.hydrated) + const conversation = useConversation(id, Boolean(token)) + const messagesQuery = useChatMessages(id, Boolean(token)) + const [content, setContent] = useState('') + const [pendingImages, setPendingImages] = useState([]) + const [uploading, setUploading] = useState(false) + const [sending, setSending] = useState(false) + const [openingSupport, setOpeningSupport] = useState(false) + const uploadingRef = useRef(0) + const scrollRef = useRef(null) + const lastReadRef = 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 = lastMessage?.id ?? null + if (!element || !lastId || lastRenderedIdRef.current === lastId) return + lastRenderedIdRef.current = lastId + element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }) + }, [lastMessage]) + + useEffect(() => { + if (!id || !lastMessage || lastReadRef.current === lastMessage.id) return + lastReadRef.current = lastMessage.id + markConversationRead(id) + .then(() => { + void queryClient.invalidateQueries({ queryKey: ['chat-conversations'] }) + void queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] }) + }) + .catch(() => undefined) + }, [id, lastMessage, queryClient]) + + const uploadOne = async (file: File) => { + if (pendingImages.length >= 9) { + message.warning('最多发送 9 张图片') + return + } + if (!file.type.startsWith('image/')) { + message.warning('聊天附件仅支持图片') + return + } + if (file.size > 10 * 1024 * 1024) { + message.warning('单张图片不能超过 10MB') + return + } + uploadingRef.current += 1 + setUploading(true) + try { + const uploaded = await uploadFile(file, 'chat') + setPendingImages(prev => { + if (prev.length >= 9) return prev + return [...prev, { url: uploaded.url, name: uploaded.filename }] + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '图片上传失败') + } finally { + uploadingRef.current -= 1 + if (uploadingRef.current === 0) setUploading(false) + } + } + + const openSupport = async () => { + if (openingSupport) return + setOpeningSupport(true) + try { + const support = await ensureSupportConversation() + navigate(`/messages/${support.id}`) + } catch { + // 全局响应拦截已提示,保留按钮可重试。 + } finally { + setOpeningSupport(false) + } + } + + const onSend = async () => { + const text = content.trim() + if (!id || (!text && pendingImages.length === 0) || sending || uploading) return + setSending(true) + try { + await sendChatMessage( + id, + text, + pendingImages.map(image => image.url) + ) + setContent('') + setPendingImages([]) + await messagesQuery.refetch() + await queryClient.invalidateQueries({ queryKey: ['chat-conversations'] }) + await queryClient.invalidateQueries({ queryKey: ['chat-unread-count'] }) + } finally { + setSending(false) + } + } + + if (!hydrated) { + return ( +
+ +
+ ) + } + + if (!token) { + return ( +
+ + + 登录后进入会话 + + 订单群聊与客服会话只对当前账号开放。 + + + +
+ ) + } + + const title = conversation.data?.title || '交易会话' + const subtitle = + conversation.data?.type === 'general_support' + ? '平台客服' + : conversation.data?.type === 'order_group' + ? `订单 ${conversation.data.latest_order_no || ''}`.trim() + : conversation.data?.type === 'listing_group' + ? '商品咨询群' + : '交易会话' + + let previousDate = '' + + return ( +
+
+ + + 消息 / {title} + +
+ + +
+
+ {title} + + + {subtitle} + + {conversation.data?.latest_order_no && conversation.data?.type !== 'order_group' ? ( + 订单 {conversation.data.latest_order_no} + ) : null} + +
+ {conversation.data?.type !== 'general_support' ? ( + + ) : null} +
+ +
+ {messagesQuery.hasNextPage ? ( +
+ +
+ ) : null} + + {messagesQuery.isPending ? ( + + ) : messagesQuery.isError ? ( + void messagesQuery.refetch()}> + 重试 + + } + /> + ) : messages.length === 0 ? ( +
还没有消息,先打个招呼吧。
+ ) : ( + messages.map(message => { + const label = dateLabel(message.created_at) + const showDate = label !== previousDate + previousDate = label + return ( +
+ {showDate ?
{label}
: null} + +
+ ) + }) + )} +
+ + {pendingImages.length > 0 ? ( +
+ {pendingImages.map(image => ( +
+ {image.name} + +
+ ))} + {pendingImages.length}/9 +
+ ) : null} + +
+ { + void uploadOne(file) + return false + }} + > + + + setContent(event.target.value)} + onPressEnter={event => { + if (!event.shiftKey) { + event.preventDefault() + void onSend() + } + }} + /> + +
+
+
+ ) +} diff --git a/frontend/src/features/messages/MessagesPage.tsx b/frontend/src/features/messages/MessagesPage.tsx new file mode 100644 index 0000000..93801b2 --- /dev/null +++ b/frontend/src/features/messages/MessagesPage.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react' +import { + Alert, + Avatar, + Badge, + Button, + Card, + Empty, + Skeleton, + Space, + Tag, + Typography, +} from 'antd' +import { ArrowRightOutlined, CustomerServiceOutlined, MessageOutlined } from '@ant-design/icons' +import { Link, useNavigate } from 'react-router-dom' + +import { BrandLogo } from '@/components/BrandLogo' +import { ensureSupportConversation, useConversations } from '@/features/messages/api' +import type { ChatConversation } from '@/features/messages/types' +import { useSessionStore } from '@/stores/session' +import { formatRelativeTime } from '@/shared/utils/format' + +function conversationMeta(type: string) { + if (type === 'general_support') return { label: '平台客服', color: 'gold' } + if (type === 'order_group') return { label: '订单群聊', color: 'green' } + if (type === 'listing_group') return { label: '商品咨询', color: 'blue' } + return { label: '交易会话', color: 'default' } +} + +function ConversationRow({ item }: { item: ChatConversation }) { + const meta = conversationMeta(item.type) + return ( + + } + style={{ background: item.type === 'general_support' ? '#b08a4a' : '#1e4d42' }} + /> +
+
+ {item.title || '未命名会话'} + {formatRelativeTime(item.last_message_at)} +
+
+ + {meta.label} + + {item.latest_order_no ? 订单 {item.latest_order_no} : null} +
+

{item.last_message_preview || '还没有消息,进入会话开始沟通。'}

+
+
+ {item.unread_count > 0 ? : null} + +
+ + ) +} + +export function MessagesPage() { + const navigate = useNavigate() + const token = useSessionStore(state => state.token) + const hydrated = useSessionStore(state => state.hydrated) + const [openingSupport, setOpeningSupport] = useState(false) + const chats = useConversations(Boolean(token)) + const items = chats.data?.pages.flatMap(page => page.items) ?? [] + const unreadTotal = items.reduce((sum, item) => sum + item.unread_count, 0) + + const openSupport = async () => { + setOpeningSupport(true) + try { + const conversation = await ensureSupportConversation() + navigate(`/messages/${conversation.id}`) + } catch { + // 全局响应拦截已提示,这里保留按钮可重试状态。 + } finally { + setOpeningSupport(false) + } + } + + if (!hydrated) { + return ( +
+ +
+ ) + } + + if (!token) { + return ( +
+ + + 登录后查看消息 + + 订单群聊、客服回复与系统通知只对当前账号开放。 + + + +
+ ) + } + + return ( +
+
+
+ MESSAGES + + 消息中心 + + + 订单群聊会随下单自动建立,交接、结账与申诉都建议在群内留痕。 + +
+ + {unreadTotal > 0 ? {unreadTotal} 条未读 : 全部已读} + + +
+ +
+
+ {items.length} 个 : null} + className="pc-messages-card" + > + {chats.isPending ? ( + + ) : chats.isError ? ( + void chats.refetch()}> + 重试 + + } + /> + ) : items.length === 0 ? ( + + + + ) : ( +
+ {items.map(item => ( + + ))} + {chats.hasNextPage ? ( + + ) : null} +
+ )} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/messages/api.ts b/frontend/src/features/messages/api.ts index 72050d7..1e46c8e 100644 --- a/frontend/src/features/messages/api.ts +++ b/frontend/src/features/messages/api.ts @@ -88,13 +88,24 @@ export function useChatMessages(id?: string, enabled = true) { }) } -export async function sendChatMessage(id: number | string, content: string) { +export async function sendChatMessage( + id: number | string, + content: string, + attachmentURLs: string[] = [] +) { const { data } = await apiClient.post>(`/chats/${id}/messages`, { content, + attachment_urls: attachmentURLs, }) return data.data } +export function fetchOrderConversation(orderId: number | string) { + return unwrapData( + apiClient.get>(`/orders/${orderId}/chat`) + ) +} + export async function markConversationRead(id: number | string) { await apiClient.post(`/chats/${id}/read`) } diff --git a/frontend/src/features/mobile/MobileChatPage.tsx b/frontend/src/features/mobile/MobileChatPage.tsx index 4d2b398..80fa5c6 100644 --- a/frontend/src/features/mobile/MobileChatPage.tsx +++ b/frontend/src/features/mobile/MobileChatPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Button, ErrorBlock, Input, Skeleton, Toast } from 'antd-mobile' -import { LeftOutline, SendOutline } from 'antd-mobile-icons' +import { CloseOutline, LeftOutline, PictureOutline, SendOutline } from 'antd-mobile-icons' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { useNavigate, useParams } from 'react-router-dom' @@ -12,6 +12,7 @@ import { useConversation, } from '@/features/messages/api' import type { ChatMessage } from '@/features/messages/types' +import { uploadFile } from '@/shared/api/files' import { useSessionStore } from '@/stores/session' function messageTime(value: string) { @@ -67,7 +68,10 @@ export function MobileChatPage() { const conversation = useConversation(id, Boolean(token)) const messagesQuery = useChatMessages(id, Boolean(token)) const [content, setContent] = useState('') + const [pendingImages, setPendingImages] = useState<{ url: string; name: string }[]>([]) + const [uploading, setUploading] = useState(false) const [sending, setSending] = useState(false) + const fileInputRef = useRef(null) const scrollRef = useRef(null) const lastMessageIdRef = useRef(null) const lastRenderedIdRef = useRef(null) @@ -99,13 +103,51 @@ export function MobileChatPage() { .catch(() => undefined) }, [id, lastMessage, queryClient]) + const onPickImages = async (files: FileList | null) => { + if (!files?.length) return + const remaining = 9 - pendingImages.length + if (remaining <= 0) { + Toast.show({ content: '最多发送 9 张图片' }) + return + } + const picked = Array.from(files) + .filter(file => file.type.startsWith('image/')) + .slice(0, remaining) + if (!picked.length) { + Toast.show({ content: '请选择图片文件' }) + return + } + setUploading(true) + try { + const uploaded: { url: string; name: string }[] = [] + for (const file of picked) { + const item = await uploadFile(file, 'chat') + uploaded.push({ url: item.url, name: item.filename }) + } + setPendingImages(prev => [...prev, ...uploaded]) + } catch (error) { + Toast.show({ + icon: 'fail', + content: error instanceof Error ? error.message : '图片上传失败', + }) + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + const onSend = async () => { const text = content.trim() - if (!id || !text || sending) return + if (!id || (!text && pendingImages.length === 0) || sending || uploading) return setSending(true) try { - await sendChatMessage(id, text) + await sendChatMessage( + id, + text, + pendingImages.map(image => image.url) + ) setContent('') + setPendingImages([]) await messagesQuery.refetch() await queryClient.invalidateQueries({ queryKey: ['chat-conversations'] }) } catch (error) { @@ -229,17 +271,54 @@ export function MobileChatPage() { })} + {pendingImages.length > 0 ? ( +
+ {pendingImages.map(image => ( +
+ {image.name} + +
+ ))} + {pendingImages.length}/9 +
+ ) : null} +
+ void onPickImages(event.target.files)} + /> + 0 ? '补充图片说明…' : '输入消息…'} onChange={setContent} onEnterPress={() => void onSend()} /> { diff --git a/frontend/src/features/orders/OrderDetailPage.tsx b/frontend/src/features/orders/OrderDetailPage.tsx index d572538..a0a13b6 100644 --- a/frontend/src/features/orders/OrderDetailPage.tsx +++ b/frontend/src/features/orders/OrderDetailPage.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' import { Alert, + App, Breadcrumb, Button, Card, @@ -11,13 +13,14 @@ import { Tag, Typography, } from 'antd' -import { ArrowLeftOutlined } from '@ant-design/icons' +import { ArrowLeftOutlined, MessageOutlined } from '@ant-design/icons' import { useQueryClient } from '@tanstack/react-query' import { Link, useNavigate, useParams } from 'react-router-dom' import { BrandLogo } from '@/components/BrandLogo' import { OrderActionPanel } from '@/features/orders/OrderActionPanel' import { useHandoffRecords, useMyOrder } from '@/features/orders/api' +import { fetchOrderConversation } from '@/features/messages/api' import { checkoutStatusMeta, handoffStatusMeta, @@ -38,12 +41,27 @@ export function OrderDetailPage() { const { id } = useParams() const navigate = useNavigate() const queryClient = useQueryClient() + const { message } = App.useApp() const token = useSessionStore(state => state.token) const hydrated = useSessionStore(state => state.hydrated) const query = useMyOrder(id, Boolean(token)) const handoffQuery = useHandoffRecords(id, Boolean(token)) const order = query.data const records = handoffQuery.data ?? [] + const [openingChat, setOpeningChat] = useState(false) + + const openOrderChat = async () => { + if (!order || openingChat) return + setOpeningChat(true) + try { + const conversation = await fetchOrderConversation(order.id) + navigate(`/messages/${conversation.id}`) + } catch (error) { + message.error(error instanceof Error ? error.message : '订单群聊暂时无法打开') + } finally { + setOpeningChat(false) + } + } if (!hydrated) { return ( @@ -294,6 +312,17 @@ export function OrderDetailPage() {
+ { diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index 50153c5..fc45379 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -1,8 +1,9 @@ -import { Button, Dropdown, Layout, Space, Typography } from 'antd' +import { Badge, Button, Dropdown, Layout, Space, Typography } from 'antd' import { Link, Outlet, useNavigate } from 'react-router-dom' import { UserOutlined } from '@ant-design/icons' import { BrandLogo } from '@/components/BrandLogo' +import { useChatUnreadCount } from '@/features/messages/api' import { useSessionStore } from '@/stores/session' const { Header, Content, Footer } = Layout @@ -11,6 +12,7 @@ export function StorefrontLayout() { const navigate = useNavigate() const user = useSessionStore(state => state.user) const logout = useSessionStore(state => state.logout) + const unreadMessages = useChatUnreadCount(Boolean(user)).data ?? 0 return ( @@ -24,6 +26,11 @@ export function StorefrontLayout() { 首页 商品 我的订单 + + + 消息 + + 流程 规则 @@ -39,6 +46,11 @@ export function StorefrontLayout() { label: '我的订单', onClick: () => navigate('/orders'), }, + { + key: 'messages', + label: '我的消息', + onClick: () => navigate('/messages'), + }, { key: 'logout', label: '退出登录', diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index d53e2d7..724e211 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -14,4 +14,5 @@ @import './mobile-order-detail.css'; @import './mobile-messages.css'; @import './mobile-chat.css'; +@import './pc-messages.css'; @import './components.css'; diff --git a/frontend/src/styles/pc-messages.css b/frontend/src/styles/pc-messages.css new file mode 100644 index 0000000..1fda919 --- /dev/null +++ b/frontend/src/styles/pc-messages.css @@ -0,0 +1,426 @@ +.pc-messages-page { + max-width: 1180px; + margin: 0 auto; + padding: 42px 24px 64px; +} + +.pc-messages-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.pc-messages-hero__eyebrow { + color: var(--brand-seal); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; +} + +.pc-messages-hero h2 { + margin: 6px 0 8px !important; +} + +.pc-messages-hero .ant-typography { + max-width: 640px; + margin-bottom: 0; +} + +.pc-messages-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 20px; + align-items: start; +} + +.pc-messages-card .ant-card-body { + padding: 0; +} + +.pc-chat-list { + display: flex; + flex-direction: column; +} + +.pc-chat-row { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 20px; + border-bottom: 1px solid var(--line); + transition: background 0.2s ease; +} + +.pc-chat-row:last-child { + border-bottom: 0; +} + +.pc-chat-row:hover { + background: var(--paper-warm); +} + +.pc-chat-row__avatar { + flex: 0 0 auto; +} + +.pc-chat-row__body { + flex: 1; + min-width: 0; +} + +.pc-chat-row__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pc-chat-row__top strong { + overflow: hidden; + color: var(--ink); + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc-chat-row__top span { + flex: 0 0 auto; + color: var(--ink-muted); + font-size: 12px; +} + +.pc-chat-row__meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + color: var(--ink-muted); + font-size: 12px; +} + +.pc-chat-row__meta .ant-tag { + margin-inline-end: 0; +} + +.pc-chat-row__body p { + display: -webkit-box; + overflow: hidden; + margin: 6px 0 0; + color: var(--ink-secondary); + font-size: 13px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; +} + +.pc-chat-row__tail { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 10px; + color: var(--ink-muted); +} + +.pc-messages-side { + display: flex; + flex-direction: column; + gap: 16px; +} + +.pc-messages-side__card .ant-card-body { + padding: 18px 20px; +} + +.pc-messages-side__card ul { + margin: 0; + padding-left: 18px; + color: var(--ink-secondary); + font-size: 13px; + line-height: 1.9; +} + +.pc-messages-side__card .ant-typography { + font-size: 13px; +} + +.pc-messages-login { + max-width: 520px; + margin: 72px auto; + text-align: center; +} + +.pc-messages-login .ant-card-body { + padding: 44px 36px; +} + +.pc-messages-login .ant-typography { + margin-top: 18px; +} + +.pc-chat-page { + max-width: 1080px; + margin: 0 auto; + padding: 28px 24px 60px; +} + +.pc-chat-page__crumb { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; + color: var(--ink-muted); + font-size: 13px; +} + +.pc-chat-page__crumb .ant-btn { + padding-left: 0; +} + +.pc-chat-card .ant-card-body { + display: flex; + flex-direction: column; + min-height: 600px; + padding: 0; +} + +.pc-chat-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} + +.pc-chat-card__head h3 { + margin: 0 0 8px !important; +} + +.pc-chat-body { + flex: 1; + min-height: 380px; + max-height: calc(100vh - 420px); + overflow-y: auto; + padding: 20px 24px; + background: #fbf9f3; +} + +.pc-chat-earlier { + display: flex; + justify-content: center; + padding-bottom: 12px; +} + +.pc-chat-empty { + display: grid; + place-items: center; + min-height: 240px; + color: var(--ink-muted); + font-size: 13px; +} + +.pc-chat-date { + display: flex; + justify-content: center; + margin: 18px 0 14px; + color: var(--ink-muted); + font-size: 11px; +} + +.pc-chat-system { + display: flex; + justify-content: center; + margin: 14px 0; + color: var(--ink-muted); + font-size: 12px; +} + +.pc-chat-message { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 16px 0; +} + +.pc-chat-message--self { + flex-direction: row-reverse; +} + +.pc-chat-message__avatar { + flex: 0 0 auto; + margin-top: 2px; +} + +.pc-chat-message__body { + max-width: 72%; +} + +.pc-chat-message--self .pc-chat-message__body { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.pc-chat-message__meta { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 5px; + color: var(--ink-muted); + font-size: 12px; +} + +.pc-chat-message__meta strong { + color: var(--ink-secondary); + font-weight: 600; +} + +.pc-chat-message__meta .ant-tag { + margin-inline-end: 0; +} + +.pc-chat-message__bubble { + padding: 11px 14px; + border: 1px solid var(--line); + border-radius: 4px 14px 14px; + background: var(--paper-warm); + box-shadow: 0 1px 0 rgba(29, 36, 33, 0.03); +} + +.pc-chat-message--self .pc-chat-message__bubble { + border-color: transparent; + border-radius: 14px 4px 14px 14px; + background: var(--brand-ink); + color: #fff; +} + +.pc-chat-message__bubble p { + margin: 0; + font-size: 14px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; +} + +.pc-chat-message__images { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 8px; +} + +.pc-chat-message__images .ant-image, +.pc-chat-message__images img { + border-radius: 8px; +} + +.pc-chat-pending { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + overflow-x: auto; + border-top: 1px solid var(--line); + background: var(--paper-warm); +} + +.pc-chat-pending__item { + position: relative; + flex: 0 0 62px; + width: 62px; + height: 62px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 10px; +} + +.pc-chat-pending__item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.pc-chat-pending__item button { + position: absolute; + top: 0; + right: 0; + width: 20px; + height: 20px; + display: grid; + place-items: center; + border: 0; + border-radius: 0 0 0 8px; + background: rgba(20, 26, 23, 0.62); + color: #fff; + font-size: 11px; + cursor: pointer; +} + +.pc-chat-pending > span { + margin-left: auto; + color: var(--ink-muted); + font-size: 12px; +} + +.pc-chat-composer { + display: flex; + align-items: flex-end; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid var(--line); + background: var(--paper-warm); +} + +.pc-chat-composer .ant-input { + flex: 1; +} + +@media (max-width: 900px) { + .pc-messages-grid { + grid-template-columns: minmax(0, 1fr); + } + + .pc-messages-side { + flex-direction: row; + flex-wrap: wrap; + } + + .pc-messages-side__card { + flex: 1 1 280px; + } +} + +@media (max-width: 767px) { + .pc-messages-page, + .pc-chat-page { + padding: 24px 14px 40px; + } + + .pc-messages-hero { + align-items: flex-start; + flex-direction: column; + gap: 14px; + } + + .pc-chat-body { + max-height: calc(100vh - 360px); + padding: 16px; + } + + .pc-chat-message__body { + max-width: 84%; + } + + .pc-chat-composer { + flex-wrap: wrap; + } + + .pc-chat-composer .ant-input { + order: 3; + width: 100%; + flex-basis: 100%; + } +} diff --git a/frontend/src/styles/storefront-listings.css b/frontend/src/styles/storefront-listings.css index a3cec2d..07a96fa 100644 --- a/frontend/src/styles/storefront-listings.css +++ b/frontend/src/styles/storefront-listings.css @@ -559,6 +559,10 @@ top: 84px; } +.pc-order-chat-button { + margin-bottom: 16px; +} + .pc-order-action-card { border-color: var(--line); background: var(--paper-warm);