feat(orders): 优化我的订单列表与订单详情界面

This commit is contained in:
yml2213
2026-11-01 15:45:00 +08:00
parent 3576378e7e
commit b5de3ab2f7
3 changed files with 436 additions and 119 deletions
@@ -13,7 +13,7 @@ import {
Tag,
Typography,
} from 'antd'
import { ArrowLeftOutlined, MessageOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, CopyOutlined, MessageOutlined } from '@ant-design/icons'
import { useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
@@ -37,6 +37,18 @@ const handoffRecordTypeMap: Record<string, string> = {
renter_return: '归还记录',
}
const TAG_COLORS: Record<string, string> = {
default: 'default',
primary: 'blue',
success: 'green',
warning: 'orange',
danger: 'red',
}
function toneColor(tone: string) {
return TAG_COLORS[tone] ?? 'default'
}
export function OrderDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
@@ -50,6 +62,16 @@ export function OrderDetailPage() {
const records = handoffQuery.data ?? []
const [openingChat, setOpeningChat] = useState(false)
const copyOrderNo = async () => {
if (!order) return
try {
await navigator.clipboard.writeText(order.order_no)
message.success('订单号已复制')
} catch {
message.error('复制失败,请手动选择订单号')
}
}
const openOrderChat = async () => {
if (!order || openingChat) return
setOpeningChat(true)
@@ -135,28 +157,78 @@ export function OrderDetailPage() {
</div>
<section className="pc-order-detail-hero">
<div>
<span className="pc-order-detail-hero__no">{order.order_no}</span>
<div className="pc-order-detail-hero__main">
<div className="pc-order-detail-hero__no">
<span>{order.order_no}</span>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
className="pc-order-detail-hero__copy"
onClick={() => void copyOrderNo()}
>
复制
</Button>
</div>
<Typography.Title level={2} className="brand-title">
{order.title}
</Typography.Title>
<Space size={6} wrap>
<Tag color={status.tone}>{status.label}</Tag>
<Tag color={handoff.tone} bordered={false}>
<Tag color={toneColor(status.tone)}>{status.label}</Tag>
<Tag color={toneColor(handoff.tone)} bordered={false}>
交接:{handoff.label}
</Tag>
<Tag color={settlement.tone} bordered={false}>
<Tag color={toneColor(settlement.tone)} bordered={false}>
结算:{settlement.label}
</Tag>
{order.active_dispute ? <Tag color="red">申诉处理中</Tag> : null}
</Space>
{order.status === 'pending_payment' ? (
<p className="pc-order-detail-hero__hint">
请在 {formatDateTime(order.payment_deadline_at)} 前完成支付,超时订单会自动关闭。
</p>
) : null}
</div>
<div className="pc-order-detail-hero__amount">
<span>订单金额</span>
<strong>{formatMoney(order.display_amount_cent)}</strong>
<small>押金 {formatMoney(order.deposit_amount_cent)}</small>
<small>含押金 {formatMoney(order.deposit_amount_cent)}</small>
{order.deposit_waived_amount_cent > 0 ? (
<small>已免押 {formatMoney(order.deposit_waived_amount_cent)}</small>
) : null}
</div>
</section>
<div className="pc-order-metrics">
<div className="pc-order-metric">
<span>租金</span>
<strong>
{formatMoney(
order.rent_amount_cent ??
Math.max(0, order.display_amount_cent - order.deposit_amount_cent)
)}
</strong>
</div>
<div className="pc-order-metric">
<span>实收押金</span>
<strong>{formatMoney(order.deposit_amount_cent)}</strong>
</div>
<div className="pc-order-metric">
<span>押金减免</span>
<strong className={order.deposit_waived_amount_cent > 0 ? 'is-waived' : ''}>
{order.deposit_waived_amount_cent > 0
? `-${formatMoney(order.deposit_waived_amount_cent)}`
: '—'}
</strong>
</div>
<div className="pc-order-metric">
<span>预计租期</span>
<strong>
{order.estimated_duration_hours ? `${order.estimated_duration_hours} 小时` : '—'}
</strong>
</div>
</div>
<Row gutter={[20, 20]} className="pc-order-detail-page__body">
<Col xs={24} lg={16}>
<Card title="商品信息" className="pc-order-detail-card">
@@ -222,7 +294,7 @@ export function OrderDetailPage() {
<Card title="结账单" className="pc-order-detail-card">
<Descriptions column={2} size="small" colon={false}>
<Descriptions.Item label="结账状态">
<Tag color={checkoutStatusMeta(checkout.status).tone}>
<Tag color={toneColor(checkoutStatusMeta(checkout.status).tone)}>
{checkoutStatusMeta(checkout.status).label}
</Tag>
</Descriptions.Item>
@@ -285,7 +357,10 @@ export function OrderDetailPage() {
<Card title="交接与结账记录" className="pc-order-detail-card">
{records.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无交接或结账记录" />
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无记录,支付完成并开始交接后,这里会保留每一次交接与结账内容。"
/>
) : (
<div className="pc-order-records">
{records.map(record => (
+128 -107
View File
@@ -4,12 +4,13 @@ import {
Alert,
Button,
Card,
Empty,
Pagination,
Select,
Skeleton,
Space,
Table,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { ReloadOutlined, RightOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
@@ -23,6 +24,14 @@ import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const PAGE_SIZE = 20
const TAG_COLORS: Record<string, string> = {
default: 'default',
primary: 'blue',
success: 'green',
warning: 'orange',
danger: 'red',
}
const statusOptions = [
'pending_payment',
'pending_handoff',
@@ -38,91 +47,79 @@ const statusOptions = [
'abnormal',
].map(value => ({ value, label: orderStatusMeta(value).label }))
function buildColumns(navigate: (id: number) => void): TableColumnsType<UserOrderListItem> {
return [
{
title: '订单',
dataIndex: 'order_no',
width: 300,
fixed: 'left',
render: (_, row) => (
<div className="pc-order-cell">
<button type="button" className="pc-order-link" onClick={() => navigate(row.id)}>
{row.order_no}
</button>
<strong>{row.title}</strong>
<span>
<Tag bordered={false}>{roleLabel(row.role)}</Tag>
<span className="pc-order-muted">{row.listing_no}</span>
</span>
function toneColor(tone: string) {
return TAG_COLORS[tone] ?? 'default'
}
function OrderCard({
order,
onOpen,
}: {
order: UserOrderListItem
onOpen: () => void
}) {
const status = orderStatusMeta(order.status)
const handoff = handoffStatusMeta(order.handoff_status)
return (
<article
className="pc-order-card"
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpen()
}
}}
>
<div className="pc-order-card__head">
<div className="pc-order-card__no">
<span>{order.order_no}</span>
<Tag bordered={false}>{roleLabel(order.role)}</Tag>
</div>
),
},
{
title: '商品',
key: 'listing',
width: 220,
render: (_, row) => (
<div className="pc-order-cell">
<span>{formatText(row.server_region)}</span>
<span className="pc-order-muted">{formatText(row.login_platform)}</span>
<span className="pc-order-muted">
{row.rental_days ? `${row.rental_days} 天` : '按账号规则'}
</span>
</div>
),
},
{
title: '金额',
key: 'amount',
width: 180,
align: 'right',
render: (_, row) => (
<div className="pc-order-amount">
<strong>{formatMoney(row.display_amount_cent)}</strong>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
{row.deposit_waived_amount_cent > 0 ? (
<span>减免 {formatMoney(row.deposit_waived_amount_cent)}</span>
) : null}
</div>
),
},
{
title: '状态',
key: 'status',
width: 190,
render: (_, row) => (
<Space size={4} wrap>
<Tag color={orderStatusMeta(row.status).tone}>{orderStatusMeta(row.status).label}</Tag>
<Tag color={handoffStatusMeta(row.handoff_status).tone} bordered={false}>
{handoffStatusMeta(row.handoff_status).label}
<Space size={6} wrap>
<Tag color={toneColor(status.tone)}>{status.label}</Tag>
<Tag color={toneColor(handoff.tone)} bordered={false}>
{handoff.label}
</Tag>
</Space>
),
},
{
title: '时间',
key: 'time',
width: 190,
render: (_, row) => (
<div className="pc-order-cell">
<span>下单 {formatDateTime(row.created_at)}</span>
<span className="pc-order-muted">支付截止 {formatDateTime(row.payment_deadline_at)}</span>
</div>
<div className="pc-order-card__body">
<div className="pc-order-card__main">
<h3>{order.title || '未命名订单'}</h3>
<div className="pc-order-card__meta">
<span>商品编号 {formatText(order.listing_no)}</span>
<span>{formatText(order.server_region)}</span>
<span>{formatText(order.login_platform)}</span>
<span>{order.rental_days ? `${order.rental_days} 天` : '按账号规则'}</span>
{order.daily_loss_m ? <span>日耗 {order.daily_loss_m}M</span> : null}
</div>
<div className="pc-order-card__times">
<span>下单 {formatDateTime(order.created_at)}</span>
<span>支付截止 {formatDateTime(order.payment_deadline_at)}</span>
</div>
</div>
),
},
{
title: '',
key: 'actions',
width: 90,
fixed: 'right',
render: (_, row) => (
<Button type="link" onClick={() => navigate(row.id)}>
详情 <RightOutlined />
</Button>
),
},
]
<div className="pc-order-card__side">
<div className="pc-order-card__amount">
<strong>{formatMoney(order.display_amount_cent)}</strong>
<span>押金 {formatMoney(order.deposit_amount_cent)}</span>
{order.deposit_waived_amount_cent > 0 ? (
<span className="is-waived">
押金减免 -{formatMoney(order.deposit_waived_amount_cent)}
</span>
) : null}
</div>
<span className="pc-order-card__detail">
查看详情 <RightOutlined />
</span>
</div>
</div>
</article>
)
}
export function OrderListPage() {
@@ -139,8 +136,9 @@ export function OrderListPage() {
enabled: Boolean(token),
})
const columns = buildColumns(id => navigate(`/orders/${id}`))
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录。'
const items = data?.items ?? []
const total = data?.total ?? 0
if (!hydrated) {
return (
@@ -183,7 +181,7 @@ export function OrderListPage() {
查看租用进度、支付、交接、结账与申诉记录。
</Typography.Text>
</div>
<Space>
<Space wrap>
<Select
allowClear
style={{ width: 190 }}
@@ -217,29 +215,52 @@ export function OrderListPage() {
) : null}
<Card className="pc-orders-card">
<Table<UserOrderListItem>
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isPending || isFetching}
scroll={{ x: 'max-content' }}
pagination={{
current: data?.page ?? page,
pageSize: data?.page_size ?? PAGE_SIZE,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
onChange: next => setPage(next),
}}
locale={{
emptyText: (
<div className="pc-orders-empty">
<p>还没有订单</p>
<Link to="/#listings">去看看柜选</Link>
<div className="pc-orders-toolbar">
<Typography.Text type="secondary">
{isFetching ? '正在刷新订单…' : `共 ${total} 条订单`}
</Typography.Text>
</div>
{isPending ? (
<div className="pc-orders-skeleton">
{[0, 1, 2].map(index => (
<div key={index} className="pc-order-card pc-order-card--skeleton">
<Skeleton active paragraph={{ rows: 2 }} title={false} />
</div>
),
}}
/>
))}
</div>
) : items.length === 0 ? (
<Empty className="pc-orders-empty" description="还没有订单">
<Link to="/#listings">
<Button type="primary">去看看柜选</Button>
</Link>
</Empty>
) : (
<div className="pc-orders-list">
{items.map(order => (
<OrderCard
key={order.id}
order={order}
onOpen={() => navigate(`/orders/${order.id}`)}
/>
))}
</div>
)}
{total > PAGE_SIZE ? (
<div className="pc-orders-pagination">
<Typography.Text type="secondary">
第 {page} 页 · 每页 {PAGE_SIZE} 条
</Typography.Text>
<Pagination
current={data?.page ?? page}
pageSize={data?.page_size ?? PAGE_SIZE}
total={total}
showSizeChanger={false}
onChange={next => setPage(next)}
/>
</div>
) : null}
</Card>
</div>
)
+224 -3
View File
@@ -152,6 +152,156 @@
background: var(--paper-warm);
}
.pc-orders-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.pc-orders-list,
.pc-orders-skeleton {
display: flex;
flex-direction: column;
gap: 12px;
}
.pc-order-card {
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
cursor: pointer;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
}
.pc-order-card:hover,
.pc-order-card:focus-visible {
border-color: var(--brand-ink);
box-shadow: 0 10px 26px rgba(30, 77, 66, 0.08);
outline: none;
}
.pc-order-card--skeleton {
cursor: default;
}
.pc-order-card__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
margin-bottom: 10px;
}
.pc-order-card__no {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-muted);
font-family: 'Inter', monospace;
font-size: 12px;
}
.pc-order-card__body {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 20px;
align-items: start;
}
.pc-order-card__main h3 {
margin: 0 0 8px;
color: var(--brand-ink);
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 17px;
font-weight: 600;
line-height: 1.4;
}
.pc-order-card__meta,
.pc-order-card__times {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
color: var(--ink-secondary);
font-size: 12px;
}
.pc-order-card__times {
margin-top: 10px;
color: var(--ink-muted);
}
.pc-order-card__side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
text-align: right;
}
.pc-order-card__amount strong {
display: block;
color: var(--brand-seal);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 24px;
line-height: 1.2;
}
.pc-order-card__amount span {
display: block;
color: var(--ink-muted);
font-size: 12px;
}
.pc-order-card__amount .is-waived {
color: var(--brand-seal);
}
.pc-order-card__detail {
color: var(--brand-ink);
font-size: 12px;
white-space: nowrap;
}
.pc-orders-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
}
.pc-orders-empty {
padding: 36px 0;
}
@media (max-width: 768px) {
.pc-order-card__body {
grid-template-columns: 1fr;
gap: 12px;
}
.pc-order-card__side {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
text-align: left;
}
.pc-order-card__amount strong {
font-size: 21px;
}
}
.pc-orders-login {
max-width: 520px;
margin: 60px auto;
@@ -249,12 +399,26 @@
}
.pc-order-detail-hero__no {
display: flex;
align-items: center;
gap: 6px;
color: #b9cbc3;
font-family: 'Inter', monospace;
font-size: 11px;
letter-spacing: 0.06em;
}
.pc-order-detail-hero__copy {
color: #d8e4de !important;
font-size: 11px;
}
.pc-order-detail-hero__hint {
margin: 12px 0 0;
color: #f0d7b8;
font-size: 12px;
}
.pc-order-detail-hero h2 {
margin: 8px 0 12px !important;
color: #f6f2e8 !important;
@@ -286,6 +450,39 @@
background: var(--paper-warm);
}
.pc-order-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.pc-order-metric {
display: flex;
flex-direction: column;
gap: 5px;
padding: 13px 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-warm);
}
.pc-order-metric span {
color: var(--ink-muted);
font-size: 12px;
}
.pc-order-metric strong {
color: var(--brand-ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 18px;
line-height: 1.2;
}
.pc-order-metric strong.is-waived {
color: var(--brand-seal);
}
.pc-order-detail-side {
position: sticky;
top: 84px;
@@ -300,6 +497,12 @@
background: var(--paper-warm);
}
.pc-order-action-card .ant-btn-primary {
width: 100%;
height: 42px;
font-weight: 600;
}
.pc-order-action-card .ant-card-head-title {
color: var(--brand-ink);
}
@@ -323,12 +526,26 @@
}
.pc-order-record {
padding: 14px 0;
border-bottom: 1px dashed var(--line);
position: relative;
margin-left: 6px;
padding: 0 0 18px 20px;
border-left: 1px solid var(--line);
}
.pc-order-record::before {
content: '';
position: absolute;
top: 5px;
left: -4px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--brand-ink);
}
.pc-order-record:last-child {
border-bottom: 0;
padding-bottom: 0;
border-left-color: transparent;
}
.pc-order-record__head {
@@ -354,6 +571,10 @@
}
@media (max-width: 767px) {
.pc-order-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pc-orders-page__head,
.pc-order-detail-hero {
flex-direction: column;