diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index bd804a3..5825053 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 分销后台主题轻改。 diff --git a/docs/Phase2-PC订单中心与操作验收.md b/docs/Phase2-PC订单中心与操作验收.md new file mode 100644 index 0000000..22d6edd --- /dev/null +++ b/docs/Phase2-PC订单中心与操作验收.md @@ -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. 结账资源快照与金额试算。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 1714d27..b000619 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与操作页待迁移。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情、下单、订单中心与操作页已接通,移动端实名入口、订单群聊与证据上传待迁移。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 64bf580..37f7d89 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { }> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/features/listings/ListingDetailPage.tsx b/frontend/src/features/listings/ListingDetailPage.tsx index 59c131e..3147893 100644 --- a/frontend/src/features/listings/ListingDetailPage.tsx +++ b/frontend/src/features/listings/ListingDetailPage.tsx @@ -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({ diff --git a/frontend/src/features/mobile/MobileOrderActionPanel.tsx b/frontend/src/features/mobile/MobileOrderActionPanel.tsx index 8e7c01a..a83c66e 100644 --- a/frontend/src/features/mobile/MobileOrderActionPanel.tsx +++ b/frontend/src/features/mobile/MobileOrderActionPanel.tsx @@ -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 } -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(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) => { 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
申诉类型 setDisputeType(values[0] ?? 'cannot_login')} /> diff --git a/frontend/src/features/orders/OrderActionPanel.tsx b/frontend/src/features/orders/OrderActionPanel.tsx new file mode 100644 index 0000000..c288096 --- /dev/null +++ b/frontend/src/features/orders/OrderActionPanel.tsx @@ -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 +} + +export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) { + const { message, modal } = App.useApp() + const [form] = Form.useForm() + const user = useSessionStore(state => state.user) + const [dialog, setDialog] = useState(null) + const [busy, setBusy] = useState(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 => { + try { + return await form.validateFields() + } catch { + return null + } + } + + const openDialog = (kind: Exclude) => { + 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) => { + 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, string> = { + pay: '支付订单', + handoff: '提交交接说明', + checkout: '发起结账', + counter: '修改结账方案', + dispute: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉', + } + + return ( + + + {actions.canPay ? ( + + + + + ) : null} + + {actions.canSubmitHandoff ? ( + + ) : null} + + {actions.canConfirmReceive ? ( + + ) : null} + + {actions.canSubmitCheckout ? ( + + ) : null} + + {actions.canConfirmCheckout ? ( + + ) : null} + + {actions.canAcceptCheckout ? ( + + ) : null} + + + {actions.canCounterCheckout ? ( + + ) : null} + {actions.canOpenDispute ? ( + + ) : null} + {actions.canCancelDispute ? ( + + ) : null} + {actions.canCancelOrder ? ( + + ) : null} + + + {!actions.hasAction ? ( + 当前订单没有可执行操作。 + ) : null} + + {checkout && checkout.shortfall_cent > 0 ? ( + + ) : null} + + + + + + + ) : ( + + + + + ) + } + > + {dialog === 'pay' ? ( + <> + +
+ + + 微信支付 + 支付宝 + + +
+ {payURL ? ( + + 请在支付页面完成付款,完成后回到本页检查支付状态。 + + + } + /> + ) : null} + + ) : null} + + {dialog === 'handoff' ? ( + <> + +
+ + + +
+ + ) : null} + + {dialog === 'checkout' || dialog === 'counter' ? ( +
+ {dialog === 'counter' ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + ) : null} + + {dialog === 'dispute' ? ( +
+ + {!actions.checkoutDisputeStage ? ( + + { + setStatus(value ?? '') + setPage(1) + }} + /> + + + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + + rowKey="id" + columns={columns} + dataSource={data?.items ?? []} + loading={isPending || isFetching} + scroll={{ x: 'max-content' }} + pagination={{ + current: data?.page ?? page, + pageSize: data?.page_size ?? PAGE_SIZE, + total: data?.total ?? 0, + showSizeChanger: false, + showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`, + onChange: next => setPage(next), + }} + locale={{ + emptyText: ( +
+

还没有订单

+ 去看看柜选 +
+ ), + }} + /> +
+
+ ) +} diff --git a/frontend/src/features/orders/api.ts b/frontend/src/features/orders/api.ts index ddc5b1c..b095193 100644 --- a/frontend/src/features/orders/api.ts +++ b/frontend/src/features/orders/api.ts @@ -16,10 +16,10 @@ import type { export const MY_ORDER_PAGE_SIZE = 20 -export function fetchMyOrders(page = 1, pageSize = MY_ORDER_PAGE_SIZE) { +export function fetchMyOrders(page = 1, pageSize = MY_ORDER_PAGE_SIZE, status = '') { return unwrapData>( apiClient.get>>('/orders', { - params: { page, page_size: pageSize }, + params: { page, page_size: pageSize, status: status || undefined }, }) ).then(data => ({ items: data.items ?? [], diff --git a/frontend/src/features/orders/orderActions.ts b/frontend/src/features/orders/orderActions.ts new file mode 100644 index 0000000..01b9cfa --- /dev/null +++ b/frontend/src/features/orders/orderActions.ts @@ -0,0 +1,132 @@ +import type { UserOrderDetail } from '@/features/orders/types' + +export const DISPUTE_TYPES = [ + { label: '无法登录', value: 'cannot_login' }, + { label: '账号异常', value: 'account_abnormal' }, + { label: '租金/押金争议', value: 'rent_dispute' }, + { label: '退款问题', value: 'refund_issue' }, + { label: '其他', value: 'other' }, +] + +export interface OrderActionAvailability { + isOwner: boolean + isRenter: boolean + canPay: boolean + canSubmitHandoff: boolean + canConfirmReceive: boolean + canSubmitCheckout: boolean + ownerCheckoutPending: boolean + renterCheckoutPending: boolean + canConfirmCheckout: boolean + canAcceptCheckout: boolean + canCounterCheckout: boolean + canCancelOrder: boolean + canOpenDispute: boolean + canCancelDispute: boolean + checkoutDisputeStage: boolean + hasAction: boolean +} + +/** + * 订单动作的前端可见性。 + * 只负责展示条件,最终权限与状态仍由后端校验。 + */ +export function getOrderActionAvailability( + order: UserOrderDetail, + userId?: number +): OrderActionAvailability { + const isOwner = order.owner_id === userId || order.price_role === 'owner' + const isRenter = order.renter_id === userId || order.price_role === 'renter' + const checkout = order.checkout + const shortfall = checkout?.shortfall_cent ?? 0 + + const canPay = isRenter && order.status === 'pending_payment' + const canSubmitHandoff = + isOwner && + order.status === 'pending_handoff' && + ['pending_owner', 'owner_timeout'].includes(order.handoff_status) + const canConfirmReceive = + isRenter && + order.status === 'pending_handoff' && + order.handoff_status === 'pending_renter_confirm' + const canSubmitCheckout = + isRenter && + ['renting', 'overdue'].includes(order.status) && + ['received', 'return_overdue'].includes(order.handoff_status) && + !checkout + const ownerCheckoutPending = + isOwner && + (order.status === 'pending_checkout_confirm' || + (order.status === 'abnormal' && + [ + 'owner_checkout_confirm_timeout', + 'owner_return_confirm_timeout', + 'pending_owner_checkout', + ].includes(order.handoff_status))) + const renterCheckoutPending = isRenter && order.status === 'pending_checkout_accept' + const canConfirmCheckout = + ownerCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0 + const canAcceptCheckout = + renterCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0 + const canCounterCheckout = Boolean(checkout?.can_counter) + const canCancelOrder = isRenter && ['pending_payment', 'pending_handoff'].includes(order.status) + const canOpenDispute = + (isOwner || isRenter) && + !order.active_dispute && + !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing'].includes(order.status) + const canCancelDispute = + Boolean(order.active_dispute) && + order.active_dispute?.initiator_type !== 'admin' && + order.active_dispute?.initiator_id === userId && + ['disputing', 'checkout_disputing'].includes(order.status) + const checkoutDisputeStage = ['pending_checkout_confirm', 'pending_checkout_accept'].includes( + order.status + ) + + return { + isOwner, + isRenter, + canPay, + canSubmitHandoff, + canConfirmReceive, + canSubmitCheckout, + ownerCheckoutPending, + renterCheckoutPending, + canConfirmCheckout, + canAcceptCheckout, + canCounterCheckout, + canCancelOrder, + canOpenDispute, + canCancelDispute, + checkoutDisputeStage, + hasAction: + canPay || + canSubmitHandoff || + canConfirmReceive || + canSubmitCheckout || + canConfirmCheckout || + canAcceptCheckout || + canCounterCheckout || + canCancelOrder || + canOpenDispute || + canCancelDispute, + } +} + +export function splitEvidenceLines(value: string) { + return value + .split(/\r?\n/) + .map(item => item.trim()) + .filter(Boolean) +} + +export function yuanToCent(value: string | number) { + const amount = Number(value) + if (!Number.isFinite(amount) || amount < 0) return 0 + return Math.round(amount * 100) +} + +export function toPositiveNumber(value: string | number) { + const amount = Number(value) + return Number.isFinite(amount) && amount >= 0 ? amount : 0 +} diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index fd6e8e4..50153c5 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -22,9 +22,10 @@ export function StorefrontLayout() { @@ -33,6 +34,11 @@ export function StorefrontLayout() { menu={{ items: [ { key: 'phone', label: `已登录:${user.phone}`, disabled: true }, + { + key: 'orders', + label: '我的订单', + onClick: () => navigate('/orders'), + }, { key: 'logout', label: '退出登录', diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 75c3c08..e82a04c 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -739,6 +739,254 @@ a { } } +.pc-orders-page, +.pc-order-detail-page { + max-width: 1240px; + margin: 0 auto; + padding: 28px 24px 64px; +} + +.pc-orders-page__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} + +.pc-orders-page__eyebrow { + color: var(--brand-seal); + font-size: 11px; + letter-spacing: 0.18em; +} + +.pc-orders-page__head h2 { + margin: 6px 0 8px !important; + color: var(--brand-ink) !important; +} + +.pc-orders-page__alert { + margin-bottom: 16px; +} + +.pc-orders-card { + border-color: var(--line); + background: var(--paper-warm); +} + +.pc-orders-login { + max-width: 520px; + margin: 60px auto; + text-align: center; + border-color: var(--line); + background: var(--paper-warm); +} + +.pc-orders-login .brand-logo { + margin-bottom: 16px; +} + +.pc-order-cell { + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} + +.pc-order-cell strong { + color: var(--ink); + font-size: 13px; + font-weight: 600; +} + +.pc-order-cell span { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink-secondary); + font-size: 11px; +} + +.pc-order-link { + padding: 0; + border: 0; + background: none; + color: var(--brand-ink); + font-family: 'Inter', monospace; + font-size: 12px; + cursor: pointer; +} + +.pc-order-link:hover { + color: var(--brand-seal); + text-decoration: underline; + text-underline-offset: 3px; +} + +.pc-order-muted { + color: var(--ink-muted) !important; +} + +.pc-order-amount { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; +} + +.pc-order-amount strong { + color: var(--brand-ink); + font-family: 'Inter', monospace; + font-size: 13px; +} + +.pc-order-amount span { + color: var(--ink-muted); + font-size: 11px; +} + +.pc-orders-empty { + padding: 32px 0; + color: var(--ink-muted); +} + +.pc-order-detail-page__crumb { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.pc-order-detail-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin: 16px 0 22px; + padding: 22px 24px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--brand-ink); + color: #f0f4f2; +} + +.pc-order-detail-hero__no { + color: #b9cbc3; + font-family: 'Inter', monospace; + font-size: 11px; + letter-spacing: 0.06em; +} + +.pc-order-detail-hero h2 { + margin: 8px 0 12px !important; + color: #f6f2e8 !important; +} + +.pc-order-detail-hero__amount { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + flex: 0 0 auto; +} + +.pc-order-detail-hero__amount span, +.pc-order-detail-hero__amount small { + color: #b9cbc3; + font-size: 11px; +} + +.pc-order-detail-hero__amount strong { + color: #e8c9a6; + font-family: 'Inter', monospace; + font-size: 30px; +} + +.pc-order-detail-card { + margin-bottom: 16px; + border-color: var(--line); + background: var(--paper-warm); +} + +.pc-order-detail-side { + position: sticky; + top: 84px; +} + +.pc-order-action-card { + border-color: var(--line); + background: var(--paper-warm); +} + +.pc-order-action-card .ant-card-head-title { + color: var(--brand-ink); +} + +.pc-order-danger { + color: var(--brand-seal); + font-family: 'Inter', monospace; +} + +.pc-order-pre { + color: var(--ink-secondary); + font-size: 12px; + line-height: 1.75; + white-space: pre-wrap; + word-break: break-word; +} + +.pc-order-records { + display: flex; + flex-direction: column; +} + +.pc-order-record { + padding: 14px 0; + border-bottom: 1px dashed var(--line); +} + +.pc-order-record:last-child { + border-bottom: 0; +} + +.pc-order-record__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.pc-order-record__head strong { + color: var(--brand-ink); + font-size: 13px; +} + +.pc-order-record__head span { + color: var(--ink-muted); + font-size: 11px; +} + +.pc-order-dispute-tag { + margin: 16px 0 12px; +} + +@media (max-width: 767px) { + .pc-orders-page__head, + .pc-order-detail-hero { + flex-direction: column; + align-items: flex-start; + } + + .pc-order-detail-hero__amount { + align-items: flex-start; + } + + .pc-order-detail-side { + position: static; + } +} + .empty-panel { display: flex; justify-content: center;