Files
soul-yongping/soul-admin/src/pages/orders/OrdersPage.tsx

458 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}