feat(admin): 新增后台订单列表与详情抽屉
This commit is contained in:
+3
-3
@@ -182,12 +182,12 @@ dist/assets/MobileListingDetailPage-*.js 27.31 kB │ gzip 10.70 kB
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单与消息页仍是占位,下单、客服会话尚未接入。
|
||||
2. 后台 43 个页面目前只有仪表盘是真实页面,其余为统一占位。
|
||||
2. 后台除仪表盘、订单列表与订单详情外,其余页面仍为统一占位。
|
||||
3. 分销后台尚未做品牌轻改。
|
||||
4. 后台菜单权限码基于旧项目推断,后续需与后端权限表逐一核对。
|
||||
4. 后台菜单权限码基于旧项目推断:订单页已核对为 `order:list`(列表)与 `order:view`(详情),其余页面仍需逐一核对。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 首屏包体仍偏大。
|
||||
7. 本移动端批次尚未提交 Git。
|
||||
7. 移动端批次已提交为 `ef01486`;后台订单列表与详情在后续批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Phase 2 验收报告(后台订单管理)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:后台订单列表、订单详情抽屉、通用列表/详情原型
|
||||
> 结论:**本切片通过,可继续迁移其它后台列表页**
|
||||
|
||||
---
|
||||
|
||||
## 1. 本批次完成内容
|
||||
|
||||
### 1.1 接口与类型
|
||||
|
||||
- `fetchAdminOrders(query)` → `GET /api/admin/orders`
|
||||
- 支持 `page`、`page_size`、`keyword`、`status`、`handoff_status`、`handoff_mode`、`settlement_status`、`offline_settlement_status`、`user_id`
|
||||
- 空字符串参数自动剔除,避免后端收到无效筛选
|
||||
- `fetchAdminOrder(id)` → `GET /api/admin/orders/:id`
|
||||
- 新增列表 `AdminOrderListItem`、分页 `AdminOrderPage`、详情 `AdminOrderDetail`、结账 `AdminCheckoutInfo`、操作位 `AdminOrderActions` 等类型
|
||||
- 列表只使用列表 DTO;详情字段只从详情接口读取,符合后端“最小暴露”设计
|
||||
|
||||
### 1.2 权限码核对
|
||||
|
||||
| 入口 | 后端实际权限码 | 前端菜单 |
|
||||
| --- | --- | --- |
|
||||
| 订单列表 | `order:list` | 已从推断值 `order:view` 修正为 `order:list` |
|
||||
| 订单详情 | `order:view` | 无 `order:view` 时隐藏详情入口与行点击,操作列显示“无详情权限” |
|
||||
|
||||
### 1.3 通用组件原型
|
||||
|
||||
位于 `frontend/src/features/admin/components/`,供后续后台页面复用:
|
||||
|
||||
- `AdminPageHeader`:眉标 + 标题 + 描述 + 标签 + 右侧操作
|
||||
- `FilterBar` / `FilterField`:可换行筛选区,统一查询/重置按钮与回车提交
|
||||
- `DataTable`:卡片容器、空态、分页文案、行点击样式、横向滚动
|
||||
- `DetailDrawer`:抽屉标题区、状态标签、分段 `Descriptions`、加载/错误/空态
|
||||
- `StatusTag`:统一状态标签外观
|
||||
|
||||
配套:
|
||||
|
||||
- `frontend/src/features/admin/labels.ts`:订单、交接、结算、线下结算、结账、退款状态文案与色调
|
||||
- `frontend/src/shared/utils/format.ts`:金额(分→元)、日期时间、数字、文本空值
|
||||
|
||||
### 1.4 订单列表页
|
||||
|
||||
`frontend/src/features/admin/AdminOrdersPage.tsx`
|
||||
|
||||
- 头部:订单总数、用户筛选提示、刷新按钮
|
||||
- 筛选:关键词、订单状态、交接状态、订单类型、结算状态、线下结算
|
||||
- 列:订单信息、商品、租客/号主、金额、订单状态、交接、结算、操作
|
||||
- 分页:10 / 20 / 50,切换页长自动回到第一页
|
||||
- 支持 `?keyword=` 与 `?user_id=` 深链(为后续用户管理“明细”入口准备)
|
||||
- 加载使用 `keepPreviousData`,翻页不闪空表
|
||||
|
||||
### 1.5 订单详情抽屉
|
||||
|
||||
`frontend/src/features/admin/AdminOrderDetailDrawer.tsx`
|
||||
|
||||
- 标题区展示订单号、商品标题、创建时间、订单/交接/结算三个状态标签
|
||||
- 分段:订单概览、商品与账号、交易双方、金额明细、交接与结算
|
||||
- 存在结账单时展示协商轮次、消耗品、哈夫币、押金扣除、租客可退、号主到手、短收/超收、结账内容与凭证
|
||||
- 存在进行中申诉时展示申诉单信息
|
||||
- 支持复制订单号、加载骨架、错误重试
|
||||
|
||||
## 2. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(主包 887.21 kB,gzip 289.49 kB,仍超 700 kB 警告)
|
||||
GET /console-jy8k3m/orders 200(Vite SPA 回退)
|
||||
GET /console-jy8k3m/login 200
|
||||
GET /api/admin/orders(未登录) 401
|
||||
GET /api/admin/orders(管理员 Cookie) ok,返回 items/total/page/page_size
|
||||
GET /api/admin/orders?status=renting&handoff_status=received&... ok
|
||||
GET /api/admin/orders/1(不存在) 404 {"code":"not_found","message":"订单不存在"}
|
||||
```
|
||||
|
||||
说明:当前开发库 `rental_orders` 为 0 条,列表验证以空态与接口契约为准;首次产生真实订单后需目视复核列宽与抽屉信息密度。
|
||||
|
||||
## 3. 已知限制
|
||||
|
||||
1. 本批次只读,不包含关闭、退款、强制交接、线下结算等操作按钮;后端操作接口已存在,留待 Phase 4 逐项接入并补齐二次确认与权限校验。
|
||||
2. 详情抽屉未加载交接记录时间线与过程事件,后续可复用 `GET /api/admin/orders/:id/handoff-records` 与 `/process-events`。
|
||||
3. 列表未做列显隐、导出与批量选择。
|
||||
4. 主包体积仍未达标。
|
||||
|
||||
## 4. 下一步建议
|
||||
|
||||
1. 后台商品管理列表 + 商品审核,复用本批 `DataTable` / `FilterBar` / `DetailDrawer`。
|
||||
2. 用户管理列表与用户详情,接通订单深链 `orders?user_id=`。
|
||||
3. 交接记录与过程事件时间线组件。
|
||||
4. 移动端 `/m/orders` 与 `/m/messages` 真实接口。
|
||||
5. 分销后台品牌轻改与 AntD 首包拆分。
|
||||
@@ -365,6 +365,8 @@ hfb_jysh/
|
||||
|
||||
### Phase 2:前端基础设施(1 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成双认证、401 单飞刷新、路由守卫与分站头;通用 `DataTable` / `FilterBar` / `DetailDrawer` / `StatusTag` / `AdminPageHeader` 原型已随后台订单页落地。
|
||||
|
||||
- Axios 客户端、响应解包、错误提示、请求日志。
|
||||
- 用户 Bearer 与管理员 Cookie 双认证。
|
||||
- 401 单飞刷新队列。
|
||||
|
||||
@@ -48,6 +48,11 @@ const AdminDashboardPage = lazy(() =>
|
||||
default: module.AdminDashboardPage,
|
||||
}))
|
||||
)
|
||||
const AdminOrdersPage = lazy(() =>
|
||||
import('@/features/admin/AdminOrdersPage').then(module => ({
|
||||
default: module.AdminOrdersPage,
|
||||
}))
|
||||
)
|
||||
const AdminLayout = lazy(() =>
|
||||
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
|
||||
)
|
||||
@@ -56,7 +61,6 @@ const AdminSectionPlaceholder = lazy(() =>
|
||||
)
|
||||
|
||||
const adminSections: Array<{ path: string; title: string; description: string }> = [
|
||||
{ path: 'orders', title: '订单管理', description: '查看全量订单、交接记录与结账状态。' },
|
||||
{ path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' },
|
||||
{ path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
|
||||
{ path: 'listings', title: '商品管理', description: '查看商品、账号资产与上下架状态。' },
|
||||
@@ -113,6 +117,7 @@ export function AppRouter() {
|
||||
<Route path="change-password" element={<AdminChangePasswordPage />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button, message, Space, Tag, Typography } from 'antd'
|
||||
import { CopyOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { fetchAdminOrder } from '@/features/admin/api'
|
||||
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import {
|
||||
checkoutStatusMap,
|
||||
handoffModeMap,
|
||||
handoffStatusMeta,
|
||||
offlineSettlementStatusMap,
|
||||
orderStatusMap,
|
||||
readStatusMeta,
|
||||
refundStatusMap,
|
||||
settlementModeMap,
|
||||
settlementStatusMap,
|
||||
} from '@/features/admin/labels'
|
||||
import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
import type { AdminOrderDetail } from '@/features/admin/types'
|
||||
|
||||
interface AdminOrderDetailDrawerProps {
|
||||
orderId: number | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function buildSections(order: AdminOrderDetail): DetailSection[] {
|
||||
const sections: DetailSection[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
title: '订单概览',
|
||||
fields: [
|
||||
{ key: 'order_no', label: '订单号', value: <span className="admin-mono">{order.order_no}</span> },
|
||||
{
|
||||
key: 'status',
|
||||
label: '订单状态',
|
||||
value: <StatusTag meta={readStatusMeta(orderStatusMap, order.status)} />,
|
||||
},
|
||||
{ key: 'created_at', label: '创建时间', value: formatDateTime(order.created_at) },
|
||||
{ key: 'updated_at', label: '更新时间', value: formatDateTime(order.updated_at) },
|
||||
{ key: 'payment_deadline', label: '支付截止', value: formatDateTime(order.payment_deadline_at) },
|
||||
{ key: 'listing_no', label: '商品编号', value: formatText(order.listing_no) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
title: '商品与账号',
|
||||
fields: [
|
||||
{ key: 'title', label: '商品标题', value: formatText(order.title), span: 2 },
|
||||
{ key: 'server_region', label: '区服', value: formatText(order.server_region) },
|
||||
{ key: 'login_platform', label: '登录平台', value: formatText(order.login_platform) },
|
||||
{ key: 'account_source', label: '账号来源', value: formatText(order.account_source) },
|
||||
{
|
||||
key: 'duration',
|
||||
label: '预计租期',
|
||||
value: order.estimated_duration_hours ? `${order.estimated_duration_hours} 小时` : emptyText,
|
||||
},
|
||||
{ key: 'estimated_end', label: '预计结束', value: formatDateTime(order.estimated_end_at) },
|
||||
{ key: 'rented_at', label: '起租时间', value: formatDateTime(order.rented_at) },
|
||||
{
|
||||
key: 'distribution_site',
|
||||
label: '来源分站',
|
||||
value: order.distribution_site_id ? `#${order.distribution_site_id}` : '主站',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'parties',
|
||||
title: '交易双方',
|
||||
fields: [
|
||||
{
|
||||
key: 'owner',
|
||||
label: '号主',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<span>{formatText(order.owner_phone)}</span>
|
||||
<Typography.Text type="secondary">ID {order.owner_id}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'renter',
|
||||
label: '租客',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<span>{formatText(order.renter_phone)}</span>
|
||||
<Typography.Text type="secondary">ID {order.renter_id}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'track_code', label: '来源追踪码', value: formatText(order.source_track_code) },
|
||||
{ key: 'account_id', label: '账号 ID', value: formatText(order.account_id) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'amounts',
|
||||
title: '金额明细',
|
||||
fields: [
|
||||
{ key: 'display', label: '展示金额', value: formatMoney(order.display_amount_cent) },
|
||||
{ key: 'rent', label: '租客租金', value: formatMoney(order.rent_amount_cent) },
|
||||
{ key: 'owner_rent', label: '号主租金', value: formatMoney(order.owner_rent_amount_cent) },
|
||||
{ key: 'platform_fee', label: '平台服务费', value: formatMoney(order.platform_fee_cent) },
|
||||
{ key: 'deposit', label: '实收押金', value: formatMoney(order.deposit_amount_cent) },
|
||||
{ key: 'deposit_original', label: '原始押金', value: formatMoney(order.deposit_original_amount_cent) },
|
||||
{ key: 'deposit_waived', label: '减免押金', value: formatMoney(order.deposit_waived_amount_cent) },
|
||||
{
|
||||
key: 'refund',
|
||||
label: '退款',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<StatusTag meta={readStatusMeta(refundStatusMap, order.refund_status || 'none')} />
|
||||
<span>{formatMoney(order.refund_amount_cent)}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fulfillment',
|
||||
title: '交接与结算',
|
||||
fields: [
|
||||
{
|
||||
key: 'handoff_mode',
|
||||
label: '订单类型',
|
||||
value: <StatusTag meta={readStatusMeta(handoffModeMap, order.handoff_mode)} />,
|
||||
},
|
||||
{ key: 'handoff_status', label: '交接状态', value: <StatusTag meta={handoffStatusMeta(order.handoff_status, order)} /> },
|
||||
{
|
||||
key: 'settlement_mode',
|
||||
label: '结算方式',
|
||||
value: <StatusTag meta={readStatusMeta(settlementModeMap, order.settlement_mode)} />,
|
||||
},
|
||||
{
|
||||
key: 'settlement_status',
|
||||
label: '结算状态',
|
||||
value: <StatusTag meta={readStatusMeta(settlementStatusMap, order.settlement_status)} />,
|
||||
},
|
||||
{
|
||||
key: 'offline_status',
|
||||
label: '线下结算',
|
||||
value: (
|
||||
<StatusTag
|
||||
meta={readStatusMeta(
|
||||
offlineSettlementStatusMap,
|
||||
order.offline_settlement_status || 'none'
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'offline_amount', label: '线下结算金额', value: formatMoney(order.offline_settlement_amount_cent) },
|
||||
{ key: 'offline_remark', label: '线下结算备注', value: formatText(order.offline_settlement_remark), span: 2 },
|
||||
{ key: 'offline_at', label: '线下结算时间', value: formatDateTime(order.offline_settled_at) },
|
||||
{
|
||||
key: 'deposit_hold',
|
||||
label: '押金冻结',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<span>{formatText(order.deposit_hold_status)}</span>
|
||||
<span>{formatMoney(order.deposit_hold_amount_cent)}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'deposit_hold_reason', label: '冻结原因', value: formatText(order.deposit_hold_reason), span: 2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (order.checkout) {
|
||||
const checkout = order.checkout
|
||||
sections.push({
|
||||
key: 'checkout',
|
||||
title: '结账单',
|
||||
fields: [
|
||||
{
|
||||
key: 'checkout_status',
|
||||
label: '结账状态',
|
||||
value: <StatusTag meta={readStatusMeta(checkoutStatusMap, checkout.status)} />,
|
||||
},
|
||||
{
|
||||
key: 'checkout_round',
|
||||
label: '协商轮次',
|
||||
value: `${checkout.round_count} / ${checkout.max_rounds}`,
|
||||
},
|
||||
{ key: 'checkout_consumable', label: '消耗品费用', value: formatMoney(checkout.consumable_amount_cent) },
|
||||
{ key: 'checkout_coin', label: '哈夫币消耗', value: `${checkout.coin_consumed_m} M` },
|
||||
{ key: 'checkout_other', label: '其他费用', value: formatMoney(checkout.other_amount_cent) },
|
||||
{ key: 'checkout_deduct', label: '押金扣除', value: formatMoney(checkout.deposit_deduct_amount_cent) },
|
||||
{ key: 'checkout_renter_refund', label: '租客可退', value: formatMoney(checkout.renter_refund_amount_cent) },
|
||||
{ key: 'checkout_owner_income', label: '号主到手', value: formatMoney(checkout.owner_income_amount_cent) },
|
||||
{
|
||||
key: 'checkout_shortfall',
|
||||
label: '差额',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<span>短收 {formatMoney(checkout.shortfall_cent)}</span>
|
||||
<span>超收 {formatMoney(checkout.overshoot_amount_cent)}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'checkout_time', label: '最近更新', value: formatDateTime(checkout.updated_at) },
|
||||
{
|
||||
key: 'checkout_content',
|
||||
label: '结账内容',
|
||||
span: 2,
|
||||
value: <div className="admin-detail__pre">{checkout.content || emptyText}</div>,
|
||||
},
|
||||
{
|
||||
key: 'checkout_evidence',
|
||||
label: '凭证',
|
||||
span: 2,
|
||||
value:
|
||||
(checkout.evidence_urls?.length ?? 0) > 0 ? (
|
||||
<Space size={4} wrap>
|
||||
{checkout.evidence_urls.map((url, index) => (
|
||||
<a key={url} href={url} target="_blank" rel="noreferrer">
|
||||
<Tag>凭证 {index + 1}</Tag>
|
||||
</a>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
emptyText
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
if (order.active_dispute) {
|
||||
sections.push({
|
||||
key: 'dispute',
|
||||
title: '进行中的申诉',
|
||||
fields: [
|
||||
{ key: 'dispute_id', label: '申诉单号', value: `#${order.active_dispute.id}` },
|
||||
{ key: 'dispute_type', label: '申诉类型', value: formatText(order.active_dispute.type) },
|
||||
{ key: 'dispute_status', label: '状态', value: formatText(order.active_dispute.status) },
|
||||
{
|
||||
key: 'dispute_initiator',
|
||||
label: '发起方',
|
||||
value: `${order.active_dispute.initiator_type} · ${order.active_dispute.initiator_id}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export function AdminOrderDetailDrawer({ orderId, open, onClose }: AdminOrderDetailDrawerProps) {
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order', orderId],
|
||||
queryFn: () => fetchAdminOrder(orderId as number),
|
||||
enabled: open && Boolean(orderId),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
const order = query.data
|
||||
const errorMessage = query.error instanceof Error ? query.error.message : null
|
||||
|
||||
const copyOrderNo = async () => {
|
||||
if (!order) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(order.order_no)
|
||||
message.success('订单号已复制')
|
||||
} catch {
|
||||
message.warning('当前环境不支持剪贴板,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
loading={query.isPending && Boolean(orderId)}
|
||||
error={query.isError ? errorMessage || '后台会话可能已失效,请重试。' : null}
|
||||
onRetry={() => void query.refetch()}
|
||||
title={order?.order_no ? <span className="admin-mono">{order.order_no}</span> : '订单详情'}
|
||||
subtitle={
|
||||
order ? (
|
||||
<span>
|
||||
{order.title} · 创建于 {dayjs(order.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
tags={
|
||||
order ? (
|
||||
<Space size={6} wrap>
|
||||
<StatusTag meta={readStatusMeta(orderStatusMap, order.status)} />
|
||||
<StatusTag meta={handoffStatusMeta(order.handoff_status, order)} />
|
||||
<StatusTag meta={readStatusMeta(settlementStatusMap, order.settlement_status)} />
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
sections={order ? buildSections(order) : []}
|
||||
footer={
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyOrderNo()} disabled={!order}>
|
||||
复制订单号
|
||||
</Button>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { fetchAdminOrders } from '@/features/admin/api'
|
||||
import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import {
|
||||
handoffModeOptions,
|
||||
handoffStatusMeta,
|
||||
handoffStatusOptions,
|
||||
offlineSettlementStatusOptions,
|
||||
orderStatusMap,
|
||||
orderStatusOptions,
|
||||
readStatusMeta,
|
||||
settlementStatusMap,
|
||||
settlementStatusOptions,
|
||||
} from '@/features/admin/labels'
|
||||
import type { AdminOrderListItem, AdminOrderQuery } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
const emptyFilters: AdminOrderQuery = {
|
||||
keyword: '',
|
||||
status: '',
|
||||
handoff_status: '',
|
||||
handoff_mode: '',
|
||||
settlement_status: '',
|
||||
offline_settlement_status: '',
|
||||
}
|
||||
|
||||
function buildColumns(
|
||||
onOpen: (row: AdminOrderListItem) => void,
|
||||
canViewDetail: boolean
|
||||
): TableColumnsType<AdminOrderListItem> {
|
||||
return [
|
||||
{
|
||||
title: '订单',
|
||||
dataIndex: 'order_no',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
{canViewDetail ? (
|
||||
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
|
||||
{row.order_no}
|
||||
</button>
|
||||
) : (
|
||||
<span className="admin-mono">{row.order_no}</span>
|
||||
)}
|
||||
<span>{formatDateTime(row.created_at)}</span>
|
||||
<span>商品 {formatText(row.listing_no || row.listing_id)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'title',
|
||||
width: 210,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<strong>{formatText(row.title)}</strong>
|
||||
<span>
|
||||
{formatText(row.server_region)} · {formatText(row.login_platform)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租客 / 号主',
|
||||
key: 'parties',
|
||||
width: 190,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>租客 {formatText(row.renter_phone)}</span>
|
||||
{row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
|
||||
<span className="admin-stack__accent">号主 平台代管</span>
|
||||
) : (
|
||||
<span>号主 {formatText(row.owner_phone)}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-amount">
|
||||
<strong>{formatMoney(row.rent_amount_cent)}</strong>
|
||||
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
|
||||
{typeof row.owner_income_amount_cent === 'number' ? (
|
||||
<span className="admin-amount__accent">
|
||||
到手 {formatMoney(row.owner_income_amount_cent)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单状态',
|
||||
dataIndex: 'status',
|
||||
width: 170,
|
||||
render: value => <StatusTag meta={readStatusMeta(orderStatusMap, value)} />,
|
||||
},
|
||||
{
|
||||
title: '交接',
|
||||
key: 'handoff',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack admin-stack--tiny">
|
||||
<StatusTag meta={handoffStatusMeta(row.handoff_status, row)} />
|
||||
{row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
|
||||
<Tag color="geekblue">平台代管</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算',
|
||||
dataIndex: 'settlement_status',
|
||||
width: 140,
|
||||
render: value => <StatusTag meta={readStatusMeta(settlementStatusMap, value)} />,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 96,
|
||||
fixed: 'right',
|
||||
render: (_, row) =>
|
||||
canViewDetail ? (
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
|
||||
详情
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无详情权限</Typography.Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminOrdersPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
|
||||
const initialUserId = Number(searchParams.get('user_id')) || undefined
|
||||
|
||||
const [draft, setDraft] = useState<AdminOrderQuery>(() => ({
|
||||
...emptyFilters,
|
||||
keyword: initialKeyword,
|
||||
user_id: initialUserId,
|
||||
}))
|
||||
const [applied, setApplied] = useState<AdminOrderQuery>(() => ({
|
||||
...emptyFilters,
|
||||
keyword: initialKeyword,
|
||||
user_id: initialUserId,
|
||||
}))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [detailId, setDetailId] = useState<number | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const canViewDetail = Boolean(permissions?.includes('*') || permissions?.includes('order:view'))
|
||||
|
||||
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
|
||||
queryKey: ['admin-orders', applied, page, pageSize],
|
||||
queryFn: () => fetchAdminOrders({ ...applied, page, page_size: pageSize }),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
buildColumns(row => {
|
||||
setDetailId(row.id)
|
||||
setDetailOpen(true)
|
||||
}, canViewDetail),
|
||||
[canViewDetail]
|
||||
)
|
||||
|
||||
const patchFilter = (patch: Partial<AdminOrderQuery>) => {
|
||||
setDraft(current => ({ ...current, ...patch }))
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
setApplied({ ...draft, keyword: draft.keyword?.trim() ?? '' })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyFilters)
|
||||
setApplied(emptyFilters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = Object.entries(draft).some(
|
||||
([, value]) => value !== '' && value != null
|
||||
)
|
||||
|
||||
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 交易"
|
||||
title="订单管理"
|
||||
description="全量订单、交接进度、金额与结算状态统一在这里核对;点击订单号查看详情。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">共 {data?.total ?? 0} 单</Tag>
|
||||
{applied.user_id ? (
|
||||
<Tag color="purple">筛选用户 #{applied.user_id}</Tag>
|
||||
) : null}
|
||||
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
|
||||
刷新数据
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FilterBar
|
||||
onSearch={applyFilters}
|
||||
onReset={hasActiveFilters ? resetFilters : undefined}
|
||||
loading={isFetching}
|
||||
>
|
||||
<FilterField label="关键词" width={300}>
|
||||
<Input
|
||||
allowClear
|
||||
value={draft.keyword}
|
||||
placeholder="订单号 / 商品编号 / 账号 / 手机号"
|
||||
onChange={event => patchFilter({ keyword: event.target.value })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="订单状态" width={180}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.status || undefined}
|
||||
placeholder="全部状态"
|
||||
options={orderStatusOptions}
|
||||
onChange={value => patchFilter({ status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="交接状态" width={180}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.handoff_status || undefined}
|
||||
placeholder="全部交接"
|
||||
options={handoffStatusOptions}
|
||||
onChange={value => patchFilter({ handoff_status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="订单类型" width={150}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.handoff_mode || undefined}
|
||||
placeholder="全部类型"
|
||||
options={handoffModeOptions}
|
||||
onChange={value => patchFilter({ handoff_mode: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="结算状态" width={160}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.settlement_status || undefined}
|
||||
placeholder="全部结算"
|
||||
options={settlementStatusOptions}
|
||||
onChange={value => patchFilter({ settlement_status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="线下结算" width={160}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.offline_settlement_status || undefined}
|
||||
placeholder="全部"
|
||||
options={offlineSettlementStatusOptions}
|
||||
onChange={value => patchFilter({ offline_settlement_status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="订单列表没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminOrderListItem>
|
||||
title={<span>订单列表</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isFetching}
|
||||
total={data?.total ?? 0}
|
||||
page={data?.page ?? page}
|
||||
pageSize={data?.page_size ?? pageSize}
|
||||
onPageChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
}}
|
||||
onRowClick={
|
||||
canViewDetail
|
||||
? row => {
|
||||
setDetailId(row.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyDescription="没有匹配的订单,换个筛选条件试试"
|
||||
/>
|
||||
|
||||
{canViewDetail ? (
|
||||
<AdminOrderDetailDrawer
|
||||
orderId={detailId}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import type { ApiResponse } from '@/shared/api/types'
|
||||
import type {
|
||||
AdminCaptcha,
|
||||
AdminLoginData,
|
||||
AdminOrderDetail,
|
||||
AdminOrderPage,
|
||||
AdminOrderQuery,
|
||||
AdminUser,
|
||||
DashboardData,
|
||||
} from '@/features/admin/types'
|
||||
@@ -47,3 +50,26 @@ export async function fetchAdminDashboard() {
|
||||
const { data } = await adminClient.get<ApiResponse<DashboardData>>('/admin/dashboard')
|
||||
return data.data
|
||||
}
|
||||
|
||||
function compactParams(query: AdminOrderQuery) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value != null)
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders(query: AdminOrderQuery) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminOrderPage>>>('/admin/orders', {
|
||||
params: compactParams(query),
|
||||
})
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(id: number | string) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminOrderDetail>>(`/admin/orders/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Typography } from 'antd'
|
||||
|
||||
interface AdminPageHeaderProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description?: ReactNode
|
||||
meta?: ReactNode
|
||||
extra?: ReactNode
|
||||
}
|
||||
|
||||
/** 后台页面统一抬头:眉标 + 标题 + 说明 + 右侧操作。 */
|
||||
export function AdminPageHeader({
|
||||
eyebrow = 'JIEYOU OPS',
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
extra,
|
||||
}: AdminPageHeaderProps) {
|
||||
return (
|
||||
<header className="admin-page__head">
|
||||
<div className="admin-page__head-main">
|
||||
<Typography.Text className="admin-page__eyebrow">{eyebrow}</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{description ? (
|
||||
<Typography.Text type="secondary" className="admin-page__desc">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{meta ? <div className="admin-page__meta">{meta}</div> : null}
|
||||
</div>
|
||||
{extra ? <div className="admin-page__actions">{extra}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card, Empty, Table, type TableColumnsType, type TableProps } from 'antd'
|
||||
|
||||
interface DataTableProps<T extends object> {
|
||||
title?: ReactNode
|
||||
extra?: ReactNode
|
||||
columns: TableColumnsType<T>
|
||||
dataSource: T[]
|
||||
rowKey: TableProps<T>['rowKey']
|
||||
loading?: boolean
|
||||
total?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
onPageChange?: (page: number, pageSize: number) => void
|
||||
onRowClick?: (row: T) => void
|
||||
emptyDescription?: ReactNode
|
||||
scroll?: TableProps<T>['scroll']
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台表格原型:统一卡片容器、空态、分页文案和行点击样式。
|
||||
* 列定义仍由页面提供,保证每页可以按业务定制。
|
||||
*/
|
||||
export function DataTable<T extends object>({
|
||||
title,
|
||||
extra,
|
||||
columns,
|
||||
dataSource,
|
||||
rowKey,
|
||||
loading,
|
||||
total = 0,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
onPageChange,
|
||||
onRowClick,
|
||||
emptyDescription = '没有符合条件的数据',
|
||||
scroll = { x: 'max-content' },
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
return (
|
||||
<Card className={['admin-table-card', className].filter(Boolean).join(' ')} title={title} extra={extra}>
|
||||
<Table<T>
|
||||
rowKey={rowKey}
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
scroll={scroll}
|
||||
onRow={
|
||||
onRowClick
|
||||
? row => ({
|
||||
onClick: () => onRowClick(row),
|
||||
className: 'admin-table__row--clickable',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />,
|
||||
}}
|
||||
pagination={
|
||||
onPageChange
|
||||
? {
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
|
||||
onChange: onPageChange,
|
||||
}
|
||||
: false
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Alert, Button, Descriptions, Drawer, Empty, Skeleton } from 'antd'
|
||||
|
||||
export interface DetailField {
|
||||
key: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
span?: number
|
||||
}
|
||||
|
||||
export interface DetailSection {
|
||||
key: string
|
||||
title: string
|
||||
icon?: ReactNode
|
||||
extra?: ReactNode
|
||||
fields: DetailField[]
|
||||
}
|
||||
|
||||
interface DetailDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
tags?: ReactNode
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
onRetry?: () => void
|
||||
sections?: DetailSection[]
|
||||
footer?: ReactNode
|
||||
width?: number
|
||||
emptyDescription?: ReactNode
|
||||
}
|
||||
|
||||
/** 详情抽屉原型:统一标题区、段落标题、描述列表和加载/错误态。 */
|
||||
export function DetailDrawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
tags,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
sections = [],
|
||||
footer,
|
||||
width = 760,
|
||||
emptyDescription = '暂无可展示的详情',
|
||||
}: DetailDrawerProps) {
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={width}
|
||||
footer={footer}
|
||||
destroyOnHidden
|
||||
className="admin-detail-drawer"
|
||||
title={
|
||||
<div className="admin-detail-drawer__head">
|
||||
<div className="admin-detail-drawer__title">{title}</div>
|
||||
{subtitle ? <div className="admin-detail-drawer__subtitle">{subtitle}</div> : null}
|
||||
{tags ? <div className="admin-detail-drawer__tags">{tags}</div> : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 12 }} />
|
||||
) : error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="详情没有加载出来"
|
||||
description={error}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button size="small" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : sections.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
) : (
|
||||
<div className="admin-detail">
|
||||
{sections.map(section => (
|
||||
<section className="admin-detail__section" key={section.key}>
|
||||
<div className="admin-detail__section-head">
|
||||
<h4>
|
||||
{section.icon}
|
||||
{section.title}
|
||||
</h4>
|
||||
{section.extra}
|
||||
</div>
|
||||
<Descriptions
|
||||
size="small"
|
||||
colon={false}
|
||||
column={2}
|
||||
items={section.fields.map(field => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
children: field.value,
|
||||
span: field.span,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Flex } from 'antd'
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
|
||||
interface FilterBarProps {
|
||||
children: ReactNode
|
||||
onSearch?: () => void
|
||||
onReset?: () => void
|
||||
loading?: boolean
|
||||
extra?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页筛选条原型:字段区自适应换行,操作区固定在右侧。
|
||||
* 后续 40+ 个后台列表页复用它,避免每页各写一套间距与按钮顺序。
|
||||
*/
|
||||
export function FilterBar({ children, onSearch, onReset, loading, extra }: FilterBarProps) {
|
||||
return (
|
||||
<form
|
||||
className="admin-filter-bar"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
onSearch?.()
|
||||
}}
|
||||
>
|
||||
<div className="admin-filter-bar__fields">{children}</div>
|
||||
<Flex className="admin-filter-bar__actions" gap={8} align="flex-end">
|
||||
{extra}
|
||||
{onReset ? (
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset} disabled={loading}>
|
||||
重置
|
||||
</Button>
|
||||
) : null}
|
||||
{onSearch ? (
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
) : null}
|
||||
</Flex>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterFieldProps {
|
||||
label: string
|
||||
children: ReactNode
|
||||
width?: number
|
||||
}
|
||||
|
||||
export function FilterField({ label, children, width }: FilterFieldProps) {
|
||||
return (
|
||||
<div className="admin-filter-field" style={width ? { width } : undefined}>
|
||||
<span className="admin-filter-field__label">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tag } from 'antd'
|
||||
|
||||
import type { StatusMeta } from '@/features/admin/labels'
|
||||
|
||||
interface StatusTagProps {
|
||||
meta: StatusMeta
|
||||
icon?: ReactNode
|
||||
bordered?: boolean
|
||||
}
|
||||
|
||||
export function StatusTag({ meta, icon, bordered = false }: StatusTagProps) {
|
||||
return (
|
||||
<Tag color={meta.tone} bordered={bordered} className="admin-status-tag">
|
||||
{icon}
|
||||
{meta.label}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
export type TagTone =
|
||||
| 'default'
|
||||
| 'success'
|
||||
| 'processing'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'gold'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'red'
|
||||
| 'purple'
|
||||
| 'geekblue'
|
||||
| 'magenta'
|
||||
| 'volcano'
|
||||
| 'green'
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string
|
||||
tone: TagTone
|
||||
}
|
||||
|
||||
export interface StatusOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function toOptions(map: Record<string, StatusMeta>): StatusOption[] {
|
||||
return Object.entries(map).map(([value, meta]) => ({ value, label: meta.label }))
|
||||
}
|
||||
|
||||
export const orderStatusMap: Record<string, StatusMeta> = {
|
||||
pending_payment: { label: '待支付', tone: 'gold' },
|
||||
pending_handoff: { label: '待交接', tone: 'blue' },
|
||||
renting: { label: '使用中', tone: 'cyan' },
|
||||
overdue: { label: '已逾期', tone: 'volcano' },
|
||||
pending_checkout_confirm: { label: '待号主确认结账', tone: 'orange' },
|
||||
pending_checkout_accept: { label: '待租客确认修正', tone: 'orange' },
|
||||
checkout_disputing: { label: '结账争议中', tone: 'red' },
|
||||
disputing: { label: '申诉中', tone: 'red' },
|
||||
completed: { label: '已完成', tone: 'success' },
|
||||
cancelled: { label: '租客已取消', tone: 'default' },
|
||||
closed: { label: '客服已关闭', tone: 'default' },
|
||||
abnormal: { label: '客服介入', tone: 'magenta' },
|
||||
}
|
||||
|
||||
export const handoffStatusMap: Record<string, StatusMeta> = {
|
||||
none: { label: '未开始', tone: 'default' },
|
||||
pending_owner: { label: '待号主交接', tone: 'blue' },
|
||||
owner_timeout: { label: '号主交接超时', tone: 'volcano' },
|
||||
pending_renter_confirm: { label: '待租客确认', tone: 'blue' },
|
||||
received: { label: '租客已收号', tone: 'cyan' },
|
||||
return_overdue: { label: '归还逾期', tone: 'volcano' },
|
||||
pending_owner_checkout: { label: '待号主确认结账', tone: 'orange' },
|
||||
pending_renter_checkout: { label: '待租客确认修正', tone: 'orange' },
|
||||
checkout_disputed: { label: '结账争议中', tone: 'red' },
|
||||
returned: { label: '已归还', tone: 'success' },
|
||||
owner_return_confirm_timeout: { label: '号主确认结账超时', tone: 'red' },
|
||||
owner_checkout_confirm_timeout: { label: '号主确认结账超时', tone: 'red' },
|
||||
renter_confirm_timeout: { label: '租客确认超时', tone: 'red' },
|
||||
cancelled: { label: '租客已取消', tone: 'default' },
|
||||
admin_closed: { label: '客服已关闭', tone: 'default' },
|
||||
admin_abnormal: { label: '客服介入', tone: 'magenta' },
|
||||
arbitrated: { label: '已仲裁', tone: 'purple' },
|
||||
}
|
||||
|
||||
export const settlementStatusMap: Record<string, StatusMeta> = {
|
||||
unsettled: { label: '未结算', tone: 'default' },
|
||||
pending: { label: '待结算', tone: 'gold' },
|
||||
frozen: { label: '冻结中', tone: 'magenta' },
|
||||
settled: { label: '已结算', tone: 'success' },
|
||||
refunded: { label: '已退款', tone: 'blue' },
|
||||
cancelled: { label: '结算已取消', tone: 'default' },
|
||||
closed: { label: '结算已关闭', tone: 'default' },
|
||||
disputed: { label: '争议中', tone: 'red' },
|
||||
arbitrated: { label: '已仲裁', tone: 'purple' },
|
||||
}
|
||||
|
||||
export const offlineSettlementStatusMap: Record<string, StatusMeta> = {
|
||||
none: { label: '无需线下结算', tone: 'default' },
|
||||
pending: { label: '待确认线下结算', tone: 'gold' },
|
||||
settled: { label: '已确认线下结算', tone: 'success' },
|
||||
}
|
||||
|
||||
export const handoffModeMap: Record<string, StatusMeta> = {
|
||||
owner: { label: '正常订单', tone: 'default' },
|
||||
platform: { label: '平台代管', tone: 'geekblue' },
|
||||
}
|
||||
|
||||
export const settlementModeMap: Record<string, StatusMeta> = {
|
||||
owner_wallet: { label: '号主钱包', tone: 'default' },
|
||||
platform_managed: { label: '平台代管', tone: 'geekblue' },
|
||||
}
|
||||
|
||||
export const checkoutStatusMap: Record<string, StatusMeta> = {
|
||||
submitted: { label: '待确认', tone: 'gold' },
|
||||
countered: { label: '已修正待确认', tone: 'orange' },
|
||||
accepted: { label: '已接受', tone: 'success' },
|
||||
disputed: { label: '争议中', tone: 'red' },
|
||||
settled: { label: '仲裁已结案', tone: 'purple' },
|
||||
}
|
||||
|
||||
export const refundStatusMap: Record<string, StatusMeta> = {
|
||||
none: { label: '未退款', tone: 'default' },
|
||||
pending: { label: '待退款', tone: 'gold' },
|
||||
pending_review: { label: '待客服审核退款', tone: 'orange' },
|
||||
refunding: { label: '退款中', tone: 'processing' },
|
||||
refunded: { label: '已退款', tone: 'success' },
|
||||
failed: { label: '退款失败', tone: 'red' },
|
||||
}
|
||||
|
||||
export function readStatusMeta(map: Record<string, StatusMeta>, value?: string | null): StatusMeta {
|
||||
if (!value) return { label: '—', tone: 'default' }
|
||||
return map[value] ?? { label: value, tone: 'default' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台代管订单的交接动作由客服执行,展示文案需要区别于普通号主订单。
|
||||
*/
|
||||
export function handoffStatusMeta(
|
||||
status?: string | null,
|
||||
order?: { handoff_mode?: string; settlement_mode?: string } | null
|
||||
) {
|
||||
const platformManaged =
|
||||
order?.handoff_mode === 'platform' || order?.settlement_mode === 'platform_managed'
|
||||
if (!platformManaged || !status) return readStatusMeta(handoffStatusMap, status)
|
||||
const platformCopy: Record<string, StatusMeta> = {
|
||||
pending_owner: { label: '待客服交接', tone: 'blue' },
|
||||
owner_timeout: { label: '客服交接超时', tone: 'volcano' },
|
||||
pending_owner_checkout: { label: '待客服确认结账', tone: 'orange' },
|
||||
owner_checkout_confirm_timeout: { label: '客服确认结账超时', tone: 'red' },
|
||||
}
|
||||
return platformCopy[status] ?? readStatusMeta(handoffStatusMap, status)
|
||||
}
|
||||
|
||||
export const orderStatusOptions = toOptions(orderStatusMap)
|
||||
|
||||
export const handoffStatusOptions = [
|
||||
'pending_owner',
|
||||
'owner_timeout',
|
||||
'pending_renter_confirm',
|
||||
'received',
|
||||
'return_overdue',
|
||||
'pending_owner_checkout',
|
||||
'pending_renter_checkout',
|
||||
'checkout_disputed',
|
||||
'returned',
|
||||
'admin_closed',
|
||||
'admin_abnormal',
|
||||
].map(value => ({ value, label: handoffStatusMap[value].label }))
|
||||
|
||||
export const handoffModeOptions = toOptions(handoffModeMap)
|
||||
export const settlementStatusOptions = toOptions(settlementStatusMap)
|
||||
export const offlineSettlementStatusOptions = ['pending', 'settled'].map(value => ({
|
||||
value,
|
||||
label: offlineSettlementStatusMap[value].label,
|
||||
}))
|
||||
@@ -62,3 +62,149 @@ export interface DashboardData {
|
||||
recent_orders: RecentOrder[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface AdminOrderQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
handoff_status?: string
|
||||
handoff_mode?: string
|
||||
settlement_status?: string
|
||||
offline_settlement_status?: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
/** 列表接口只返回表格需要的字段,敏感详情必须再请求详情接口。 */
|
||||
export interface AdminOrderListItem {
|
||||
id: number
|
||||
order_no: string
|
||||
listing_id: number
|
||||
listing_no: string
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rent_amount_cent: number
|
||||
deposit_amount_cent: number
|
||||
status: string
|
||||
handoff_status: string
|
||||
handoff_mode: string
|
||||
settlement_mode: string
|
||||
settlement_status: string
|
||||
created_at: string
|
||||
owner_income_amount_cent?: number | null
|
||||
}
|
||||
|
||||
export interface AdminOrderPage {
|
||||
items: AdminOrderListItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface AdminCheckoutInfo {
|
||||
id: number
|
||||
order_id: number
|
||||
initiated_by: number
|
||||
status: string
|
||||
round_count: number
|
||||
turn: string
|
||||
max_rounds: number
|
||||
content: string
|
||||
evidence_urls: string[]
|
||||
rent_amount_cent?: number | null
|
||||
owner_rent_amount_cent?: number | null
|
||||
platform_fee_cent?: number | null
|
||||
deposit_amount_cent: number
|
||||
consumable_amount_cent: number
|
||||
coin_consumed_m: number
|
||||
other_amount_cent: number
|
||||
deposit_deduct_amount_cent: number
|
||||
renter_refund_amount_cent?: number | null
|
||||
owner_income_amount_cent?: number | null
|
||||
shortfall_cent: number
|
||||
overshoot_amount_cent: number
|
||||
owner_adjustment_reason?: string
|
||||
owner_adjusted_at?: string | null
|
||||
renter_confirmed_at?: string | null
|
||||
renter_rejected_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminActionState {
|
||||
enabled: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AdminOrderActions {
|
||||
reset_handoff?: AdminActionState
|
||||
platform_handoff?: AdminActionState
|
||||
force_handoff?: AdminActionState
|
||||
platform_checkout_confirm?: AdminActionState
|
||||
platform_checkout_counter?: AdminActionState
|
||||
platform_checkout_dispute?: AdminActionState
|
||||
platform_offline_settlement?: AdminActionState
|
||||
}
|
||||
|
||||
export interface AdminActiveDispute {
|
||||
id: number
|
||||
initiator_id: number
|
||||
initiator_type: string
|
||||
initiator_admin_id?: number
|
||||
type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AdminOrderDetail {
|
||||
id: number
|
||||
order_no: string
|
||||
listing_id: number
|
||||
listing_no: string
|
||||
account_id: number
|
||||
owner_id: number
|
||||
renter_id: number
|
||||
owner_phone?: string
|
||||
renter_phone?: string
|
||||
title: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rented_at?: string | null
|
||||
estimated_duration_hours: number
|
||||
estimated_end_at?: string | null
|
||||
display_amount_cent: number
|
||||
rent_amount_cent?: number | null
|
||||
owner_rent_amount_cent?: number | null
|
||||
platform_fee_cent?: number | null
|
||||
deposit_amount_cent: number
|
||||
deposit_original_amount_cent: number
|
||||
deposit_waived_amount_cent: number
|
||||
account_snapshot?: Record<string, unknown> | null
|
||||
account_source: string
|
||||
status: string
|
||||
handoff_status: string
|
||||
handoff_mode: string
|
||||
settlement_mode: string
|
||||
settlement_status: string
|
||||
offline_settlement_status?: string
|
||||
offline_settlement_amount_cent: number
|
||||
offline_settlement_remark?: string
|
||||
offline_settled_at?: string | null
|
||||
refund_status?: string
|
||||
refund_amount_cent: number
|
||||
deposit_hold_status?: string
|
||||
deposit_hold_amount_cent: number
|
||||
deposit_hold_reason?: string
|
||||
deposit_held_at?: string | null
|
||||
deposit_hold_released_at?: string | null
|
||||
active_dispute?: AdminActiveDispute | null
|
||||
checkout?: AdminCheckoutInfo | null
|
||||
admin_actions?: AdminOrderActions | null
|
||||
payment_deadline_at?: string | null
|
||||
distribution_site_id?: number | null
|
||||
source_track_code?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ const allNavGroups: NavGroup[] = [
|
||||
label: '交易',
|
||||
icon: <ShoppingCartOutlined />,
|
||||
children: [
|
||||
{ key: '/orders', label: '订单管理', permission: 'order:view' },
|
||||
{ key: '/orders', label: '订单管理', permission: 'order:list' },
|
||||
{ key: '/orders/refund-review', label: '退款审核', permission: 'order:close' },
|
||||
{ key: '/disputes', label: '申诉仲裁', permission: 'dispute:view' },
|
||||
],
|
||||
@@ -206,7 +206,7 @@ export function AdminLayout() {
|
||||
{!collapsed ? (
|
||||
<div className="admin-sider__foot">
|
||||
<Typography.Text type="secondary">解忧商行 · 运营后台</Typography.Text>
|
||||
<Typography.Text type="secondary">Phase 1 Shell</Typography.Text>
|
||||
<Typography.Text type="secondary">Phase 2 · 交易与商品</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</Sider>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const EMPTY = '—'
|
||||
|
||||
/** 金额统一以「分」入参,展示为带千分位的两位小数。 */
|
||||
export function formatMoney(cents?: number | null) {
|
||||
if (cents == null || Number.isNaN(cents)) return EMPTY
|
||||
return `¥${(cents / 100).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return EMPTY
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null) {
|
||||
if (!value) return EMPTY
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function formatNumber(value?: number | null) {
|
||||
if (value == null || Number.isNaN(value)) return EMPTY
|
||||
return value.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
export function formatText(value?: string | number | null) {
|
||||
if (value === '' || value == null) return EMPTY
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 只保留末四位之前的号码,避免列表页大面积暴露完整手机号。 */
|
||||
export function maskPhone(value?: string | null) {
|
||||
if (!value) return EMPTY
|
||||
if (value.length < 7) return value
|
||||
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
||||
}
|
||||
|
||||
export const emptyText = EMPTY
|
||||
@@ -1104,6 +1104,30 @@ a {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-page__head-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-page__desc {
|
||||
display: block;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.admin-page__meta {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.admin-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-page__head h2 {
|
||||
margin: 6px 0 8px !important;
|
||||
color: var(--brand-ink) !important;
|
||||
@@ -1253,6 +1277,215 @@ a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.admin-filter-bar__fields {
|
||||
display: flex;
|
||||
flex: 1 1 640px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 14px;
|
||||
}
|
||||
|
||||
.admin-filter-bar__actions {
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.admin-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.admin-filter-field__label {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.admin-filter-field .ant-select,
|
||||
.admin-filter-field .ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-status-tag {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-stack strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-stack span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-stack__accent {
|
||||
color: var(--brand-seal) !important;
|
||||
}
|
||||
|
||||
.admin-stack--tiny {
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-order-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-order-link:hover {
|
||||
color: var(--brand-seal);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.admin-amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-amount strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-amount span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-amount__accent {
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.admin-table__row--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-tbody > tr.ant-table-row.admin-table__row--clickable:hover > td {
|
||||
background: #f3f7f4;
|
||||
}
|
||||
|
||||
.admin-table-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.admin-detail__section {
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.admin-detail__section:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-detail__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-detail__section-head h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.admin-detail__section .ant-descriptions-item-label {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-detail__section .ant-descriptions-item-content {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-detail__pre {
|
||||
max-height: 220px;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fbf8f1;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-detail-drawer .ant-drawer-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-detail-drawer__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-detail-drawer__title {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.admin-detail-drawer__subtitle {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-detail-drawer__tags {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-placeholder-card {
|
||||
position: relative;
|
||||
max-width: 760px;
|
||||
|
||||
Reference in New Issue
Block a user