294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { Alert, App, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||
import { ReloadOutlined } from '@ant-design/icons'
|
||
|
||
import {
|
||
cancelCrashOrder,
|
||
completeCrashOrder,
|
||
fetchCrashOrders,
|
||
receiveCrashOrder,
|
||
refundCrashOrder,
|
||
} from '@/features/admin/api'
|
||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||
import { DataTable } from '@/features/admin/components/DataTable'
|
||
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
|
||
import type { AdminCrashOrder } from '@/features/admin/types'
|
||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||
|
||
const statusMeta: Record<string, { label: string; color: string }> = {
|
||
pending_payment: { label: '待支付', color: 'gold' },
|
||
paid: { label: '已支付', color: 'blue' },
|
||
received: { label: '已接单', color: 'cyan' },
|
||
completed: { label: '已完成', color: 'green' },
|
||
cancelled: { label: '已取消', color: 'default' },
|
||
refunded: { label: '已退款', color: 'red' },
|
||
}
|
||
|
||
function errorText(error: unknown) {
|
||
const response = (error as { response?: { data?: { message?: string } } })?.response
|
||
return response?.data?.message || (error instanceof Error ? error.message : '请求失败')
|
||
}
|
||
|
||
export function AdminCrashOrdersPage() {
|
||
const { message, modal } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [filters, setFilters] = useState<{ keyword: string; status?: string }>({ keyword: '' })
|
||
const [applied, setApplied] = useState(filters)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||
const canView = Boolean(permissions?.includes('*') || permissions?.includes('mohong:order_view'))
|
||
const canManage = Boolean(permissions?.includes('*') || permissions?.includes('mohong:order_manage'))
|
||
|
||
const query = {
|
||
keyword: applied.keyword.trim() || undefined,
|
||
status: applied.status,
|
||
page,
|
||
page_size: pageSize,
|
||
}
|
||
|
||
const ordersQuery = useQuery({
|
||
queryKey: ['admin-crash-orders', query],
|
||
queryFn: () => fetchCrashOrders(query),
|
||
enabled: canView,
|
||
placeholderData: keepPreviousData,
|
||
})
|
||
|
||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin-crash-orders'] })
|
||
|
||
const act = async (action: () => Promise<unknown>, success: string) => {
|
||
try {
|
||
await action()
|
||
message.success(success)
|
||
await invalidate()
|
||
} catch (error) {
|
||
message.error(errorText(error))
|
||
}
|
||
}
|
||
|
||
const columns: TableColumnsType<AdminCrashOrder> = [
|
||
{
|
||
title: '订单',
|
||
key: 'order',
|
||
width: 240,
|
||
render: (_, row) => (
|
||
<div className="admin-stack">
|
||
<strong>{row.order_no}</strong>
|
||
<span className="admin-mono">{row.product_title}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '买家',
|
||
key: 'buyer',
|
||
width: 170,
|
||
render: (_, row) => (
|
||
<div className="admin-stack">
|
||
<span>{formatText(row.buyer_nickname)}</span>
|
||
<span className="admin-mono">{formatText(row.buyer_phone)}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '数量 / 金额',
|
||
key: 'amount',
|
||
width: 150,
|
||
align: 'right',
|
||
render: (_, row) => (
|
||
<div className="admin-stack">
|
||
<strong>{formatMoney(row.amount_cent)}</strong>
|
||
<span className="admin-mono">× {row.quantity}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 110,
|
||
render: value => {
|
||
const meta = statusMeta[value] ?? { label: value, color: 'default' }
|
||
return (
|
||
<Tag bordered={false} color={meta.color}>
|
||
{meta.label}
|
||
</Tag>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '备注',
|
||
key: 'remark',
|
||
render: (_, row) => formatText(row.admin_remark || row.cancel_reason),
|
||
},
|
||
{
|
||
title: '下单时间',
|
||
dataIndex: 'created_at',
|
||
width: 170,
|
||
render: value => formatDateTime(value),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 260,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={4} wrap>
|
||
{row.status === 'paid' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
disabled={!canManage}
|
||
onClick={() => void act(() => receiveCrashOrder(row.id), '已接单')}
|
||
>
|
||
接单
|
||
</Button>
|
||
) : null}
|
||
{row.status === 'received' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
disabled={!canManage}
|
||
onClick={() => void act(() => completeCrashOrder(row.id), '订单已完成')}
|
||
>
|
||
完成
|
||
</Button>
|
||
) : null}
|
||
{row.status === 'paid' || row.status === 'received' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
disabled={!canManage}
|
||
onClick={() =>
|
||
modal.confirm({
|
||
title: `确认取消订单 ${row.order_no}?`,
|
||
content: (
|
||
<Input
|
||
placeholder="填写取消原因"
|
||
id={`cancel-reason-${row.id}`}
|
||
defaultValue="后台取消"
|
||
/>
|
||
),
|
||
okText: '确认取消',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '返回',
|
||
onOk: async () => {
|
||
const el = document.getElementById(`cancel-reason-${row.id}`) as HTMLInputElement | null
|
||
await act(() => cancelCrashOrder(row.id, el?.value || '后台取消'), '订单已取消')
|
||
},
|
||
})
|
||
}
|
||
>
|
||
取消
|
||
</Button>
|
||
) : null}
|
||
{row.status === 'paid' || row.status === 'received' || row.status === 'completed' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
disabled={!canManage}
|
||
onClick={() =>
|
||
modal.confirm({
|
||
title: `确认退款订单 ${row.order_no}?`,
|
||
content: '将调用支付渠道发起退款,操作不可撤销。',
|
||
okText: '确认退款',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '返回',
|
||
onOk: async () => act(() => refundCrashOrder(row.id, { reason: '后台退款' }), '退款已提交'),
|
||
})
|
||
}
|
||
>
|
||
退款
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
|
||
if (!canView) {
|
||
return (
|
||
<div className="admin-page">
|
||
<AdminPageHeader eyebrow="JIEYOU OPS · 摸红" title="撞车订单" />
|
||
<Alert type="warning" showIcon message="当前账号没有撞车订单权限" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="admin-page">
|
||
<AdminPageHeader
|
||
eyebrow="JIEYOU OPS · 摸红"
|
||
title="撞车订单"
|
||
description="处理撞车商城订单:接单、完成、取消与退款。"
|
||
meta={<Tag color="geekblue">共 {ordersQuery.data?.total ?? 0} 单</Tag>}
|
||
extra={
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
loading={ordersQuery.isFetching}
|
||
onClick={() => void ordersQuery.refetch()}
|
||
>
|
||
刷新
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<FilterBar
|
||
loading={ordersQuery.isFetching}
|
||
onSearch={() => {
|
||
setPage(1)
|
||
setApplied(filters)
|
||
}}
|
||
onReset={() => {
|
||
const empty = { keyword: '' }
|
||
setFilters(empty)
|
||
setApplied(empty)
|
||
setPage(1)
|
||
}}
|
||
>
|
||
<FilterField label="关键字">
|
||
<Input
|
||
allowClear
|
||
value={filters.keyword}
|
||
placeholder="订单号 / 商品 / 手机号"
|
||
onChange={event => setFilters(current => ({ ...current, keyword: event.target.value }))}
|
||
/>
|
||
</FilterField>
|
||
<FilterField label="状态">
|
||
<Select
|
||
allowClear
|
||
value={filters.status}
|
||
placeholder="全部状态"
|
||
options={Object.entries(statusMeta).map(([value, meta]) => ({ value, label: meta.label }))}
|
||
onChange={(value: string | undefined) => setFilters(current => ({ ...current, status: value }))}
|
||
/>
|
||
</FilterField>
|
||
</FilterBar>
|
||
|
||
{ordersQuery.isError ? (
|
||
<Alert type="error" showIcon className="admin-table-alert" message="订单加载失败" />
|
||
) : null}
|
||
|
||
<DataTable<AdminCrashOrder>
|
||
columns={columns}
|
||
dataSource={ordersQuery.data?.items ?? []}
|
||
rowKey="id"
|
||
loading={ordersQuery.isFetching}
|
||
total={ordersQuery.data?.total ?? 0}
|
||
page={ordersQuery.data?.page ?? page}
|
||
pageSize={ordersQuery.data?.page_size ?? pageSize}
|
||
onPageChange={(nextPage, nextSize) => {
|
||
setPage(nextPage)
|
||
setPageSize(nextSize)
|
||
}}
|
||
emptyDescription="还没有订单"
|
||
/>
|
||
|
||
<Typography.Paragraph type="secondary" style={{ marginTop: 10 }}>
|
||
提示:退款会走支付渠道,若渠道未配置会由后端返回错误;开发环境请勿执行真实退款。
|
||
</Typography.Paragraph>
|
||
</div>
|
||
)
|
||
}
|