feat(messages): 新增 PC 消息中心与订单群聊入口
This commit is contained in:
@@ -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 的号主就是联调用户。
|
||||
+1
-1
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情、下单、订单中心与操作页已接通,移动端实名入口、订单群聊与证据上传待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传与订单群聊;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,移动端实名入口、卖家发布链路待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -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() {
|
||||
<Route path="listings/:id" element={<ListingDetailPage />} />
|
||||
<Route path="orders" element={<OrderListPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="messages" element={<MessagesPage />} />
|
||||
<Route path="messages/:id" element={<ChatPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -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 <div className="pc-chat-system">{message.content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`pc-chat-message${message.is_self ? ' pc-chat-message--self' : ''}`}>
|
||||
{!message.is_self ? (
|
||||
<Avatar
|
||||
size={38}
|
||||
className="pc-chat-message__avatar"
|
||||
icon={<UserOutlined />}
|
||||
style={{ background: message.sender_role === 'admin' ? '#b08a4a' : '#1e4d42' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="pc-chat-message__body">
|
||||
<div className="pc-chat-message__meta">
|
||||
{!message.is_self ? <strong>{message.sender_name}</strong> : null}
|
||||
<span>{dayjs(message.created_at).format('HH:mm')}</span>
|
||||
{message.sender_role === 'admin' ? <Tag color="gold">客服</Tag> : null}
|
||||
</div>
|
||||
<div className="pc-chat-message__bubble">
|
||||
{message.attachment_urls?.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<div className="pc-chat-message__images">
|
||||
{message.attachment_urls.map(url => (
|
||||
<Image src={url} alt="聊天图片" key={url} width={168} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
) : null}
|
||||
{message.content ? <p>{message.content}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<PendingImage[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [openingSupport, setOpeningSupport] = useState(false)
|
||||
const uploadingRef = useRef(0)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const lastReadRef = useRef<number | null>(null)
|
||||
const lastRenderedIdRef = useRef<number | null>(null)
|
||||
|
||||
const pages = messagesQuery.data?.pages ?? []
|
||||
const messages = pages
|
||||
.slice()
|
||||
.reverse()
|
||||
.flatMap(page => page.items)
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current
|
||||
const lastId = 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 (
|
||||
<div className="pc-chat-page">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pc-chat-page">
|
||||
<Card className="pc-messages-login">
|
||||
<BrandLogo height={48} variant="seal" />
|
||||
<Typography.Title level={3}>登录后进入会话</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单群聊与客服会话只对当前账号开放。
|
||||
</Typography.Paragraph>
|
||||
<Button type="primary" onClick={() => navigate(`/login?redirect=/messages/${id}`)}>
|
||||
去登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="pc-chat-page">
|
||||
<div className="pc-chat-page__crumb">
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/messages')}>
|
||||
返回消息中心
|
||||
</Button>
|
||||
<span>
|
||||
<Link to="/messages">消息</Link> / {title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card className="pc-chat-card">
|
||||
<div className="pc-chat-card__head">
|
||||
<div>
|
||||
<Typography.Title level={3}>{title}</Typography.Title>
|
||||
<Space size={6} wrap>
|
||||
<Tag color={conversation.data?.type === 'general_support' ? 'gold' : 'green'}>
|
||||
{subtitle}
|
||||
</Tag>
|
||||
{conversation.data?.latest_order_no && conversation.data?.type !== 'order_group' ? (
|
||||
<Tag>订单 {conversation.data.latest_order_no}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
{conversation.data?.type !== 'general_support' ? (
|
||||
<Button
|
||||
icon={<CustomerServiceOutlined />}
|
||||
loading={openingSupport}
|
||||
onClick={() => void openSupport()}
|
||||
>
|
||||
需要客服介入
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pc-chat-body" ref={scrollRef}>
|
||||
{messagesQuery.hasNextPage ? (
|
||||
<div className="pc-chat-earlier">
|
||||
<Button
|
||||
size="small"
|
||||
loading={messagesQuery.isFetchingNextPage}
|
||||
onClick={() => void messagesQuery.fetchNextPage()}
|
||||
>
|
||||
查看更早消息
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messagesQuery.isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : messagesQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="会话没有加载出来"
|
||||
action={
|
||||
<Button size="small" onClick={() => void messagesQuery.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="pc-chat-empty">还没有消息,先打个招呼吧。</div>
|
||||
) : (
|
||||
messages.map(message => {
|
||||
const label = dateLabel(message.created_at)
|
||||
const showDate = label !== previousDate
|
||||
previousDate = label
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{showDate ? <div className="pc-chat-date">{label}</div> : null}
|
||||
<MessageBubble message={message} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingImages.length > 0 ? (
|
||||
<div className="pc-chat-pending">
|
||||
{pendingImages.map(image => (
|
||||
<div className="pc-chat-pending__item" key={image.url}>
|
||||
<img src={image.url} alt={image.name} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除图片"
|
||||
onClick={() =>
|
||||
setPendingImages(prev => prev.filter(item => item.url !== image.url))
|
||||
}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span>{pendingImages.length}/9</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="pc-chat-composer">
|
||||
<Upload
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={file => {
|
||||
void uploadOne(file)
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<Button icon={<PictureOutlined />} loading={uploading} disabled={pendingImages.length >= 9}>
|
||||
图片
|
||||
</Button>
|
||||
</Upload>
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
maxLength={1000}
|
||||
placeholder="输入消息,Shift + Enter 换行"
|
||||
onChange={event => setContent(event.target.value)}
|
||||
onPressEnter={event => {
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void onSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sending}
|
||||
disabled={(!content.trim() && pendingImages.length === 0) || uploading}
|
||||
onClick={() => void onSend()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Link to={`/messages/${item.id}`} className="pc-chat-row">
|
||||
<Avatar
|
||||
size={46}
|
||||
className="pc-chat-row__avatar"
|
||||
icon={<MessageOutlined />}
|
||||
style={{ background: item.type === 'general_support' ? '#b08a4a' : '#1e4d42' }}
|
||||
/>
|
||||
<div className="pc-chat-row__body">
|
||||
<div className="pc-chat-row__top">
|
||||
<strong>{item.title || '未命名会话'}</strong>
|
||||
<span>{formatRelativeTime(item.last_message_at)}</span>
|
||||
</div>
|
||||
<div className="pc-chat-row__meta">
|
||||
<Tag color={meta.color} bordered={false}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
{item.latest_order_no ? <span>订单 {item.latest_order_no}</span> : null}
|
||||
</div>
|
||||
<p>{item.last_message_preview || '还没有消息,进入会话开始沟通。'}</p>
|
||||
</div>
|
||||
<div className="pc-chat-row__tail">
|
||||
{item.unread_count > 0 ? <Badge count={item.unread_count} /> : null}
|
||||
<ArrowRightOutlined />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="pc-messages-page">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pc-messages-page">
|
||||
<Card className="pc-messages-login">
|
||||
<BrandLogo height={48} variant="seal" />
|
||||
<Typography.Title level={3}>登录后查看消息</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单群聊、客服回复与系统通知只对当前账号开放。
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate('/login?redirect=/messages')}
|
||||
>
|
||||
去登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pc-messages-page">
|
||||
<header className="pc-messages-hero">
|
||||
<div>
|
||||
<span className="pc-messages-hero__eyebrow">MESSAGES</span>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
消息中心
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单群聊会随下单自动建立,交接、结账与申诉都建议在群内留痕。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
{unreadTotal > 0 ? <Tag color="red">{unreadTotal} 条未读</Tag> : <Tag>全部已读</Tag>}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CustomerServiceOutlined />}
|
||||
loading={openingSupport}
|
||||
onClick={() => void openSupport()}
|
||||
>
|
||||
联系平台客服
|
||||
</Button>
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
<div className="pc-messages-grid">
|
||||
<main>
|
||||
<Card
|
||||
title="全部会话"
|
||||
extra={items.length ? <Typography.Text type="secondary">{items.length} 个</Typography.Text> : null}
|
||||
className="pc-messages-card"
|
||||
>
|
||||
{chats.isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : chats.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="会话没有加载出来"
|
||||
action={
|
||||
<Button size="small" onClick={() => void chats.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Empty description="还没有会话">
|
||||
<Button type="primary" loading={openingSupport} onClick={() => void openSupport()}>
|
||||
联系平台客服
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="pc-chat-list">
|
||||
{items.map(item => (
|
||||
<ConversationRow item={item} key={item.id} />
|
||||
))}
|
||||
{chats.hasNextPage ? (
|
||||
<Button
|
||||
block
|
||||
loading={chats.isFetchingNextPage}
|
||||
onClick={() => void chats.fetchNextPage()}
|
||||
>
|
||||
加载更多会话
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
<aside className="pc-messages-side">
|
||||
<Card title="沟通建议" className="pc-messages-side__card">
|
||||
<ul>
|
||||
<li>交接说明写清账号、密码、区服与可登录时间。</li>
|
||||
<li>结账前先在群内确认消耗品与押金扣除依据。</li>
|
||||
<li>图片证据可直接上传,外部截图请保证可长期访问。</li>
|
||||
</ul>
|
||||
</Card>
|
||||
<Card title="客服范围" className="pc-messages-side__card">
|
||||
<Typography.Paragraph type="secondary">
|
||||
账号异常、支付问题、申诉进度与退款审核都可以联系平台客服。
|
||||
</Typography.Paragraph>
|
||||
<Button block icon={<CustomerServiceOutlined />} onClick={() => void openSupport()}>
|
||||
新建客服会话
|
||||
</Button>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, {
|
||||
content,
|
||||
attachment_urls: attachmentURLs,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export function fetchOrderConversation(orderId: number | string) {
|
||||
return unwrapData<ChatConversation>(
|
||||
apiClient.get<ApiResponse<ChatConversation>>(`/orders/${orderId}/chat`)
|
||||
)
|
||||
}
|
||||
|
||||
export async function markConversationRead(id: number | string) {
|
||||
await apiClient.post(`/chats/${id}/read`)
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const lastMessageIdRef = useRef<number | null>(null)
|
||||
const lastRenderedIdRef = useRef<number | null>(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() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pendingImages.length > 0 ? (
|
||||
<div className="mobile-chat__pending">
|
||||
{pendingImages.map(image => (
|
||||
<div className="mobile-chat__pending-item" key={image.url}>
|
||||
<img src={image.url} alt={image.name} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="移除图片"
|
||||
onClick={() =>
|
||||
setPendingImages(prev => prev.filter(item => item.url !== image.url))
|
||||
}
|
||||
>
|
||||
<CloseOutline />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span className="mobile-chat__pending-count">{pendingImages.length}/9</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mobile-chat__composer">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
hidden
|
||||
onChange={event => void onPickImages(event.target.files)}
|
||||
/>
|
||||
<Button
|
||||
fill="none"
|
||||
className="mobile-chat__attach"
|
||||
loading={uploading}
|
||||
disabled={sending || pendingImages.length >= 9}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<PictureOutline />
|
||||
</Button>
|
||||
<Input
|
||||
value={content}
|
||||
placeholder="输入消息…"
|
||||
placeholder={pendingImages.length > 0 ? '补充图片说明…' : '输入消息…'}
|
||||
onChange={setContent}
|
||||
onEnterPress={() => void onSend()}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
loading={sending}
|
||||
disabled={!content.trim()}
|
||||
disabled={(!content.trim() && pendingImages.length === 0) || uploading}
|
||||
onClick={() => void onSend()}
|
||||
>
|
||||
<SendOutline />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, ErrorBlock, List, Skeleton, Tag } from 'antd-mobile'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Button, ErrorBlock, List, Skeleton, Tag, Toast } from 'antd-mobile'
|
||||
import { LeftOutline } from 'antd-mobile-icons'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useHandoffRecords, useMyOrder } from '@/features/orders/api'
|
||||
import { fetchOrderConversation } from '@/features/messages/api'
|
||||
import { MobileOrderActionPanel } from '@/features/mobile/MobileOrderActionPanel'
|
||||
import {
|
||||
checkoutStatusMeta,
|
||||
@@ -42,6 +43,23 @@ export function MobileOrderDetailPage() {
|
||||
const handoffQuery = useHandoffRecords(id, Boolean(token))
|
||||
const order = query.data
|
||||
const handoffRecords = handoffQuery.data ?? []
|
||||
const [openingChat, setOpeningChat] = useState(false)
|
||||
|
||||
const openOrderChat = async () => {
|
||||
if (!order || openingChat) return
|
||||
setOpeningChat(true)
|
||||
try {
|
||||
const conversation = await fetchOrderConversation(order.id)
|
||||
navigate(`/m/messages/${conversation.id}`)
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error instanceof Error ? error.message : '订单群聊暂时无法打开',
|
||||
})
|
||||
} finally {
|
||||
setOpeningChat(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
@@ -229,6 +247,15 @@ export function MobileOrderDetailPage() {
|
||||
) : null}
|
||||
|
||||
<div className="mobile-order-detail__actions">
|
||||
<Button
|
||||
block
|
||||
fill="outline"
|
||||
color="primary"
|
||||
loading={openingChat}
|
||||
onClick={() => void openOrderChat()}
|
||||
>
|
||||
进入订单群聊
|
||||
</Button>
|
||||
<MobileOrderActionPanel
|
||||
order={order}
|
||||
onChanged={async () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="pc-order-detail-side">
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<MessageOutlined />}
|
||||
loading={openingChat}
|
||||
className="pc-order-chat-button"
|
||||
onClick={() => void openOrderChat()}
|
||||
>
|
||||
进入订单群聊
|
||||
</Button>
|
||||
<OrderActionPanel
|
||||
order={order}
|
||||
onChanged={async () => {
|
||||
|
||||
@@ -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 (
|
||||
<Layout className="site-shell">
|
||||
@@ -24,6 +26,11 @@ export function StorefrontLayout() {
|
||||
<Link to="/">首页</Link>
|
||||
<a href="/#listings">商品</a>
|
||||
<Link to="/orders">我的订单</Link>
|
||||
<Link to="/messages">
|
||||
<Badge count={unreadMessages} size="small" offset={[8, -2]}>
|
||||
<span>消息</span>
|
||||
</Badge>
|
||||
</Link>
|
||||
<a href="/#process">流程</a>
|
||||
<a href="/#rules">规则</a>
|
||||
</nav>
|
||||
@@ -39,6 +46,11 @@ export function StorefrontLayout() {
|
||||
label: '我的订单',
|
||||
onClick: () => navigate('/orders'),
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: '我的消息',
|
||||
onClick: () => navigate('/messages'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
@import './mobile-order-detail.css';
|
||||
@import './mobile-messages.css';
|
||||
@import './mobile-chat.css';
|
||||
@import './pc-messages.css';
|
||||
@import './components.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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user