feat(storefront): 新增 PC 订单中心与订单操作
This commit is contained in:
+2
-2
@@ -193,7 +193,7 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端商品详情、下单确认、订单列表、详情、支付、交接、确认收号、结账、还价与申诉已接入真实接口;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与 PC 订单操作仍待迁移。
|
||||
1. 移动端商品详情、下单确认、订单列表、详情、支付、交接、确认收号、结账、还价与申诉已接入真实接口;PC 商品详情、下单、订单中心与订单操作已接通,移动端实名认证入口、订单群聊与证据上传仍待迁移。
|
||||
2. 后台导航已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点,长尾能力仍待迁移。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`(资金出款列表)与 `finance:manual_disbursement` / `finance:operating_expense`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`,分销站点 `distribution:site_view` / `distribution:site_manage`(列表也接受 `qrcode:view`);当前菜单权限码已全部对照后端。
|
||||
@@ -205,7 +205,7 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
|
||||
## 6. 建议下一批次
|
||||
|
||||
1. PC 订单中心与订单操作页、移动端实名认证入口、证据上传与订单群聊入口。
|
||||
1. 订单群聊、证据上传与聊天 SSE、移动端实名认证入口。
|
||||
2. 后台列表页原型:DataTable、FilterBar、详情 Drawer、批量操作。
|
||||
3. 先迁移后台订单列表与商品审核列表。
|
||||
4. 分销后台主题轻改。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Phase 2 验收报告(PC 订单中心与操作)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/orders`、`/orders/:id`
|
||||
> 结论:**本切片通过,PC 订单列表、详情、支付、交接、收号、结账、还价与申诉已接通**
|
||||
|
||||
---
|
||||
|
||||
## 1. 页面与路由
|
||||
|
||||
| 路径 | 页面 |
|
||||
| --- | --- |
|
||||
| `/orders` | `frontend/src/features/orders/OrderListPage.tsx` |
|
||||
| `/orders/:id` | `frontend/src/features/orders/OrderDetailPage.tsx` |
|
||||
|
||||
- 顶部导航新增「我的订单」,用户菜单也新增入口
|
||||
- PC 下单成功后改为跳转 `/orders/:id`
|
||||
- 订单详情操作区使用 `frontend/src/features/orders/OrderActionPanel.tsx`
|
||||
|
||||
## 2. 我的订单
|
||||
|
||||
- 登录守卫,未登录跳转 `/login?redirect=/orders`
|
||||
- 状态筛选:待支付、待交接、使用中、逾期、待确认结账、待接受结账、争议中、已完成、已取消、已关闭、异常
|
||||
- 列:订单/角色、商品区服、金额/押金、状态/交接、时间、详情
|
||||
- 分页每页 20 条
|
||||
- 空态引导去首页柜选
|
||||
|
||||
## 3. 订单详情
|
||||
|
||||
- 订单顶部:订单号、标题、订单状态、交接状态、结算状态、金额与押金
|
||||
- 商品信息、金额明细、时间、结账单、申诉、交接/结账记录
|
||||
- 结账单展示协商轮次、当前轮到、消耗品、哈夫币、押金扣除、可退金额、号主到手、押金缺口与结账备注
|
||||
- 交接记录展示说明、时间、租客/号主确认状态
|
||||
- 右侧吸顶订单操作区
|
||||
|
||||
## 4. 操作能力
|
||||
|
||||
桌面端与移动端共用 `frontend/src/features/orders/orderActions.ts` 的动作可用性判断:
|
||||
|
||||
- 支付:微信 / 支付宝,创建支付单后打开支付页面链接,支持检查支付状态
|
||||
- 提交交接说明
|
||||
- 确认收号
|
||||
- 发起结账
|
||||
- 确认结账 / 同意结账
|
||||
- 修改结账方案(最多 6 轮)
|
||||
- 发起申诉 / 结账争议
|
||||
- 取消申诉
|
||||
- 取消订单(待交接取消需客服审核退款)
|
||||
|
||||
金额前端按元输入,提交前转换为分;所有写操作二次确认并提示资金影响。
|
||||
|
||||
## 5. 共享逻辑
|
||||
|
||||
- `getOrderActionAvailability(order, userId)`:统一角色与状态判断
|
||||
- `DISPUTE_TYPES`:申诉类型选项
|
||||
- `splitEvidenceLines` / `yuanToCent` / `toPositiveNumber`:表单值转换
|
||||
- 移动端 `MobileOrderActionPanel` 已改为复用同一套判断,避免两端规则漂移
|
||||
|
||||
## 6. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 500.19 kB,gzip 162.01 kB)
|
||||
GET /orders 200
|
||||
GET /orders/1 200
|
||||
GET /api/orders?status=pending_payment ok,返回空列表
|
||||
```
|
||||
|
||||
开发库当前无订单,未执行下单、支付、交接或结账写操作。
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
1. 证据仍只支持链接,未接图片上传。
|
||||
2. 未展示订单群聊入口。
|
||||
3. PC 端没有隐藏订单、实名认证与客服入口。
|
||||
4. 结账未接资源使用量快照与试算。
|
||||
5. 订单列表没有关键字搜索与时间范围筛选。
|
||||
|
||||
## 8. 下一步建议
|
||||
|
||||
1. 订单群聊入口与证据上传。
|
||||
2. 移动端实名认证与客服入口。
|
||||
3. 聊天 SSE 与附件上传。
|
||||
4. 结账资源快照与金额试算。
|
||||
+1
-1
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与操作页待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情、下单、订单中心与操作页已接通,移动端实名入口、订单群聊与证据上传待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -13,6 +13,12 @@ const ListingDetailPage = lazy(() =>
|
||||
default: module.ListingDetailPage,
|
||||
}))
|
||||
)
|
||||
const OrderListPage = lazy(() =>
|
||||
import('@/features/orders/OrderListPage').then(module => ({ default: module.OrderListPage }))
|
||||
)
|
||||
const OrderDetailPage = lazy(() =>
|
||||
import('@/features/orders/OrderDetailPage').then(module => ({ default: module.OrderDetailPage }))
|
||||
)
|
||||
const LoginPage = lazy(() =>
|
||||
import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage }))
|
||||
)
|
||||
@@ -149,6 +155,8 @@ export function AppRouter() {
|
||||
<Route element={<StorefrontLayout />}>
|
||||
<Route index element={<ListingListPage />} />
|
||||
<Route path="listings/:id" element={<ListingDetailPage />} />
|
||||
<Route path="orders" element={<OrderListPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -117,7 +117,7 @@ export function ListingDetailPage() {
|
||||
title: '订单已创建',
|
||||
content: '订单已生成,接下来请完成支付;支付成功后才进入交接流程。',
|
||||
okText: '去支付',
|
||||
onOk: () => navigate(`/m/orders/${order.id}`),
|
||||
onOk: () => navigate(`/orders/${order.id}`),
|
||||
})
|
||||
} catch (error) {
|
||||
modal.error({
|
||||
|
||||
@@ -23,6 +23,13 @@ import {
|
||||
submitCheckout,
|
||||
submitHandoff,
|
||||
} from '@/features/orders/api'
|
||||
import {
|
||||
DISPUTE_TYPES,
|
||||
getOrderActionAvailability,
|
||||
splitEvidenceLines,
|
||||
toPositiveNumber,
|
||||
yuanToCent,
|
||||
} from '@/features/orders/orderActions'
|
||||
import type { UserOrderDetail } from '@/features/orders/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
@@ -46,32 +53,6 @@ interface MobileOrderActionPanelProps {
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const disputeTypes = [
|
||||
{ label: '无法登录', value: 'cannot_login' },
|
||||
{ label: '账号异常', value: 'account_abnormal' },
|
||||
{ label: '租金/押金争议', value: 'rent_dispute' },
|
||||
{ label: '退款问题', value: 'refund_issue' },
|
||||
{ label: '其他', value: 'other' },
|
||||
]
|
||||
|
||||
function splitLines(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function toCent(value: string) {
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount) || amount < 0) return 0
|
||||
return Math.round(amount * 100)
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
const amount = Number(value)
|
||||
return Number.isFinite(amount) && amount >= 0 ? amount : 0
|
||||
}
|
||||
|
||||
export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPanelProps) {
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
@@ -95,63 +76,22 @@ export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPa
|
||||
const [disputeDescription, setDisputeDescription] = useState('')
|
||||
const [disputeEvidence, setDisputeEvidence] = useState('')
|
||||
|
||||
const isOwner = order.owner_id === user?.id || order.price_role === 'owner'
|
||||
const isRenter = order.renter_id === user?.id || 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 === user?.id &&
|
||||
['disputing', 'checkout_disputing'].includes(order.status)
|
||||
const checkoutDisputeStage = ['pending_checkout_confirm', 'pending_checkout_accept'].includes(
|
||||
order.status
|
||||
)
|
||||
|
||||
const hasAction =
|
||||
canPay ||
|
||||
canSubmitHandoff ||
|
||||
canConfirmReceive ||
|
||||
canSubmitCheckout ||
|
||||
canConfirmCheckout ||
|
||||
canAcceptCheckout ||
|
||||
canCounterCheckout ||
|
||||
canCancelOrder ||
|
||||
canOpenDispute ||
|
||||
canCancelDispute
|
||||
const {
|
||||
canPay,
|
||||
canSubmitHandoff,
|
||||
canConfirmReceive,
|
||||
canSubmitCheckout,
|
||||
canConfirmCheckout,
|
||||
canAcceptCheckout,
|
||||
canCounterCheckout,
|
||||
canCancelOrder,
|
||||
canOpenDispute,
|
||||
canCancelDispute,
|
||||
checkoutDisputeStage,
|
||||
hasAction,
|
||||
} = getOrderActionAvailability(order, user?.id)
|
||||
|
||||
const run = async (action: BusyAction, fn: () => Promise<void>) => {
|
||||
setBusy(action)
|
||||
@@ -266,10 +206,10 @@ export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPa
|
||||
await run('checkout', async () => {
|
||||
await submitCheckout(order.id, {
|
||||
content: checkoutContent.trim(),
|
||||
consumable_amount_cent: toCent(consumableAmount),
|
||||
coin_consumed_m: toNumber(coinConsumed),
|
||||
other_amount_cent: toCent(otherAmount),
|
||||
evidence_urls: splitLines(checkoutEvidence),
|
||||
consumable_amount_cent: yuanToCent(consumableAmount),
|
||||
coin_consumed_m: toPositiveNumber(coinConsumed),
|
||||
other_amount_cent: yuanToCent(otherAmount),
|
||||
evidence_urls: splitEvidenceLines(checkoutEvidence),
|
||||
})
|
||||
setCheckoutOpen(false)
|
||||
Toast.show({ icon: 'success', content: '结账已发起,等待对方确认' })
|
||||
@@ -328,11 +268,11 @@ export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPa
|
||||
await counterCheckout(order.id, {
|
||||
content: counterReason.trim(),
|
||||
reason: counterReason.trim(),
|
||||
consumable_amount_cent: toCent(counterConsumable),
|
||||
coin_consumed_m: toNumber(counterCoin),
|
||||
other_amount_cent: toCent(counterDeduct),
|
||||
deposit_deduct_amount_cent: toCent(counterDeduct),
|
||||
evidence_urls: splitLines(counterEvidence),
|
||||
consumable_amount_cent: yuanToCent(counterConsumable),
|
||||
coin_consumed_m: toPositiveNumber(counterCoin),
|
||||
other_amount_cent: yuanToCent(counterDeduct),
|
||||
deposit_deduct_amount_cent: yuanToCent(counterDeduct),
|
||||
evidence_urls: splitEvidenceLines(counterEvidence),
|
||||
})
|
||||
setCounterOpen(false)
|
||||
Toast.show({ icon: 'success', content: '结账方案已提交' })
|
||||
@@ -361,7 +301,7 @@ export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPa
|
||||
await createOrderDispute(order.id, {
|
||||
type: checkoutDisputeStage ? 'checkout_dispute' : disputeType,
|
||||
description: disputeDescription.trim(),
|
||||
evidence_urls: splitLines(disputeEvidence),
|
||||
evidence_urls: splitEvidenceLines(disputeEvidence),
|
||||
})
|
||||
setDisputeOpen(false)
|
||||
Toast.show({ icon: 'success', content: '申诉已提交' })
|
||||
@@ -657,7 +597,7 @@ export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPa
|
||||
<div className="mobile-order-sheet__field">
|
||||
<span>申诉类型</span>
|
||||
<Selector
|
||||
options={disputeTypes}
|
||||
options={DISPUTE_TYPES}
|
||||
value={[disputeType]}
|
||||
onChange={values => setDisputeType(values[0] ?? 'cannot_login')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
|
||||
import {
|
||||
acceptCheckout,
|
||||
cancelMyOrder,
|
||||
cancelOrderDispute,
|
||||
confirmCheckout,
|
||||
confirmReceive,
|
||||
counterCheckout as counterCheckoutApi,
|
||||
createOrderDispute,
|
||||
queryOrderPayment,
|
||||
startOrderPayment,
|
||||
submitCheckout as submitCheckoutApi,
|
||||
submitHandoff as submitHandoffApi,
|
||||
} from '@/features/orders/api'
|
||||
import {
|
||||
DISPUTE_TYPES,
|
||||
getOrderActionAvailability,
|
||||
splitEvidenceLines,
|
||||
toPositiveNumber,
|
||||
yuanToCent,
|
||||
} from '@/features/orders/orderActions'
|
||||
import type { UserOrderDetail } from '@/features/orders/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type DialogKind = 'pay' | 'handoff' | 'checkout' | 'counter' | 'dispute' | null
|
||||
|
||||
interface ActionFormValues {
|
||||
payWay?: 'WXZF' | 'ZFBZF'
|
||||
content?: string
|
||||
consumableYuan?: number
|
||||
coinConsumed?: number
|
||||
otherYuan?: number
|
||||
reason?: string
|
||||
evidenceText?: string
|
||||
disputeType?: string
|
||||
}
|
||||
|
||||
interface OrderActionPanelProps {
|
||||
order: UserOrderDetail
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
const { message, modal } = App.useApp()
|
||||
const [form] = Form.useForm<ActionFormValues>()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [dialog, setDialog] = useState<DialogKind>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [payURL, setPayURL] = useState('')
|
||||
const actions = getOrderActionAvailability(order, user?.id)
|
||||
const checkout = order.checkout
|
||||
|
||||
const closeDialog = () => {
|
||||
if (busy) return
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const readForm = async (): Promise<ActionFormValues | null> => {
|
||||
try {
|
||||
return await form.validateFields()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const openDialog = (kind: Exclude<DialogKind, null>) => {
|
||||
form.resetFields()
|
||||
setPayURL('')
|
||||
if (kind === 'pay') {
|
||||
form.setFieldsValue({ payWay: 'WXZF' })
|
||||
}
|
||||
if (kind === 'checkout') {
|
||||
form.setFieldsValue({
|
||||
content: '',
|
||||
consumableYuan: 0,
|
||||
coinConsumed: checkout?.coin_consumed_m ?? 0,
|
||||
otherYuan: 0,
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
if (kind === 'counter') {
|
||||
form.setFieldsValue({
|
||||
consumableYuan: (checkout?.consumable_amount_cent ?? 0) / 100,
|
||||
coinConsumed: checkout?.coin_consumed_m ?? 0,
|
||||
otherYuan: (checkout?.deposit_deduct_amount_cent ?? 0) / 100,
|
||||
reason: '',
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
if (kind === 'dispute') {
|
||||
form.setFieldsValue({
|
||||
disputeType: actions.checkoutDisputeStage ? 'checkout_dispute' : 'cannot_login',
|
||||
content: '',
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
setDialog(kind)
|
||||
}
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败,请稍后再试')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitPay = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
setBusy('pay')
|
||||
try {
|
||||
const payment = await startOrderPayment(order.id, values.payWay ?? 'WXZF')
|
||||
if (payment.paid) {
|
||||
message.success('支付成功')
|
||||
await onChanged()
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
return
|
||||
}
|
||||
const url = payment.jspay_url || payment.td_code || payment.jspay_info || ''
|
||||
if (url) {
|
||||
setPayURL(url)
|
||||
message.info('支付单已创建,请打开支付页面完成付款')
|
||||
} else {
|
||||
message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '发起支付失败')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPayment = async () => {
|
||||
setBusy('check-pay')
|
||||
try {
|
||||
const payment = await queryOrderPayment(order.id)
|
||||
if (payment.paid) {
|
||||
message.success('支付成功,订单已进入待交接')
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
await onChanged()
|
||||
return
|
||||
}
|
||||
message.info('支付暂未完成,请稍后再试')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '查询支付状态失败')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitHandoff = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
await run('handoff', async () => {
|
||||
await submitHandoffApi(order.id, values.content ?? '')
|
||||
message.success('交接说明已提交')
|
||||
})
|
||||
}
|
||||
|
||||
const submitReceive = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认收号',
|
||||
content: '请确认账号可以正常登录、资料与交接说明一致。确认后订单进入使用中。',
|
||||
okText: '确认收号',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('receive', async () => {
|
||||
await confirmReceive(order.id)
|
||||
message.success('已确认收号')
|
||||
})
|
||||
}
|
||||
|
||||
const submitCheckout = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认提交结账',
|
||||
content: '结账会直接影响租金、押金赔付与双方结算金额,提交后将发送给对方确认。',
|
||||
okText: '提交结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('checkout', async () => {
|
||||
await submitCheckoutApi(order.id, {
|
||||
content: values.content ?? '',
|
||||
consumable_amount_cent: yuanToCent(values.consumableYuan ?? 0),
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('结账已发起,等待对方确认')
|
||||
})
|
||||
}
|
||||
|
||||
const submitConfirmCheckout = async () => {
|
||||
if ((checkout?.shortfall_cent ?? 0) > 0) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认结账',
|
||||
content: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
|
||||
okText: '确认结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('confirm-checkout', async () => {
|
||||
await confirmCheckout(order.id)
|
||||
message.success('结账已确认,订单完成')
|
||||
})
|
||||
}
|
||||
|
||||
const submitAcceptCheckout = async () => {
|
||||
if ((checkout?.shortfall_cent ?? 0) > 0) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
title: '同意结账方案',
|
||||
content: '确定同意当前结账方案吗?同意后订单将完成结算。',
|
||||
okText: '同意结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('accept-checkout', async () => {
|
||||
await acceptCheckout(order.id)
|
||||
message.success('已同意结账,订单完成')
|
||||
})
|
||||
}
|
||||
|
||||
const submitCounter = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
await run('counter', async () => {
|
||||
await counterCheckoutApi(order.id, {
|
||||
content: values.reason ?? '',
|
||||
reason: values.reason ?? '',
|
||||
consumable_amount_cent: yuanToCent(values.consumableYuan ?? 0),
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
deposit_deduct_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('结账方案已修改,等待对方处理')
|
||||
})
|
||||
}
|
||||
|
||||
const submitDispute = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
title: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉',
|
||||
content: '提交后订单会进入仲裁流程,客服会根据双方证据处理。确定提交吗?',
|
||||
okText: '提交',
|
||||
cancelText: '再想想',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('dispute', async () => {
|
||||
await createOrderDispute(order.id, {
|
||||
type: actions.checkoutDisputeStage ? 'checkout_dispute' : values.disputeType ?? 'other',
|
||||
description: values.content ?? '',
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('申诉已提交')
|
||||
})
|
||||
}
|
||||
|
||||
const cancelDispute = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
title: '取消申诉',
|
||||
content: '取消后订单会恢复到发起申诉前的流程,确定继续吗?',
|
||||
okText: '取消申诉',
|
||||
cancelText: '保留申诉',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('cancel-dispute', async () => {
|
||||
await cancelOrderDispute(order.id)
|
||||
message.success('申诉已取消')
|
||||
})
|
||||
}
|
||||
|
||||
const cancelOrder = async () => {
|
||||
const refundReview = order.status === 'pending_handoff'
|
||||
const confirmed = await modal.confirm({
|
||||
title: refundReview ? '申请取消订单' : '取消订单',
|
||||
content: refundReview
|
||||
? '订单已支付,取消后需要客服审核退款,审核通过前账号仍会保持占用。'
|
||||
: '取消后订单会关闭,已冻结的押金将按原路退回。',
|
||||
okText: refundReview ? '申请取消' : '取消订单',
|
||||
cancelText: '再想想',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('cancel', async () => {
|
||||
await cancelMyOrder(order.id)
|
||||
message.success(refundReview ? '取消申请已提交' : '订单已取消')
|
||||
})
|
||||
}
|
||||
|
||||
const dialogTitle: Record<Exclude<DialogKind, null>, string> = {
|
||||
pay: '支付订单',
|
||||
handoff: '提交交接说明',
|
||||
checkout: '发起结账',
|
||||
counter: '修改结账方案',
|
||||
dispute: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="订单操作" className="pc-order-action-card">
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{actions.canPay ? (
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => openDialog('pay')}>
|
||||
去支付
|
||||
</Button>
|
||||
<Button loading={busy === 'check-pay'} onClick={() => void checkPayment()}>
|
||||
检查支付状态
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{actions.canSubmitHandoff ? (
|
||||
<Button type="primary" onClick={() => openDialog('handoff')}>
|
||||
提交交接说明
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canConfirmReceive ? (
|
||||
<Button type="primary" loading={busy === 'receive'} onClick={() => void submitReceive()}>
|
||||
确认收号
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canSubmitCheckout ? (
|
||||
<Button type="primary" onClick={() => openDialog('checkout')}>
|
||||
发起结账
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canConfirmCheckout ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy === 'confirm-checkout'}
|
||||
onClick={() => void submitConfirmCheckout()}
|
||||
>
|
||||
确认结账
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canAcceptCheckout ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy === 'accept-checkout'}
|
||||
onClick={() => void submitAcceptCheckout()}
|
||||
>
|
||||
同意结账方案
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Space wrap>
|
||||
{actions.canCounterCheckout ? (
|
||||
<Button onClick={() => openDialog('counter')}>修改结账方案</Button>
|
||||
) : null}
|
||||
{actions.canOpenDispute ? (
|
||||
<Button danger onClick={() => openDialog('dispute')}>
|
||||
{actions.checkoutDisputeStage ? '拒绝方案并发起争议' : '发起申诉'}
|
||||
</Button>
|
||||
) : null}
|
||||
{actions.canCancelDispute ? (
|
||||
<Button danger loading={busy === 'cancel-dispute'} onClick={() => void cancelDispute()}>
|
||||
取消申诉
|
||||
</Button>
|
||||
) : null}
|
||||
{actions.canCancelOrder ? (
|
||||
<Button danger loading={busy === 'cancel'} onClick={() => void cancelOrder()}>
|
||||
取消订单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{!actions.hasAction ? (
|
||||
<Typography.Text type="secondary">当前订单没有可执行操作。</Typography.Text>
|
||||
) : null}
|
||||
|
||||
{checkout && checkout.shortfall_cent > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`押金缺口 ${formatMoney(checkout.shortfall_cent)}`}
|
||||
description="押金不足以覆盖当前方案,需修改方案或发起争议由客服处理。"
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
open={Boolean(dialog)}
|
||||
title={dialog ? dialogTitle[dialog] : ''}
|
||||
width={640}
|
||||
onCancel={closeDialog}
|
||||
maskClosable={!busy}
|
||||
confirmLoading={Boolean(busy)}
|
||||
footer={
|
||||
dialog === 'pay' && payURL ? (
|
||||
<Space>
|
||||
<Button onClick={closeDialog} disabled={Boolean(busy)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button loading={busy === 'check-pay'} onClick={() => void checkPayment()}>
|
||||
检查支付状态
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button onClick={closeDialog} disabled={Boolean(busy)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={Boolean(busy)}
|
||||
onClick={() => {
|
||||
if (dialog === 'pay') void submitPay()
|
||||
if (dialog === 'handoff') void submitHandoff()
|
||||
if (dialog === 'checkout') void submitCheckout()
|
||||
if (dialog === 'counter') void submitCounter()
|
||||
if (dialog === 'dispute') void submitDispute()
|
||||
}}
|
||||
>
|
||||
{dialog === 'pay' ? '发起支付' : dialog === 'handoff' ? '提交交接' : '提交'}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{dialog === 'pay' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="选择支付方式"
|
||||
description="支付成功后才进入交接流程;支付完成后回到本页点击「检查支付状态」。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="payWay" label="支付方式">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="WXZF">微信支付</Radio.Button>
|
||||
<Radio.Button value="ZFBZF">支付宝</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{payURL ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message="支付单已创建"
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<span>请在支付页面完成付款,完成后回到本页检查支付状态。</span>
|
||||
<Button type="primary" href={payURL} target="_blank">
|
||||
打开支付页面
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialog === 'handoff' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="交接说明会展示给租客"
|
||||
description="请写清账号、密码、区服、注意事项与可登录时间。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="交接说明"
|
||||
rules={[{ required: true, message: '请填写交接说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={6} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialog === 'checkout' || dialog === 'counter' ? (
|
||||
<Form form={form} layout="vertical">
|
||||
{dialog === 'counter' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`第 ${checkout?.round_count ?? 1}/${checkout?.max_rounds ?? 6} 轮协商`}
|
||||
description="修改后由对方继续确认或还价,最多 6 轮。"
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name={dialog === 'counter' ? 'reason' : 'content'}
|
||||
label={dialog === 'counter' ? '修改原因' : '结账备注'}
|
||||
rules={[{ required: true, message: '请填写说明' }]}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder={
|
||||
dialog === 'counter'
|
||||
? '说明为什么需要调整金额或扣除押金'
|
||||
: '说明账号使用情况、消耗品、哈夫币与费用依据'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ width: '100%' }} align="start">
|
||||
<Form.Item name="consumableYuan" label="消耗品金额(元)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coinConsumed" label="哈夫币消耗(M)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="otherYuan" label="押金扣除(元)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
|
||||
{dialog === 'dispute' ? (
|
||||
<Form form={form} layout="vertical">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="提交后进入仲裁"
|
||||
description="请写清时间、经过、诉求与已经沟通的结果,并尽量附上证据链接。"
|
||||
/>
|
||||
{!actions.checkoutDisputeStage ? (
|
||||
<Form.Item name="disputeType" label="申诉类型" style={{ marginTop: 16 }}>
|
||||
<Select options={DISPUTE_TYPES} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Tag color="red" className="pc-order-dispute-tag">
|
||||
结账争议
|
||||
</Tag>
|
||||
)}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="申诉说明"
|
||||
rules={[{ required: true, message: '请填写申诉说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={5} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
Alert,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Row,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { ArrowLeftOutlined } 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 {
|
||||
checkoutStatusMeta,
|
||||
handoffStatusMeta,
|
||||
orderStatusMeta,
|
||||
settlementStatusMeta,
|
||||
} from '@/features/orders/labels'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const handoffRecordTypeMap: Record<string, string> = {
|
||||
owner_handoff: '号主交接',
|
||||
platform_handoff: '平台代交接',
|
||||
renter_checkout: '结账记录',
|
||||
renter_return: '归还记录',
|
||||
}
|
||||
|
||||
export function OrderDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
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 ?? []
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="pc-order-detail-page">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pc-order-detail-page">
|
||||
<Card className="pc-orders-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=/orders/${id}`)}>
|
||||
去登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="pc-order-detail-page">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError || !order) {
|
||||
return (
|
||||
<div className="pc-order-detail-page">
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="订单详情没有加载出来"
|
||||
description="订单可能已关闭,或当前账号无权查看。"
|
||||
action={
|
||||
<Button size="small" onClick={() => void query.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = orderStatusMeta(order.status)
|
||||
const handoff = handoffStatusMeta(order.handoff_status)
|
||||
const settlement = settlementStatusMeta(order.settlement_status)
|
||||
const checkout = order.checkout
|
||||
|
||||
return (
|
||||
<div className="pc-order-detail-page">
|
||||
<div className="pc-order-detail-page__crumb">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: <Link to="/">首页</Link> },
|
||||
{ title: <Link to="/orders">我的订单</Link> },
|
||||
{ title: order.order_no },
|
||||
]}
|
||||
/>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/orders')}>
|
||||
返回订单
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="pc-order-detail-hero">
|
||||
<div>
|
||||
<span className="pc-order-detail-hero__no">{order.order_no}</span>
|
||||
<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}>
|
||||
交接:{handoff.label}
|
||||
</Tag>
|
||||
<Tag color={settlement.tone} bordered={false}>
|
||||
结算:{settlement.label}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="pc-order-detail-hero__amount">
|
||||
<span>订单金额</span>
|
||||
<strong>{formatMoney(order.display_amount_cent)}</strong>
|
||||
<small>押金 {formatMoney(order.deposit_amount_cent)}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Row gutter={[20, 20]} className="pc-order-detail-page__body">
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="商品信息" className="pc-order-detail-card">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
<Descriptions.Item label="商品编号">{formatText(order.listing_no)}</Descriptions.Item>
|
||||
<Descriptions.Item label="登录平台">
|
||||
{formatText(order.login_platform)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="区服">{formatText(order.server_region)}</Descriptions.Item>
|
||||
<Descriptions.Item label="预计租期">
|
||||
{order.estimated_duration_hours
|
||||
? `${order.estimated_duration_hours} 小时`
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="金额明细" className="pc-order-detail-card">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
<Descriptions.Item label="租客租金">
|
||||
{formatMoney(order.rent_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="号主租金">
|
||||
{formatMoney(order.owner_rent_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台服务费">
|
||||
{formatMoney(order.platform_fee_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="实收押金">
|
||||
{formatMoney(order.deposit_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
{order.deposit_waived_amount_cent > 0 ? (
|
||||
<Descriptions.Item label="押金减免">
|
||||
{formatMoney(order.deposit_waived_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{order.refund_amount_cent > 0 ? (
|
||||
<Descriptions.Item label="已退金额">
|
||||
{formatMoney(order.refund_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="时间" className="pc-order-detail-card">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
<Descriptions.Item label="下单时间">
|
||||
{formatDateTime(order.created_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付截止">
|
||||
{formatDateTime(order.payment_deadline_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="起租时间">
|
||||
{formatDateTime(order.rented_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预计结束">
|
||||
{formatDateTime(order.estimated_end_at)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{checkout ? (
|
||||
<Card title="结账单" className="pc-order-detail-card">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
<Descriptions.Item label="结账状态">
|
||||
<Tag color={checkoutStatusMeta(checkout.status).tone}>
|
||||
{checkoutStatusMeta(checkout.status).label}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="协商轮次">
|
||||
{checkout.round_count} / {checkout.max_rounds}
|
||||
{checkout.turn ? ` · ${checkout.turn === 'owner' ? '号主处理' : '租客处理'}` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="消耗品费用">
|
||||
{formatMoney(checkout.consumable_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="哈夫币消耗">
|
||||
{checkout.coin_consumed_m} M
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="其他费用">
|
||||
{formatMoney(checkout.other_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="押金扣除">
|
||||
{formatMoney(checkout.deposit_deduct_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可退金额">
|
||||
{formatMoney(checkout.renter_refund_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="号主到手">
|
||||
{formatMoney(checkout.owner_income_amount_cent)}
|
||||
</Descriptions.Item>
|
||||
{checkout.shortfall_cent > 0 ? (
|
||||
<Descriptions.Item label="押金缺口" span={2}>
|
||||
<span className="pc-order-danger">
|
||||
{formatMoney(checkout.shortfall_cent)}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{checkout.content ? (
|
||||
<Descriptions.Item label="结账备注" span={2}>
|
||||
<div className="pc-order-pre">{checkout.content}</div>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{order.active_dispute ? (
|
||||
<Card title="申诉" className="pc-order-detail-card">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
<Descriptions.Item label="申诉单号">
|
||||
#{order.active_dispute.id}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{formatText(order.active_dispute.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{formatText(order.active_dispute.type)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发起方">
|
||||
{order.active_dispute.initiator_type === 'admin' ? '平台' : '交易方'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="交接与结账记录" className="pc-order-detail-card">
|
||||
{records.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无交接或结账记录" />
|
||||
) : (
|
||||
<div className="pc-order-records">
|
||||
{records.map(record => (
|
||||
<div className="pc-order-record" key={record.id}>
|
||||
<div className="pc-order-record__head">
|
||||
<strong>{handoffRecordTypeMap[record.type] ?? record.type}</strong>
|
||||
<span>{formatDateTime(record.created_at)}</span>
|
||||
</div>
|
||||
<div className="pc-order-pre">{record.content}</div>
|
||||
<Space size={6}>
|
||||
{record.confirmed_by_renter_at ? (
|
||||
<Tag color="success">租客已确认</Tag>
|
||||
) : null}
|
||||
{record.confirmed_by_owner_at ? (
|
||||
<Tag color="success">号主已确认</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<div className="pc-order-detail-side">
|
||||
<OrderActionPanel
|
||||
order={order}
|
||||
onChanged={async () => {
|
||||
await Promise.all([query.refetch(), handoffQuery.refetch()])
|
||||
await queryClient.invalidateQueries({ queryKey: ['pc-my-orders'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['my-orders'] })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<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>
|
||||
</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}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" onClick={() => navigate(row.id)}>
|
||||
详情 <RightOutlined />
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="pc-orders-page">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="pc-orders-page">
|
||||
<Card className="pc-orders-login">
|
||||
<BrandLogo height={48} variant="seal" />
|
||||
<Typography.Title level={3}>登录后查看我的订单</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单、押金、交接与结账记录只对交易双方可见。
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => navigate('/login?redirect=/orders')}
|
||||
>
|
||||
去登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pc-orders-page">
|
||||
<header className="pc-orders-page__head">
|
||||
<div>
|
||||
<span className="pc-orders-page__eyebrow">MY ORDERS</span>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
我的订单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
查看租用进度、支付、交接、结账与申诉记录。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 190 }}
|
||||
value={status || undefined}
|
||||
placeholder="全部状态"
|
||||
options={statusOptions}
|
||||
onChange={value => {
|
||||
setStatus(value ?? '')
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="pc-orders-page__alert"
|
||||
message="订单没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : 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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Partial<UserOrderPage>>(
|
||||
apiClient.get<ApiResponse<Partial<UserOrderPage>>>('/orders', {
|
||||
params: { page, page_size: pageSize },
|
||||
params: { page, page_size: pageSize, status: status || undefined },
|
||||
})
|
||||
).then(data => ({
|
||||
items: data.items ?? [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -22,9 +22,10 @@ export function StorefrontLayout() {
|
||||
|
||||
<nav className="site-nav" aria-label="主导航">
|
||||
<Link to="/">首页</Link>
|
||||
<a href="#listings">商品</a>
|
||||
<a href="#process">流程</a>
|
||||
<a href="#rules">规则</a>
|
||||
<a href="/#listings">商品</a>
|
||||
<Link to="/orders">我的订单</Link>
|
||||
<a href="/#process">流程</a>
|
||||
<a href="/#rules">规则</a>
|
||||
</nav>
|
||||
|
||||
<Space size={12}>
|
||||
@@ -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: '退出登录',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user