322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Alert,
|
|
App,
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Empty,
|
|
Skeleton,
|
|
Space,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
} from 'antd'
|
|
import { ArrowRightOutlined, CustomerServiceOutlined, MessageOutlined } from '@ant-design/icons'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
|
|
import { BrandLogo } from '@/components/BrandLogo'
|
|
import {
|
|
ensureSupportConversation,
|
|
markAllNotificationsRead,
|
|
markNotificationRead,
|
|
useConversations,
|
|
useNotifications,
|
|
} from '@/features/messages/api'
|
|
import type { AppNotification, ChatConversation } from '@/features/messages/types'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import { formatRelativeTime } from '@/shared/utils/format'
|
|
|
|
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>
|
|
)
|
|
}
|
|
|
|
function NotificationRow({
|
|
notice,
|
|
onRead,
|
|
}: {
|
|
notice: AppNotification
|
|
onRead: (id: number) => void
|
|
}) {
|
|
const unread = !notice.read_at
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`pc-notice-row${unread ? ' pc-notice-row--unread' : ''}`}
|
|
onClick={() => onRead(notice.id)}
|
|
>
|
|
<span className="pc-notice-row__dot" aria-hidden />
|
|
<span className="pc-notice-row__body">
|
|
<span className="pc-notice-row__top">
|
|
<strong>{notice.title}</strong>
|
|
<small>{formatRelativeTime(notice.created_at)}</small>
|
|
</span>
|
|
<span className="pc-notice-row__content">{notice.content}</span>
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export function MessagesPage() {
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const { message } = App.useApp()
|
|
const token = useSessionStore(state => state.token)
|
|
const hydrated = useSessionStore(state => state.hydrated)
|
|
const [openingSupport, setOpeningSupport] = useState(false)
|
|
const [tab, setTab] = useState<'chats' | 'notices'>('chats')
|
|
const chats = useConversations(Boolean(token))
|
|
const notices = useNotifications(Boolean(token))
|
|
const items = chats.data?.pages.flatMap(page => page.items) ?? []
|
|
const noticeItems = notices.data?.pages.flatMap(page => page.items) ?? []
|
|
const unreadTotal = items.reduce((sum, item) => sum + item.unread_count, 0)
|
|
const unreadNotices = noticeItems.filter(item => !item.read_at).length
|
|
|
|
const readNotice = async (id: number) => {
|
|
try {
|
|
await markNotificationRead(id)
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '标记已读失败')
|
|
}
|
|
}
|
|
|
|
const readAllNotices = async () => {
|
|
try {
|
|
await markAllNotificationsRead()
|
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
|
message.success('通知已全部标记为已读')
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '操作失败,请稍后再试')
|
|
}
|
|
}
|
|
|
|
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>
|
|
|
|
<Tabs
|
|
activeKey={tab}
|
|
onChange={key => setTab(key as 'chats' | 'notices')}
|
|
className="pc-messages-tabs"
|
|
items={[
|
|
{ key: 'chats', label: `会话${items.length ? ` · ${items.length}` : ''}` },
|
|
{
|
|
key: 'notices',
|
|
label: `通知${unreadNotices ? ` · ${unreadNotices}` : ''}`,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<div className="pc-messages-grid">
|
|
<main>
|
|
<Card
|
|
title={tab === 'chats' ? '全部会话' : '系统通知'}
|
|
extra={
|
|
tab === 'chats' ? (
|
|
items.length ? (
|
|
<Typography.Text type="secondary">{items.length} 个</Typography.Text>
|
|
) : null
|
|
) : unreadNotices > 0 ? (
|
|
<Button size="small" onClick={() => void readAllNotices()}>
|
|
全部已读
|
|
</Button>
|
|
) : (
|
|
<Tag>全部已读</Tag>
|
|
)
|
|
}
|
|
className="pc-messages-card"
|
|
>
|
|
{tab === 'chats' ? (
|
|
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>
|
|
)
|
|
) : notices.isPending ? (
|
|
<Skeleton active paragraph={{ rows: 6 }} />
|
|
) : notices.isError ? (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message="通知没有加载出来"
|
|
action={
|
|
<Button size="small" onClick={() => void notices.refetch()}>
|
|
重试
|
|
</Button>
|
|
}
|
|
/>
|
|
) : noticeItems.length === 0 ? (
|
|
<Empty description="暂时没有通知" />
|
|
) : (
|
|
<div className="pc-notice-list">
|
|
{noticeItems.map(notice => (
|
|
<NotificationRow
|
|
notice={notice}
|
|
key={notice.id}
|
|
onRead={id => void readNotice(id)}
|
|
/>
|
|
))}
|
|
{notices.hasNextPage ? (
|
|
<Button
|
|
block
|
|
loading={notices.isFetchingNextPage}
|
|
onClick={() => void notices.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>
|
|
)
|
|
}
|