+
+ 首页 },
+ { title: 我的订单 },
+ { title: order.order_no },
+ ]}
+ />
+ } onClick={() => navigate('/orders')}>
+ 返回订单
+
+
+
+
+
+ {order.order_no}
+
+ {order.title}
+
+
+ {status.label}
+
+ 交接:{handoff.label}
+
+
+ 结算:{settlement.label}
+
+
+
+
+ 订单金额
+ {formatMoney(order.display_amount_cent)}
+ 押金 {formatMoney(order.deposit_amount_cent)}
+
+
+
+
+
+
+
+ {formatText(order.listing_no)}
+
+ {formatText(order.login_platform)}
+
+ {formatText(order.server_region)}
+
+ {order.estimated_duration_hours
+ ? `${order.estimated_duration_hours} 小时`
+ : '—'}
+
+
+
+
+
+
+
+ {formatMoney(order.rent_amount_cent)}
+
+
+ {formatMoney(order.owner_rent_amount_cent)}
+
+
+ {formatMoney(order.platform_fee_cent)}
+
+
+ {formatMoney(order.deposit_amount_cent)}
+
+ {order.deposit_waived_amount_cent > 0 ? (
+
+ {formatMoney(order.deposit_waived_amount_cent)}
+
+ ) : null}
+ {order.refund_amount_cent > 0 ? (
+
+ {formatMoney(order.refund_amount_cent)}
+
+ ) : null}
+
+
+
+
+
+
+ {formatDateTime(order.created_at)}
+
+
+ {formatDateTime(order.payment_deadline_at)}
+
+
+ {formatDateTime(order.rented_at)}
+
+
+ {formatDateTime(order.estimated_end_at)}
+
+
+
+
+ {checkout ? (
+
+
+
+
+ {checkoutStatusMeta(checkout.status).label}
+
+
+
+ {checkout.round_count} / {checkout.max_rounds}
+ {checkout.turn ? ` · ${checkout.turn === 'owner' ? '号主处理' : '租客处理'}` : ''}
+
+
+ {formatMoney(checkout.consumable_amount_cent)}
+
+
+ {checkout.coin_consumed_m} M
+
+
+ {formatMoney(checkout.other_amount_cent)}
+
+
+ {formatMoney(checkout.deposit_deduct_amount_cent)}
+
+
+ {formatMoney(checkout.renter_refund_amount_cent)}
+
+
+ {formatMoney(checkout.owner_income_amount_cent)}
+
+ {checkout.shortfall_cent > 0 ? (
+
+
+ {formatMoney(checkout.shortfall_cent)}
+
+
+ ) : null}
+ {checkout.content ? (
+
+ {checkout.content}
+
+ ) : null}
+
+
+ ) : null}
+
+ {order.active_dispute ? (
+
+
+
+ #{order.active_dispute.id}
+
+
+ {formatText(order.active_dispute.status)}
+
+
+ {formatText(order.active_dispute.type)}
+
+
+ {order.active_dispute.initiator_type === 'admin' ? '平台' : '交易方'}
+
+
+
+ ) : null}
+
+
+ {records.length === 0 ? (
+
+ ) : (
+
+ {records.map(record => (
+
+
+ {handoffRecordTypeMap[record.type] ?? record.type}
+ {formatDateTime(record.created_at)}
+
+
{record.content}
+
+ {record.confirmed_by_renter_at ? (
+ 租客已确认
+ ) : null}
+ {record.confirmed_by_owner_at ? (
+ 号主已确认
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {
+ await Promise.all([query.refetch(), handoffQuery.refetch()])
+ await queryClient.invalidateQueries({ queryKey: ['pc-my-orders'] })
+ await queryClient.invalidateQueries({ queryKey: ['my-orders'] })
+ }}
+ />
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/orders/OrderListPage.tsx b/frontend/src/features/orders/OrderListPage.tsx
new file mode 100644
index 0000000..7a50924
--- /dev/null
+++ b/frontend/src/features/orders/OrderListPage.tsx
@@ -0,0 +1,246 @@
+import { useState } from 'react'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import {
+ Alert,
+ Button,
+ Card,
+ Select,
+ Space,
+ Table,
+ Tag,
+ Typography,
+ type TableColumnsType,
+} from 'antd'
+import { ReloadOutlined, RightOutlined } from '@ant-design/icons'
+import { Link, useNavigate } from 'react-router-dom'
+
+import { BrandLogo } from '@/components/BrandLogo'
+import { fetchMyOrders } from '@/features/orders/api'
+import { handoffStatusMeta, orderStatusMeta, roleLabel } from '@/features/orders/labels'
+import type { UserOrderListItem } from '@/features/orders/types'
+import { useSessionStore } from '@/stores/session'
+import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
+
+const PAGE_SIZE = 20
+
+const statusOptions = [
+ 'pending_payment',
+ 'pending_handoff',
+ 'renting',
+ 'overdue',
+ 'pending_checkout_confirm',
+ 'pending_checkout_accept',
+ 'checkout_disputing',
+ 'disputing',
+ 'completed',
+ 'cancelled',
+ 'closed',
+ 'abnormal',
+].map(value => ({ value, label: orderStatusMeta(value).label }))
+
+function buildColumns(navigate: (id: number) => void): TableColumnsType {
+ return [
+ {
+ title: '订单',
+ dataIndex: 'order_no',
+ width: 300,
+ fixed: 'left',
+ render: (_, row) => (
+
+
+ {row.title}
+
+ {roleLabel(row.role)}
+ {row.listing_no}
+
+
+ ),
+ },
+ {
+ title: '商品',
+ key: 'listing',
+ width: 220,
+ render: (_, row) => (
+
+ {formatText(row.server_region)}
+ {formatText(row.login_platform)}
+
+ {row.rental_days ? `${row.rental_days} 天` : '按账号规则'}
+
+
+ ),
+ },
+ {
+ title: '金额',
+ key: 'amount',
+ width: 180,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.display_amount_cent)}
+ 押金 {formatMoney(row.deposit_amount_cent)}
+ {row.deposit_waived_amount_cent > 0 ? (
+ 减免 {formatMoney(row.deposit_waived_amount_cent)}
+ ) : null}
+
+ ),
+ },
+ {
+ title: '状态',
+ key: 'status',
+ width: 190,
+ render: (_, row) => (
+
+ {orderStatusMeta(row.status).label}
+
+ {handoffStatusMeta(row.handoff_status).label}
+
+
+ ),
+ },
+ {
+ title: '时间',
+ key: 'time',
+ width: 190,
+ render: (_, row) => (
+
+ 下单 {formatDateTime(row.created_at)}
+ 支付截止 {formatDateTime(row.payment_deadline_at)}
+
+ ),
+ },
+ {
+ title: '',
+ key: 'actions',
+ width: 90,
+ fixed: 'right',
+ render: (_, row) => (
+
+ ),
+ },
+ ]
+}
+
+export function OrderListPage() {
+ const navigate = useNavigate()
+ const token = useSessionStore(state => state.token)
+ const hydrated = useSessionStore(state => state.hydrated)
+ const [status, setStatus] = useState('')
+ const [page, setPage] = useState(1)
+
+ const { data, isPending, isFetching, isError, error, refetch } = useQuery({
+ queryKey: ['pc-my-orders', status, page],
+ queryFn: () => fetchMyOrders(page, PAGE_SIZE, status),
+ placeholderData: keepPreviousData,
+ enabled: Boolean(token),
+ })
+
+ const columns = buildColumns(id => navigate(`/orders/${id}`))
+ const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录。'
+
+ if (!hydrated) {
+ return (
+
+
+
+ )
+ }
+
+ if (!token) {
+ return (
+
+
+
+ 登录后查看我的订单
+
+ 订单、押金、交接与结账记录只对交易双方可见。
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {isError ? (
+
void refetch()}>
+ 重试
+
+ }
+ />
+ ) : null}
+
+
+
+ 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: (
+
+ ),
+ }}
+ />
+
+
+ )
+}
diff --git a/frontend/src/features/orders/api.ts b/frontend/src/features/orders/api.ts
index ddc5b1c..b095193 100644
--- a/frontend/src/features/orders/api.ts
+++ b/frontend/src/features/orders/api.ts
@@ -16,10 +16,10 @@ import type {
export const MY_ORDER_PAGE_SIZE = 20
-export function fetchMyOrders(page = 1, pageSize = MY_ORDER_PAGE_SIZE) {
+export function fetchMyOrders(page = 1, pageSize = MY_ORDER_PAGE_SIZE, status = '') {
return unwrapData>(
apiClient.get>>('/orders', {
- params: { page, page_size: pageSize },
+ params: { page, page_size: pageSize, status: status || undefined },
})
).then(data => ({
items: data.items ?? [],
diff --git a/frontend/src/features/orders/orderActions.ts b/frontend/src/features/orders/orderActions.ts
new file mode 100644
index 0000000..01b9cfa
--- /dev/null
+++ b/frontend/src/features/orders/orderActions.ts
@@ -0,0 +1,132 @@
+import type { UserOrderDetail } from '@/features/orders/types'
+
+export const DISPUTE_TYPES = [
+ { label: '无法登录', value: 'cannot_login' },
+ { label: '账号异常', value: 'account_abnormal' },
+ { label: '租金/押金争议', value: 'rent_dispute' },
+ { label: '退款问题', value: 'refund_issue' },
+ { label: '其他', value: 'other' },
+]
+
+export interface OrderActionAvailability {
+ isOwner: boolean
+ isRenter: boolean
+ canPay: boolean
+ canSubmitHandoff: boolean
+ canConfirmReceive: boolean
+ canSubmitCheckout: boolean
+ ownerCheckoutPending: boolean
+ renterCheckoutPending: boolean
+ canConfirmCheckout: boolean
+ canAcceptCheckout: boolean
+ canCounterCheckout: boolean
+ canCancelOrder: boolean
+ canOpenDispute: boolean
+ canCancelDispute: boolean
+ checkoutDisputeStage: boolean
+ hasAction: boolean
+}
+
+/**
+ * 订单动作的前端可见性。
+ * 只负责展示条件,最终权限与状态仍由后端校验。
+ */
+export function getOrderActionAvailability(
+ order: UserOrderDetail,
+ userId?: number
+): OrderActionAvailability {
+ const isOwner = order.owner_id === userId || order.price_role === 'owner'
+ const isRenter = order.renter_id === userId || order.price_role === 'renter'
+ const checkout = order.checkout
+ const shortfall = checkout?.shortfall_cent ?? 0
+
+ const canPay = isRenter && order.status === 'pending_payment'
+ const canSubmitHandoff =
+ isOwner &&
+ order.status === 'pending_handoff' &&
+ ['pending_owner', 'owner_timeout'].includes(order.handoff_status)
+ const canConfirmReceive =
+ isRenter &&
+ order.status === 'pending_handoff' &&
+ order.handoff_status === 'pending_renter_confirm'
+ const canSubmitCheckout =
+ isRenter &&
+ ['renting', 'overdue'].includes(order.status) &&
+ ['received', 'return_overdue'].includes(order.handoff_status) &&
+ !checkout
+ const ownerCheckoutPending =
+ isOwner &&
+ (order.status === 'pending_checkout_confirm' ||
+ (order.status === 'abnormal' &&
+ [
+ 'owner_checkout_confirm_timeout',
+ 'owner_return_confirm_timeout',
+ 'pending_owner_checkout',
+ ].includes(order.handoff_status)))
+ const renterCheckoutPending = isRenter && order.status === 'pending_checkout_accept'
+ const canConfirmCheckout =
+ ownerCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0
+ const canAcceptCheckout =
+ renterCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0
+ const canCounterCheckout = Boolean(checkout?.can_counter)
+ const canCancelOrder = isRenter && ['pending_payment', 'pending_handoff'].includes(order.status)
+ const canOpenDispute =
+ (isOwner || isRenter) &&
+ !order.active_dispute &&
+ !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing'].includes(order.status)
+ const canCancelDispute =
+ Boolean(order.active_dispute) &&
+ order.active_dispute?.initiator_type !== 'admin' &&
+ order.active_dispute?.initiator_id === userId &&
+ ['disputing', 'checkout_disputing'].includes(order.status)
+ const checkoutDisputeStage = ['pending_checkout_confirm', 'pending_checkout_accept'].includes(
+ order.status
+ )
+
+ return {
+ isOwner,
+ isRenter,
+ canPay,
+ canSubmitHandoff,
+ canConfirmReceive,
+ canSubmitCheckout,
+ ownerCheckoutPending,
+ renterCheckoutPending,
+ canConfirmCheckout,
+ canAcceptCheckout,
+ canCounterCheckout,
+ canCancelOrder,
+ canOpenDispute,
+ canCancelDispute,
+ checkoutDisputeStage,
+ hasAction:
+ canPay ||
+ canSubmitHandoff ||
+ canConfirmReceive ||
+ canSubmitCheckout ||
+ canConfirmCheckout ||
+ canAcceptCheckout ||
+ canCounterCheckout ||
+ canCancelOrder ||
+ canOpenDispute ||
+ canCancelDispute,
+ }
+}
+
+export function splitEvidenceLines(value: string) {
+ return value
+ .split(/\r?\n/)
+ .map(item => item.trim())
+ .filter(Boolean)
+}
+
+export function yuanToCent(value: string | number) {
+ const amount = Number(value)
+ if (!Number.isFinite(amount) || amount < 0) return 0
+ return Math.round(amount * 100)
+}
+
+export function toPositiveNumber(value: string | number) {
+ const amount = Number(value)
+ return Number.isFinite(amount) && amount >= 0 ? amount : 0
+}
diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx
index fd6e8e4..50153c5 100644
--- a/frontend/src/layouts/StorefrontLayout.tsx
+++ b/frontend/src/layouts/StorefrontLayout.tsx
@@ -22,9 +22,10 @@ export function StorefrontLayout() {
@@ -33,6 +34,11 @@ export function StorefrontLayout() {
menu={{
items: [
{ key: 'phone', label: `已登录:${user.phone}`, disabled: true },
+ {
+ key: 'orders',
+ label: '我的订单',
+ onClick: () => navigate('/orders'),
+ },
{
key: 'logout',
label: '退出登录',
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 75c3c08..e82a04c 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -739,6 +739,254 @@ a {
}
}
+.pc-orders-page,
+.pc-order-detail-page {
+ max-width: 1240px;
+ margin: 0 auto;
+ padding: 28px 24px 64px;
+}
+
+.pc-orders-page__head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 20px;
+}
+
+.pc-orders-page__eyebrow {
+ color: var(--brand-seal);
+ font-size: 11px;
+ letter-spacing: 0.18em;
+}
+
+.pc-orders-page__head h2 {
+ margin: 6px 0 8px !important;
+ color: var(--brand-ink) !important;
+}
+
+.pc-orders-page__alert {
+ margin-bottom: 16px;
+}
+
+.pc-orders-card {
+ border-color: var(--line);
+ background: var(--paper-warm);
+}
+
+.pc-orders-login {
+ max-width: 520px;
+ margin: 60px auto;
+ text-align: center;
+ border-color: var(--line);
+ background: var(--paper-warm);
+}
+
+.pc-orders-login .brand-logo {
+ margin-bottom: 16px;
+}
+
+.pc-order-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: flex-start;
+}
+
+.pc-order-cell strong {
+ color: var(--ink);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.pc-order-cell span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--ink-secondary);
+ font-size: 11px;
+}
+
+.pc-order-link {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--brand-ink);
+ font-family: 'Inter', monospace;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.pc-order-link:hover {
+ color: var(--brand-seal);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.pc-order-muted {
+ color: var(--ink-muted) !important;
+}
+
+.pc-order-amount {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 3px;
+}
+
+.pc-order-amount strong {
+ color: var(--brand-ink);
+ font-family: 'Inter', monospace;
+ font-size: 13px;
+}
+
+.pc-order-amount span {
+ color: var(--ink-muted);
+ font-size: 11px;
+}
+
+.pc-orders-empty {
+ padding: 32px 0;
+ color: var(--ink-muted);
+}
+
+.pc-order-detail-page__crumb {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.pc-order-detail-hero {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin: 16px 0 22px;
+ padding: 22px 24px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--brand-ink);
+ color: #f0f4f2;
+}
+
+.pc-order-detail-hero__no {
+ color: #b9cbc3;
+ font-family: 'Inter', monospace;
+ font-size: 11px;
+ letter-spacing: 0.06em;
+}
+
+.pc-order-detail-hero h2 {
+ margin: 8px 0 12px !important;
+ color: #f6f2e8 !important;
+}
+
+.pc-order-detail-hero__amount {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 3px;
+ flex: 0 0 auto;
+}
+
+.pc-order-detail-hero__amount span,
+.pc-order-detail-hero__amount small {
+ color: #b9cbc3;
+ font-size: 11px;
+}
+
+.pc-order-detail-hero__amount strong {
+ color: #e8c9a6;
+ font-family: 'Inter', monospace;
+ font-size: 30px;
+}
+
+.pc-order-detail-card {
+ margin-bottom: 16px;
+ border-color: var(--line);
+ background: var(--paper-warm);
+}
+
+.pc-order-detail-side {
+ position: sticky;
+ top: 84px;
+}
+
+.pc-order-action-card {
+ border-color: var(--line);
+ background: var(--paper-warm);
+}
+
+.pc-order-action-card .ant-card-head-title {
+ color: var(--brand-ink);
+}
+
+.pc-order-danger {
+ color: var(--brand-seal);
+ font-family: 'Inter', monospace;
+}
+
+.pc-order-pre {
+ color: var(--ink-secondary);
+ font-size: 12px;
+ line-height: 1.75;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.pc-order-records {
+ display: flex;
+ flex-direction: column;
+}
+
+.pc-order-record {
+ padding: 14px 0;
+ border-bottom: 1px dashed var(--line);
+}
+
+.pc-order-record:last-child {
+ border-bottom: 0;
+}
+
+.pc-order-record__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 6px;
+}
+
+.pc-order-record__head strong {
+ color: var(--brand-ink);
+ font-size: 13px;
+}
+
+.pc-order-record__head span {
+ color: var(--ink-muted);
+ font-size: 11px;
+}
+
+.pc-order-dispute-tag {
+ margin: 16px 0 12px;
+}
+
+@media (max-width: 767px) {
+ .pc-orders-page__head,
+ .pc-order-detail-hero {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .pc-order-detail-hero__amount {
+ align-items: flex-start;
+ }
+
+ .pc-order-detail-side {
+ position: static;
+ }
+}
+
.empty-panel {
display: flex;
justify-content: center;