458 lines
19 KiB
TypeScript
458 lines
19 KiB
TypeScript
import toast from '@/utils/toast'
|
||
import { useState, useEffect } from 'react'
|
||
import { Card, CardContent } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogFooter,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Search, RefreshCw, Download, Filter, Undo2 } from 'lucide-react'
|
||
import { useDebounce } from '@/hooks/useDebounce'
|
||
import { Pagination } from '@/components/ui/Pagination'
|
||
import { get, put } from '@/api/client'
|
||
|
||
interface Purchase {
|
||
id: string
|
||
userId: string
|
||
type?: 'section' | 'fullbook' | 'match' | 'vip'
|
||
sectionId?: string
|
||
sectionTitle?: string
|
||
productId?: string
|
||
amount: number
|
||
status: 'pending' | 'completed' | 'failed' | 'paid' | 'created' | 'refunded'
|
||
paymentMethod?: string
|
||
referrerEarnings?: number
|
||
createdAt: string
|
||
orderSn?: string
|
||
userNickname?: string
|
||
productType?: string
|
||
description?: string
|
||
refundReason?: string
|
||
}
|
||
|
||
interface UsersItem {
|
||
id: string
|
||
nickname?: string
|
||
phone?: string
|
||
}
|
||
|
||
export function OrdersPage() {
|
||
const [purchases, setPurchases] = useState<Purchase[]>([])
|
||
const [users, setUsers] = useState<UsersItem[]>([])
|
||
const [total, setTotal] = useState(0)
|
||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||
const [todayRevenue, setTodayRevenue] = useState(0)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(10)
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const debouncedSearch = useDebounce(searchTerm, 300)
|
||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [refundOrder, setRefundOrder] = useState<Purchase | null>(null)
|
||
const [refundReason, setRefundReason] = useState('')
|
||
const [refundLoading, setRefundLoading] = useState(false)
|
||
|
||
async function loadOrders() {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
try {
|
||
const statusParam = statusFilter === 'all' ? '' : statusFilter === 'completed' ? 'completed' : statusFilter
|
||
const ordersParams = new URLSearchParams({
|
||
page: String(page),
|
||
pageSize: String(pageSize),
|
||
...(statusParam && { status: statusParam }),
|
||
...(debouncedSearch && { search: debouncedSearch }),
|
||
})
|
||
const [ordersData, usersData] = await Promise.all([
|
||
get<{ success?: boolean; orders?: Purchase[]; total?: number; totalRevenue?: number; todayRevenue?: number }>(`/api/orders?${ordersParams}`),
|
||
get<{ success?: boolean; users?: UsersItem[] }>('/api/db/users?page=1&pageSize=500'),
|
||
])
|
||
if (ordersData?.success) {
|
||
setPurchases(ordersData.orders || [])
|
||
setTotal(ordersData.total ?? 0)
|
||
setTotalRevenue(ordersData.totalRevenue ?? 0)
|
||
setTodayRevenue(ordersData.todayRevenue ?? 0)
|
||
}
|
||
if (usersData?.success && usersData.users) setUsers(usersData.users)
|
||
} catch (e) {
|
||
console.error('加载订单失败', e)
|
||
setError('加载订单失败,请检查网络后重试')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setPage(1)
|
||
}, [debouncedSearch, statusFilter])
|
||
|
||
useEffect(() => {
|
||
loadOrders()
|
||
}, [page, pageSize, debouncedSearch, statusFilter])
|
||
|
||
const getUserNickname = (order: Purchase) =>
|
||
order.userNickname || users.find((u) => u.id === order.userId)?.nickname || '匿名用户'
|
||
|
||
const getUserPhone = (userId: string) => users.find((u) => u.id === userId)?.phone || '-'
|
||
|
||
const formatProduct = (order: Purchase) => {
|
||
const type = order.productType || order.type || ''
|
||
const desc = order.description || ''
|
||
if (desc) {
|
||
if (type === 'section' && desc.includes('章节')) {
|
||
if (desc.includes('-')) {
|
||
const parts = desc.split('-')
|
||
if (parts.length >= 3) {
|
||
return { name: `第${parts[1]}章 第${parts[2]}节`, type: '《一场Soul的创业实验》' }
|
||
}
|
||
}
|
||
return { name: desc, type: '章节购买' }
|
||
}
|
||
if (type === 'fullbook' || desc.includes('全书')) {
|
||
return { name: '《一场Soul的创业实验》', type: '全书购买' }
|
||
}
|
||
if (type === 'vip' || desc.includes('VIP')) {
|
||
return { name: 'VIP年度会员', type: 'VIP' }
|
||
}
|
||
if (type === 'match' || desc.includes('伙伴')) {
|
||
return { name: '找伙伴匹配', type: '功能服务' }
|
||
}
|
||
return { name: desc, type: '其他' }
|
||
}
|
||
if (type === 'section') return { name: `章节 ${order.productId || order.sectionId || ''}`, type: '单章' }
|
||
if (type === 'fullbook') return { name: '《一场Soul的创业实验》', type: '全书' }
|
||
if (type === 'vip') return { name: 'VIP年度会员', type: 'VIP' }
|
||
if (type === 'match') return { name: '找伙伴匹配', type: '功能' }
|
||
return { name: '未知商品', type: type || '其他' }
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / pageSize) || 1
|
||
|
||
async function handleRefund() {
|
||
if (!refundOrder?.orderSn && !refundOrder?.id) return
|
||
setRefundLoading(true)
|
||
setError(null)
|
||
try {
|
||
const res = await put<{ success?: boolean; error?: string }>('/api/admin/orders/refund', {
|
||
orderSn: refundOrder.orderSn || refundOrder.id,
|
||
reason: refundReason || undefined,
|
||
})
|
||
if (res?.success) {
|
||
setRefundOrder(null)
|
||
setRefundReason('')
|
||
loadOrders()
|
||
} else {
|
||
setError(res?.error || '退款失败')
|
||
}
|
||
} catch (e) {
|
||
const err = e as Error & { data?: { error?: string } }
|
||
setError(err?.data?.error || '退款失败,请检查网络后重试')
|
||
} finally {
|
||
setRefundLoading(false)
|
||
}
|
||
}
|
||
|
||
function handleExport() {
|
||
if (purchases.length === 0) {
|
||
toast.info('暂无数据可导出')
|
||
return
|
||
}
|
||
const headers = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '退款原因', '分销佣金', '下单时间']
|
||
const rows = purchases.map((p) => {
|
||
const product = formatProduct(p)
|
||
return [
|
||
p.orderSn || p.id || '',
|
||
getUserNickname(p),
|
||
getUserPhone(p.userId),
|
||
product.name,
|
||
Number(p.amount || 0).toFixed(2),
|
||
p.paymentMethod === 'wechat' ? '微信支付' : p.paymentMethod === 'alipay' ? '支付宝' : p.paymentMethod || '微信支付',
|
||
p.status === 'refunded' ? '已退款' : p.status === 'paid' || p.status === 'completed' ? '已完成' : p.status === 'pending' || p.status === 'created' ? '待支付' : '已失败',
|
||
p.status === 'refunded' && p.refundReason ? p.refundReason : '-',
|
||
p.referrerEarnings ? Number(p.referrerEarnings).toFixed(2) : '-',
|
||
p.createdAt ? new Date(p.createdAt).toLocaleString('zh-CN') : '',
|
||
].join(',')
|
||
})
|
||
const csv = '\uFEFF' + [headers.join(','), ...rows].join('\n')
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `订单列表_${new Date().toISOString().slice(0, 10)}.csv`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
return (
|
||
<div className="p-8 w-full">
|
||
{error && (
|
||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||
<span>{error}</span>
|
||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between items-center mb-8">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">订单管理</h2>
|
||
<p className="text-gray-400 mt-1">共 {purchases.length} 笔订单</p>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<Button
|
||
variant="outline"
|
||
onClick={loadOrders}
|
||
disabled={isLoading}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||
刷新
|
||
</Button>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="text-gray-400">总收入:</span>
|
||
<span className="text-[#38bdac] font-bold">¥{totalRevenue.toFixed(2)}</span>
|
||
<span className="text-gray-600">|</span>
|
||
<span className="text-gray-400">今日:</span>
|
||
<span className="text-[#FFD700] font-bold">¥{todayRevenue.toFixed(2)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 mb-6">
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||
<Input
|
||
type="text"
|
||
placeholder="搜索订单号/用户/章节..."
|
||
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Filter className="w-4 h-4 text-gray-400" />
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||
>
|
||
<option value="all">全部状态</option>
|
||
<option value="completed">已完成</option>
|
||
<option value="pending">待支付</option>
|
||
<option value="created">已创建</option>
|
||
<option value="failed">已失败</option>
|
||
<option value="refunded">已退款</option>
|
||
</select>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleExport}
|
||
disabled={purchases.length === 0}
|
||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||
>
|
||
<Download className="w-4 h-4 mr-2" />
|
||
导出 CSV
|
||
</Button>
|
||
</div>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardContent className="p-0">
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||
<TableHead className="text-gray-400">订单号</TableHead>
|
||
<TableHead className="text-gray-400">用户</TableHead>
|
||
<TableHead className="text-gray-400">商品</TableHead>
|
||
<TableHead className="text-gray-400">金额</TableHead>
|
||
<TableHead className="text-gray-400">支付方式</TableHead>
|
||
<TableHead className="text-gray-400">状态</TableHead>
|
||
<TableHead className="text-gray-400">退款原因</TableHead>
|
||
<TableHead className="text-gray-400">分销佣金</TableHead>
|
||
<TableHead className="text-gray-400">下单时间</TableHead>
|
||
<TableHead className="text-gray-400">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{purchases.map((purchase) => {
|
||
const product = formatProduct(purchase)
|
||
return (
|
||
<TableRow key={purchase.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||
<TableCell className="font-mono text-xs text-gray-400">
|
||
{(purchase.orderSn || purchase.id || '').slice(0, 12)}...
|
||
</TableCell>
|
||
<TableCell>
|
||
<div>
|
||
<p className="text-white text-sm">{getUserNickname(purchase)}</p>
|
||
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div>
|
||
<p className="text-white text-sm flex items-center gap-2">
|
||
{product.name}
|
||
{(purchase.productType || purchase.type) === 'vip' && (
|
||
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
|
||
VIP
|
||
</Badge>
|
||
)}
|
||
</p>
|
||
<p className="text-gray-500 text-xs">{product.type}</p>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-[#38bdac] font-bold">
|
||
¥{Number(purchase.amount || 0).toFixed(2)}
|
||
</TableCell>
|
||
<TableCell className="text-gray-300">
|
||
{purchase.paymentMethod === 'wechat'
|
||
? '微信支付'
|
||
: purchase.paymentMethod === 'alipay'
|
||
? '支付宝'
|
||
: purchase.paymentMethod || '微信支付'}
|
||
</TableCell>
|
||
<TableCell>
|
||
{purchase.status === 'refunded' ? (
|
||
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
|
||
已退款
|
||
</Badge>
|
||
) : purchase.status === 'paid' || purchase.status === 'completed' ? (
|
||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||
已完成
|
||
</Badge>
|
||
) : purchase.status === 'pending' || purchase.status === 'created' ? (
|
||
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||
待支付
|
||
</Badge>
|
||
) : (
|
||
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
|
||
已失败
|
||
</Badge>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-gray-400 text-sm max-w-[120px] truncate" title={purchase.refundReason}>
|
||
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'}
|
||
</TableCell>
|
||
<TableCell className="text-[#FFD700]">
|
||
{purchase.referrerEarnings
|
||
? `¥${Number(purchase.referrerEarnings).toFixed(2)}`
|
||
: '-'}
|
||
</TableCell>
|
||
<TableCell className="text-gray-400 text-sm">
|
||
{new Date(purchase.createdAt).toLocaleString('zh-CN')}
|
||
</TableCell>
|
||
<TableCell>
|
||
{(purchase.status === 'paid' || purchase.status === 'completed') && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-orange-500/50 text-orange-400 hover:bg-orange-500/20"
|
||
onClick={() => {
|
||
setRefundOrder(purchase)
|
||
setRefundReason('')
|
||
}}
|
||
>
|
||
<Undo2 className="w-3 h-3 mr-1" />
|
||
退款
|
||
</Button>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
)
|
||
})}
|
||
{purchases.length === 0 && (
|
||
<TableRow>
|
||
<TableCell colSpan={10} className="text-center py-12 text-gray-500">
|
||
暂无订单数据
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
<Pagination
|
||
page={page}
|
||
totalPages={totalPages}
|
||
total={total}
|
||
pageSize={pageSize}
|
||
onPageChange={setPage}
|
||
onPageSizeChange={(n) => {
|
||
setPageSize(n)
|
||
setPage(1)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={!!refundOrder} onOpenChange={(open) => !open && setRefundOrder(null)}>
|
||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">订单退款</DialogTitle>
|
||
</DialogHeader>
|
||
{refundOrder && (
|
||
<div className="space-y-4">
|
||
<p className="text-gray-400 text-sm">
|
||
订单号:{refundOrder.orderSn || refundOrder.id}
|
||
</p>
|
||
<p className="text-gray-400 text-sm">
|
||
退款金额:¥{Number(refundOrder.amount || 0).toFixed(2)}
|
||
</p>
|
||
<div>
|
||
<label className="text-sm text-gray-400 block mb-2">退款原因(选填)</label>
|
||
<div className="form-input">
|
||
<Input
|
||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
||
placeholder="如:用户申请退款"
|
||
value={refundReason}
|
||
onChange={(e) => setRefundReason(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-orange-400/80 text-xs">
|
||
退款将原路退回至用户微信,且无法撤销,请确认后再操作。
|
||
</p>
|
||
</div>
|
||
)}
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
className="border-gray-600 text-gray-300"
|
||
onClick={() => setRefundOrder(null)}
|
||
disabled={refundLoading}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||
onClick={handleRefund}
|
||
disabled={refundLoading}
|
||
>
|
||
{refundLoading ? '退款中...' : '确认退款'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|