mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 14:46:36 +08:00
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
import * as React from "react"
|
|
import { useVirtualizer } from "@tanstack/react-virtual"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { typeConfig, statusConfig } from "@/components/common/general/table-filter"
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
|
import { ErrorInline } from "@/components/layout/error"
|
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
|
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
|
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
|
import { Lightbulb, ListRestart, Layers, LucideIcon } from "lucide-react"
|
|
import { formatDateTime } from "@/lib/utils"
|
|
import type { Order } from "@/lib/services"
|
|
import { useUser } from "@/contexts/user-context"
|
|
import {
|
|
OrderDetailDialog,
|
|
CreateDisputeDialog,
|
|
ViewDisputeHistoryDialog,
|
|
RefundReviewDialog,
|
|
} from "./dispute-dialog"
|
|
import {usePublicConfig} from "@/hooks/use-public-config";
|
|
|
|
const FALLBACK_TYPE_CONFIG = {
|
|
label: '历史活动',
|
|
color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
|
|
}
|
|
|
|
const ROW_HEIGHT = 36
|
|
|
|
/**
|
|
* 虚拟化交易数据表格组件
|
|
* 使用 @tanstack/react-virtual 实现大数据量高性能渲染
|
|
*/
|
|
export const TransactionDataTable = React.memo(function TransactionDataTable({
|
|
transactions
|
|
}: {
|
|
transactions: Order[]
|
|
}) {
|
|
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: transactions.length,
|
|
getScrollElement: () => {
|
|
if (!scrollAreaRef.current) return null
|
|
const viewport = scrollAreaRef.current.querySelector('[data-slot="scroll-area-viewport"]')
|
|
return viewport as Element
|
|
},
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: 5,
|
|
})
|
|
|
|
const virtualItems = rowVirtualizer.getVirtualItems()
|
|
const totalSize = rowVirtualizer.getTotalSize()
|
|
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
|
|
const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0
|
|
|
|
return (
|
|
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
|
<ScrollArea
|
|
ref={scrollAreaRef}
|
|
className="w-full h-[600px] whitespace-nowrap"
|
|
>
|
|
<table className="w-full caption-bottom text-sm min-w-full">
|
|
<TableHeader className="sticky top-0 z-30 bg-background">
|
|
<TableRow className="border-b border-dashed hover:bg-transparent">
|
|
<TableHead className="whitespace-nowrap w-[120px]">名称</TableHead>
|
|
<TableHead className="whitespace-nowrap text-center min-w-[76px]">积分</TableHead>
|
|
<TableHead className="whitespace-nowrap text-center min-w-[50px]">类型</TableHead>
|
|
<TableHead className="whitespace-nowrap text-center min-w-[84px]">状态</TableHead>
|
|
<TableHead className="whitespace-nowrap text-center min-w-[80px]">积分动向</TableHead>
|
|
<TableHead className="whitespace-nowrap text-center min-w-[80px]">应用名</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left min-w-[120px]">编号</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left min-w-[120px]">业务单号</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left w-[120px]">创建时间</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left w-[120px]">交易时间</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left w-[120px]">订单过期时间</TableHead>
|
|
<TableHead className="whitespace-nowrap text-left min-w-[100px]">备注</TableHead>
|
|
<TableHead className="sticky right-0 whitespace-nowrap text-center bg-background shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] w-[150px] z-40">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody
|
|
key={transactions[0]?.id}
|
|
className="animate-in fade-in duration-200 [will-change:transform,opacity]"
|
|
>
|
|
{paddingTop > 0 && (
|
|
<tr style={{ height: `${ paddingTop }px` }}>
|
|
<td colSpan={13} />
|
|
</tr>
|
|
)}
|
|
{virtualItems.map((virtualRow) => {
|
|
const order = transactions[virtualRow.index]
|
|
return (
|
|
<TransactionTableRow
|
|
key={order.id}
|
|
order={order}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
/>
|
|
)
|
|
})}
|
|
{paddingBottom > 0 && (
|
|
<tr style={{ height: `${ paddingBottom }px` }}>
|
|
<td colSpan={13} />
|
|
</tr>
|
|
)}
|
|
</TableBody>
|
|
</table>
|
|
<ScrollBar orientation="horizontal" />
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
/**
|
|
* 交易表格行组件
|
|
*/
|
|
const TransactionTableRow = React.memo(React.forwardRef<HTMLTableRowElement, {
|
|
order: Order
|
|
style?: React.CSSProperties
|
|
} & React.HTMLAttributes<HTMLTableRowElement>>(function TransactionTableRow({
|
|
order,
|
|
style,
|
|
...props
|
|
}, ref) {
|
|
const { user } = useUser()
|
|
const getAmountDisplay = (amount: string) => (
|
|
<span className="text-xs font-semibold">
|
|
{parseFloat(amount).toFixed(2)}
|
|
</span>
|
|
)
|
|
|
|
const { config: publicConfig } = usePublicConfig()
|
|
|
|
const isDisputeSupported = order.type === 'payment' || order.type === 'online' || order.type === 'receive'
|
|
const isCurrentUserPayer = user?.id === order.payer_user_id
|
|
const isCurrentUserPayee = user?.id === order.payee_user_id
|
|
const isDisputing = order.status === 'disputing' && isCurrentUserPayee
|
|
const hasPendingTransfer = order.payee_transfer_status === 'pending' && isCurrentUserPayee
|
|
const currentTypeConfig = typeConfig[order.type as keyof typeof typeConfig] ?? FALLBACK_TYPE_CONFIG
|
|
const pendingTransferHint = React.useMemo(() => {
|
|
const settleAt = order.payee_transfer_at ? formatDateTime(order.payee_transfer_at) : `${publicConfig?.settlement_delay_days_min ?? 7}-${publicConfig?.settlement_delay_days_max ?? 14} 天`
|
|
|
|
switch (order.status) {
|
|
case 'success':
|
|
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
|
|
case 'refused':
|
|
return `延迟结算机制:已拒绝退款,积分会在 ${settleAt} 后转入可用积分`
|
|
case 'disputing':
|
|
return `延迟结算机制:争议处理中,积分会在 ${settleAt} 后转入可用积分`
|
|
case 'refund':
|
|
return `延迟结算机制:已退款成功,积分会在 ${settleAt} 后转入可用积分`
|
|
default:
|
|
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
|
|
}
|
|
}, [order.status, order.payee_transfer_at, publicConfig?.settlement_delay_days_max, publicConfig?.settlement_delay_days_min])
|
|
|
|
return (
|
|
<TableRow
|
|
ref={ref}
|
|
style={style}
|
|
{...props}
|
|
className={`
|
|
border-dashed group hover:bg-muted/50 data-[state=selected]:bg-muted
|
|
${ isDisputing ? 'bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/30' : '' }
|
|
`}
|
|
>
|
|
<TableCell className="text-[11px] font-medium whitespace-nowrap py-1">
|
|
{order.order_name}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
|
{getAmountDisplay(order.amount)}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
|
<Badge
|
|
variant="secondary"
|
|
className={`text-[10px] px-1 ${ currentTypeConfig.color }`}
|
|
>
|
|
{currentTypeConfig.label}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
|
<div className="flex justify-center">
|
|
<div className="relative inline-flex items-center">
|
|
<Badge
|
|
variant="secondary"
|
|
className={`text-[10px] px-1 ${ statusConfig[order.status].color }`}
|
|
>
|
|
{statusConfig[order.status].label}
|
|
</Badge>
|
|
{hasPendingTransfer && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="absolute left-full ml-1 inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
|
aria-label="查看到账说明"
|
|
>
|
|
<Lightbulb className="size-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="px-2.5 py-1.5">
|
|
{pendingTransferHint}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
|
{order.status === 'pending' || order.status === 'expired' || order.type === 'community' || order.type === 'red_envelope_send' || order.type === 'red_envelope_refund' ? (
|
|
<div className="text-muted-foreground">-</div>
|
|
) : (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="flex items-center cursor-pointer gap-1 justify-center">
|
|
<Avatar className="h-4 w-4">
|
|
<AvatarImage src={order.payer_avatar_url || undefined} />
|
|
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
|
|
{order.payer_username.substring(0, 1).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="text-xs font-bold">⭢</div>
|
|
<Avatar className="h-4 w-4">
|
|
<AvatarImage src={order.payee_avatar_url || undefined} />
|
|
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
|
|
{order.payee_username.substring(0, 1).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="px-2.5 py-1.5">
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-xs font-semibold">消费方</p>
|
|
<p className="text-xs">ID: {order.payer_user_id}</p>
|
|
<p className="text-xs">账户: {order.payer_username}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold">服务方</p>
|
|
<p className="text-xs">ID: {order.payee_user_id}</p>
|
|
<p className="text-xs">账户: {order.payee_username}</p>
|
|
</div>
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium text-center py-1">
|
|
{order.app_name || '-'}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
|
|
{order.order_no}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
|
|
{order.merchant_order_no || '-'}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium text-left py-1">
|
|
{formatDateTime(order.created_at)}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium text-left py-1">
|
|
{(order.status === 'success' || order.status === 'refund') ? formatDateTime(order.trade_time) : '-'}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium text-left py-1">
|
|
{formatDateTime(order.expires_at)}
|
|
</TableCell>
|
|
<TableCell className="text-[11px] font-medium text-left py-1 max-w-[250px] truncate" title={order.remark || ''}>
|
|
{order.remark || '-'}
|
|
</TableCell>
|
|
<TableCell className={`
|
|
sticky right-0 whitespace-nowrap text-center shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] py-1 z-20
|
|
bg-background
|
|
after:absolute after:inset-0 after:z-[-1] after:content-[''] after:pointer-events-none after:transition-colors
|
|
${ isDisputing
|
|
? 'after:bg-yellow-50 dark:after:bg-yellow-900/20 group-hover:after:bg-yellow-100/50 dark:group-hover:after:bg-yellow-900/30'
|
|
: 'group-hover:after:bg-muted/50' }
|
|
`}>
|
|
<OrderDetailDialog order={order} />
|
|
|
|
{/* 场景1:消费方对成功的订单发起争议 */}
|
|
{isDisputeSupported && isCurrentUserPayer && order.status === 'success' && (
|
|
<CreateDisputeDialog order={order} />
|
|
)}
|
|
|
|
{/* 场景2:消费方查看正在进行的争议 */}
|
|
{isDisputeSupported && isCurrentUserPayer && order.status === 'disputing' && (
|
|
<ViewDisputeHistoryDialog order={order} viewer="payer" />
|
|
)}
|
|
|
|
{/* 场景3:消费方查看已处理的争议(拒绝或退款) */}
|
|
{isDisputeSupported && isCurrentUserPayer && (order.status === 'refused' || order.status === 'refund') && (
|
|
<ViewDisputeHistoryDialog order={order} viewer="payer" />
|
|
)}
|
|
|
|
{/* 场景4:服务方处理正在进行的争议 */}
|
|
{isDisputeSupported && isCurrentUserPayee && order.status === 'disputing' && (
|
|
<RefundReviewDialog order={order} />
|
|
)}
|
|
|
|
{/* 场景5:服务方查看已处理的争议(拒绝或退款) */}
|
|
{isDisputeSupported && isCurrentUserPayee && (order.status === 'refused' || order.status === 'refund') && (
|
|
<ViewDisputeHistoryDialog order={order} viewer="payee" />
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}))
|
|
|
|
interface TransactionTableListProps {
|
|
loading: boolean
|
|
error: Error | null
|
|
transactions: Order[]
|
|
onRetry: () => void
|
|
emptyIcon?: LucideIcon
|
|
emptyDescription?: string
|
|
}
|
|
|
|
/**
|
|
* 交易列表容器组件
|
|
* 统一处理加载、错误、空状态
|
|
*/
|
|
export const TransactionTableList = React.memo(function TransactionTableList({
|
|
loading,
|
|
error,
|
|
transactions,
|
|
onRetry,
|
|
emptyIcon = Layers,
|
|
emptyDescription = "未发现积分活动"
|
|
}: TransactionTableListProps) {
|
|
if (loading && transactions.length === 0) {
|
|
return (
|
|
<LoadingStateWithBorder
|
|
icon={ListRestart}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8 border border-dashed rounded-lg">
|
|
<ErrorInline
|
|
error={error}
|
|
onRetry={onRetry}
|
|
className="justify-center"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!transactions || transactions.length === 0) {
|
|
return (
|
|
<EmptyStateWithBorder
|
|
icon={emptyIcon}
|
|
description={emptyDescription}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<TransactionDataTable transactions={transactions} />
|
|
)
|
|
})
|
|
|
|
export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialog"
|