feat(storefront): 新增 PC 订单中心与订单操作

This commit is contained in:
yml2213
2026-10-30 13:56:44 +08:00
parent 55e914edd1
commit e13eda20a2
13 changed files with 1668 additions and 101 deletions
+2 -2
View File
@@ -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
View File
@@ -381,7 +381,7 @@ hfb_jysh/
### Phase 3:C 端 PC + 移动端(3 周)
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与操作页待迁移。
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情、下单、订单中心与操作页已接通,移动端实名入口、订单群聊与证据上传待迁移。
- 首页、商品列表、商品详情、撞车商品。
- 登录、注册、找回密码、实名认证。
+8
View File
@@ -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>
)
}
+2 -2
View File
@@ -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
}
+9 -3
View File
@@ -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: '退出登录',
+248
View File
@@ -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;