Files
hfb_jysh/frontend/src/features/messages/MessagesPage.tsx
T

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>
)
}