feat(admin): 新增后台订单列表与详情抽屉

This commit is contained in:
yml2213
2026-10-30 12:36:02 +08:00
parent ef01486e53
commit 17b985a4a4
17 changed files with 1656 additions and 6 deletions
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+157
View File
@@ -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,
}))
+146
View File
@@ -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
}