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 (
+
+
+ } onClick={() => navigate('/messages')}>
+ 返回消息中心
+
+
+ 消息 / {title}
+
+
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+ {conversation.data?.latest_order_no && conversation.data?.type !== 'order_group' ? (
+ 订单 {conversation.data.latest_order_no}
+ ) : null}
+
+
+ {conversation.data?.type !== 'general_support' ? (
+
}
+ loading={openingSupport}
+ onClick={() => void openSupport()}
+ >
+ 需要客服介入
+
+ ) : 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 => (
+
+

+
+
+ ))}
+
{pendingImages.length}/9
+
+ ) : null}
+
+
+ {
+ void uploadOne(file)
+ return false
+ }}
+ >
+ } loading={uploading} disabled={pendingImages.length >= 9}>
+ 图片
+
+
+ setContent(event.target.value)}
+ onPressEnter={event => {
+ if (!event.shiftKey) {
+ event.preventDefault()
+ void onSend()
+ }
+ }}
+ />
+ }
+ loading={sending}
+ disabled={(!content.trim() && pendingImages.length === 0) || uploading}
+ onClick={() => 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 (
+
+
+
+
+
+ {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 => (
+
+

+
+
+ ))}
+
{pendingImages.length}/9
+
+ ) : null}
+
+
void onPickImages(event.target.files)}
+ />
+
0 ? '补充图片说明…' : '输入消息…'}
onChange={setContent}
onEnterPress={() => void onSend()}
/>