mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
裁剪
swagger 移除 merchant swagger 改造首页内容为通用后台管理系统定位 - 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform' - 更新副标题为 '为二次开发而生' - 更新首页描述为通用平台的特点 - 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建' - 修改展示卡片为技术栈和二次开发相关 - 更新开发者示例代码为通用的注册和 API Key 获取示例 - 更新页脚品牌名为 'Modern Platform' - 调整页脚导航链接为通用平台相关内容 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 去除遗留 裁剪 移除 /api/v1/user/pay-key 相关代码 - 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体 - 删除 User 模型中的 PayKey 字段 - 删除 User.VerifyPayKey 方法 - 删除 EncryptPayKeyFailed 错误常量 - 删除 /api/v1/user/pay-key PUT 路由 - 删除 OAuth 返回中的 IsPayKey 字段 - 删除前端 UserService.updatePayKey 方法 - 删除前端所有支付密钥 UI 和逻辑 - 更新相关的导出和注释 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 去除遗留 api 修正 系统配置 前端裁剪 后端裁剪 init
This commit is contained in:
@@ -1,760 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { Order, DisputeWithOrder, Dispute } from "@/lib/services"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DisputeService } from "@/lib/services"
|
||||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { usePublicConfig } from "@/hooks/use-public-config"
|
||||
import { useTransaction } from "@/contexts/transaction-context"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { typeConfig } from "@/components/common/general/table-filter"
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
|
||||
|
||||
type DisputeViewer = 'payer' | 'payee'
|
||||
|
||||
/**
|
||||
* 活动详情弹窗
|
||||
*/
|
||||
export function OrderDetailDialog({ order }: { order: Order }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Banknote className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>查看详情</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false} className="bg-transparent border-0 shadow-none p-0 w-100">
|
||||
<VisuallyHidden>
|
||||
<DialogHeader>
|
||||
<DialogTitle>积分流转详情</DialogTitle>
|
||||
<DialogDescription>查看积分活动的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
</VisuallyHidden>
|
||||
<div className="p-5 space-y-5 bg-card border border-border/50 rounded-lg shadow-xl">
|
||||
<div className="flex flex-col items-center space-y-2 pb-4 border-b-2 border-dashed border-border/50">
|
||||
<h3 className="font-bold text-2xl tracking-wider uppercase">{order.app_name || 'RECEIPT'}</h3>
|
||||
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-widest">
|
||||
{formatDateTime(order.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Total Credits</p>
|
||||
<div className="text-4xl font-black tracking-tighter flex items-start justify-center gap-1">
|
||||
{parseFloat(order.amount).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0 text-sm border-y-2 border-dashed border-border/50 py-4">
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">编号</span>
|
||||
<span className="font-mono text-xs font-medium">{order.order_no}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">类型</span>
|
||||
<span className="text-xs font-medium">{typeConfig[order.type]?.label || order.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">消费方</span>
|
||||
<span className="text-xs font-medium">{order.payer_username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">服务方</span>
|
||||
<span className="text-xs font-medium">{order.payee_username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">备注</span>
|
||||
<span className="text-xs font-medium">{order.remark}</span>
|
||||
</div>
|
||||
{(order.status === 'success' || order.status === 'refund') && (
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">时间</span>
|
||||
<span className="font-mono text-xs">{formatDateTime(order.trade_time)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col items-center space-y-3">
|
||||
<div className="h-8 w-full max-w-[200px] flex items-end justify-between opacity-40">
|
||||
{[...Array(30)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-foreground w-[2px]"
|
||||
style={{ height: `${ Math.max(40, Math.random() * 100) }%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground font-mono tracking-wider text-center">
|
||||
LINUX DO Credit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起争议弹窗
|
||||
*/
|
||||
export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const isMobile = useIsMobile()
|
||||
const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig()
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [reason, setReason] = useState("")
|
||||
|
||||
const resetForm = () => {
|
||||
setReason("")
|
||||
}
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
if (configLoading) {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
if (configLoading) {
|
||||
toast.error('配置加载中', { description: '请稍候再试' })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
toast.error('配置加载失败', { description: '请刷新页面重试' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (configError || !publicConfig) {
|
||||
toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' })
|
||||
return
|
||||
}
|
||||
|
||||
const tradeTime = new Date(order.trade_time)
|
||||
const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000)
|
||||
const now = new Date()
|
||||
|
||||
if (now > expiryTime) {
|
||||
toast.error('无法发起争议', { description: '已超过最晚发起争议时间' })
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen && !loading) {
|
||||
resetForm()
|
||||
}
|
||||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
const handleCreateDispute = async () => {
|
||||
if (!reason.trim()) {
|
||||
toast.error('表单验证失败', { description: '请填写争议原因' })
|
||||
return
|
||||
}
|
||||
|
||||
if (reason.length > 100) {
|
||||
toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() })
|
||||
toast.success('争议已发起', { description: '请等待服务方处理' })
|
||||
updateOrderStatus(order.id, {
|
||||
status: 'disputing',
|
||||
dispute_id: dispute.id
|
||||
})
|
||||
setOpen(false)
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '发起争议失败'
|
||||
toast.error('发起争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>发起争议</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>发起争议</DialogTitle>
|
||||
<DialogDescription>
|
||||
如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason" className={isMobile ? "text-sm" : ""}>
|
||||
争议原因
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
placeholder="请详细描述您遇到的问题..."
|
||||
maxLength={100}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={loading}
|
||||
className={isMobile ? "text-sm" : ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{reason.length}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleCreateDispute() }}
|
||||
disabled={loading}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 提交中</> : '确认发起'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消争议弹窗
|
||||
*/
|
||||
export function CancelDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleCancelDispute = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('取消争议失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
await DisputeService.closeDispute({ dispute_id: order.dispute_id })
|
||||
toast.success('争议已取消', { description: '争议已成功取消' })
|
||||
updateOrderStatus(order.id, { status: 'success' })
|
||||
setOpen(false)
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '取消争议失败'
|
||||
toast.error('取消争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>争议正在进行中,点击取消</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>争议取消</DialogTitle>
|
||||
<DialogDescription>
|
||||
您确定取消当前的争议吗?取消后争议将恢复正常。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleCancelDispute() }}
|
||||
disabled={loading}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 取消中</> : '确认取消'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看争议历史弹窗
|
||||
*/
|
||||
export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: Order; viewer?: DisputeViewer }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [disputeHistory, setDisputeHistory] = useState<Dispute | null>(null)
|
||||
const [fetchingHistory, setFetchingHistory] = useState(false)
|
||||
|
||||
const disputeConfig = React.useMemo(() => {
|
||||
switch (order.status) {
|
||||
case 'refused':
|
||||
return {
|
||||
tooltip: '争议已拒绝,点击查看',
|
||||
timelineText: '服务方驳回争议',
|
||||
isRed: true,
|
||||
showTimestamp: true,
|
||||
showContent: false,
|
||||
content: null
|
||||
}
|
||||
case 'refund':
|
||||
return {
|
||||
tooltip: '争议已退款',
|
||||
timelineText: '服务方已退款',
|
||||
isRed: false,
|
||||
showTimestamp: true,
|
||||
showContent: true,
|
||||
content: '退款已完成'
|
||||
}
|
||||
case 'disputing':
|
||||
return {
|
||||
tooltip: '争议处理中,点击查看',
|
||||
timelineText: '争议进行中,等待服务方处理',
|
||||
isRed: false,
|
||||
showTimestamp: false,
|
||||
showContent: false,
|
||||
content: null
|
||||
}
|
||||
}
|
||||
}, [order.status])
|
||||
|
||||
if (!disputeConfig) return null
|
||||
|
||||
const resetForm = () => {
|
||||
setDisputeHistory(null)
|
||||
}
|
||||
|
||||
const fetchDisputeHistory = async () => {
|
||||
try {
|
||||
setFetchingHistory(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('获取争议记录失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
dispute_id: order.dispute_id
|
||||
}
|
||||
|
||||
const res = viewer === 'payee'
|
||||
? await DisputeService.listMerchantDisputes(params)
|
||||
: await DisputeService.listDisputes(params)
|
||||
|
||||
if (res.disputes && res.disputes.length > 0) {
|
||||
setDisputeHistory(res.disputes[0])
|
||||
} else {
|
||||
toast.error('未找到争议记录')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '获取争议记录失败'
|
||||
toast.error('获取争议记录失败', { description: errorMessage })
|
||||
} finally {
|
||||
setFetchingHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
setOpen(true)
|
||||
resetForm()
|
||||
fetchDisputeHistory()
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const parseDisputeReason = (fullReason: string) => {
|
||||
const match = fullReason.match(/^(.*?)\s*\[(?:服务方|受托方)拒绝理由:\s*(.*?)\]$/)
|
||||
if (match) {
|
||||
return { userReason: match[1].trim(), merchantReason: match[2].trim() }
|
||||
}
|
||||
return { userReason: fullReason, merchantReason: null }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
<Layers className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{disputeConfig.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>争议详情</DialogTitle>
|
||||
<DialogDescription>查看争议处理记录</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{fetchingHistory ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : disputeHistory ? (
|
||||
<div className="py-4 relative pl-4 border-l border-border/50 space-y-8 ml-2">
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-background" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">消费方发起争议</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(disputeHistory.created_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{parseDisputeReason(disputeHistory.reason).userReason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${ disputeConfig.isRed ? 'bg-destructive' : 'bg-primary' } ring-4 ring-background`} />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-medium ${ disputeConfig.isRed ? 'text-destructive' : '' }`}>
|
||||
{disputeConfig.timelineText}
|
||||
</span>
|
||||
{disputeConfig.showTimestamp && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(disputeHistory.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.status === 'refused' && (
|
||||
<div className="text-sm text-muted-foreground bg-destructive/5 border border-destructive/10 p-3 rounded-md">
|
||||
{parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{disputeConfig.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
无法加载争议记录
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" className="h-8 text-xs">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款审核活动弹窗
|
||||
*/
|
||||
export function RefundReviewDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const { refetch: refetchUser } = useUser()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [action, setAction] = useState<'refund' | 'closed' | null>(null)
|
||||
const [reason, setReason] = useState("")
|
||||
|
||||
const [disputeInfo, setDisputeInfo] = useState<DisputeWithOrder | null>(null)
|
||||
const [fetchingDispute, setFetchingDispute] = useState(false)
|
||||
|
||||
const resetForm = () => {
|
||||
setAction(null)
|
||||
setReason("")
|
||||
setDisputeInfo(null)
|
||||
}
|
||||
|
||||
const fetchDisputeInfo = async () => {
|
||||
try {
|
||||
setFetchingDispute(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('获取争议详情失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
const res = await DisputeService.listMerchantDisputes({
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
dispute_id: order.dispute_id
|
||||
})
|
||||
|
||||
if (res.disputes && res.disputes.length > 0) {
|
||||
setDisputeInfo(res.disputes[0])
|
||||
} else {
|
||||
toast.error('获取争议详情失败', { description: '未找到争议记录' })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '获取争议详情失败'
|
||||
toast.error('获取争议详情失败', { description: errorMessage })
|
||||
} finally {
|
||||
setFetchingDispute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
setOpen(true)
|
||||
resetForm()
|
||||
fetchDisputeInfo()
|
||||
} else if (!loading) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!action) {
|
||||
toast.error('请选择处理方式')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'closed' && !reason.trim()) {
|
||||
toast.error('请填写拒绝原因')
|
||||
return
|
||||
}
|
||||
|
||||
if (reason.length > 100) {
|
||||
toast.error('拒绝理由不能超过 100 个字符')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('处理失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
await DisputeService.refundReview({
|
||||
dispute_id: order.dispute_id,
|
||||
status: action,
|
||||
reason: action === 'closed' ? reason.trim() : undefined,
|
||||
})
|
||||
|
||||
toast.success('处理成功', {
|
||||
description: action === 'refund' ? '已同意积分退回,争议结束' : '已拒绝积分退回,此积分活动正常进行'
|
||||
})
|
||||
|
||||
updateOrderStatus(order.id, {
|
||||
status: action === 'refund' ? 'refund' : 'refused'
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
refetchUser()
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '处理争议失败'
|
||||
toast.error('处理争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50"
|
||||
onClick={() => {
|
||||
setOpen(true)
|
||||
fetchDisputeInfo()
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>处理争议</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>处理争议</DialogTitle>
|
||||
<DialogDescription>
|
||||
请审核此争议。您可以选择同意或拒绝退回积分。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{fetchingDispute ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
{disputeInfo && (
|
||||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 mb-2 text-muted-foreground text-xs">
|
||||
<span>发起争议理由</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(disputeInfo.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
{disputeInfo.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-primary ${ action === 'refund' && 'border-primary text-primary' }`}
|
||||
onClick={() => setAction('refund')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Banknote className="h-4 w-4" />
|
||||
<span className="font-semibold">同意积分退回</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-normal">积分全额退回,争议结束</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-destructive ${ action === 'closed' && 'border-destructive text-destructive' }`}
|
||||
onClick={() => setAction('closed')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TicketSlash className="h-4 w-4" />
|
||||
<span className="font-semibold">拒绝积分退回</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-normal">拒绝积分退回,此积分活动正常进行</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{action === 'closed' && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 pt-2">
|
||||
<Label htmlFor="reject-reason" className="text-xs font-medium">
|
||||
拒绝原因 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
placeholder="请说明拒绝退回的原因..."
|
||||
maxLength={100}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground text-right">
|
||||
{reason.length}/100
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleSubmit() }}
|
||||
disabled={loading || !action}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 提交中</> : '确认处理'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { TablePagination, FilterSelect } from "@/components/common/general/table-filter"
|
||||
import { Inbox, ListRestart, Gift, Coins, Check, Copy, ExternalLink } from "lucide-react"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { RedEnvelope } from "@/lib/services"
|
||||
|
||||
export interface ListDataProps {
|
||||
fetchData: (params: { page: number, page_size: number, type: string }) => Promise<{ list: RedEnvelope[], total: number }>
|
||||
tabs: { value: string, label: string, color?: string }[]
|
||||
defaultTab?: string
|
||||
pageSize?: number
|
||||
className?: string
|
||||
emptyState?: (tab: string) => React.ReactNode
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包列表数据组件
|
||||
*
|
||||
* 提供分页、标签筛选、数据加载状态管理和网格布局,专门用于展示红包列表
|
||||
*/
|
||||
export function ListData({
|
||||
fetchData,
|
||||
tabs,
|
||||
defaultTab,
|
||||
pageSize = 20,
|
||||
className,
|
||||
emptyState,
|
||||
refreshTrigger = 0
|
||||
}: ListDataProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.value)
|
||||
|
||||
/* 数据状态 */
|
||||
const [items, setItems] = useState<RedEnvelope[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
/* 缓存页码 */
|
||||
const loadData = async (type: string, p: number, s: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchData({ page: p, page_size: s, type })
|
||||
setItems(result.list)
|
||||
setTotal(result.total)
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 初始加载和Tab切换 */
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
loadData(activeTab, 1, currentPageSize)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, refreshTrigger])
|
||||
|
||||
const handlePageChange = (p: number) => {
|
||||
setPage(p)
|
||||
loadData(activeTab, p, currentPageSize)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (s: number) => {
|
||||
setCurrentPageSize(s)
|
||||
setPage(1)
|
||||
loadData(activeTab, 1, s)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / currentPageSize)
|
||||
|
||||
const getEnvelopeLink = (id: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${ window.location.origin }/redenvelope/${ id }`
|
||||
}
|
||||
return `/redenvelope/${ id }`
|
||||
}
|
||||
|
||||
const handleCopyLink = async (link: string, envelopeId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setCopiedId(envelopeId)
|
||||
toast.success("链接已复制")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-green-600 bg-green-50/50 border-green-200">进行中</Badge>
|
||||
case 'finished':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-muted-foreground bg-muted/50 border-dashed">已领完</Badge>
|
||||
case 'expired':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-red-500 bg-red-50/50 border-red-200">已过期</Badge>
|
||||
default:
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none">{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
/* 默认空状态 */
|
||||
const defaultEmptyState = (
|
||||
<EmptyStateWithBorder
|
||||
icon={Inbox}
|
||||
description="暂无数据"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FilterSelect<string>
|
||||
label="视图"
|
||||
selectedValues={[activeTab]}
|
||||
options={tabs.reduce((acc, tab) => ({
|
||||
...acc,
|
||||
[tab.value]: { label: tab.label, color: tab.color }
|
||||
}), {})}
|
||||
onToggleValue={(v) => setActiveTab(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TablePagination
|
||||
currentPage={totalPages > 0 ? page : 0}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={currentPageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onRefresh={() => loadData(activeTab, page, currentPageSize)}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading && items.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={ListRestart} />
|
||||
) : items.length === 0 ? (
|
||||
emptyState ? emptyState(activeTab) : defaultEmptyState
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((envelope) => {
|
||||
const isSent = activeTab === 'sent'
|
||||
const link = getEnvelopeLink(envelope.id)
|
||||
const claimedCount = envelope.total_count - envelope.remaining_count
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={envelope.id}
|
||||
className="group border-dashed hover:bg-muted/30 transition-all bg-card/50 shadow-none"
|
||||
>
|
||||
<CardContent className="px-4 py-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 border ${ isSent
|
||||
? "bg-red-50 text-red-600 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/30"
|
||||
: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-900/30"
|
||||
}`}>
|
||||
{isSent ? <Gift size={16} /> : <Coins size={16} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-xs text-foreground truncate select-none">
|
||||
{isSent
|
||||
? (envelope.type === 'random' ? '拼手气红包' : '普通红包')
|
||||
: envelope.creator_username
|
||||
}
|
||||
</span>
|
||||
{getStatusBadge(envelope.status)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate max-w-[140px] mt-0.5">
|
||||
{envelope.greeting || "恭喜发财,大吉大利"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-2.5 px-2.5 py-2 bg-muted/40 rounded-md text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">总金额</div>
|
||||
<div className="font-mono font-medium text-foreground">{envelope.total_amount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">已领取</div>
|
||||
<div className="font-medium text-foreground">{claimedCount}/{envelope.total_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 -mb-2">
|
||||
<div className="text-[10px] text-muted-foreground/60 font-mono">
|
||||
{formatDateTime(envelope.created_at)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 h-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
|
||||
onClick={() => handleCopyLink(link, envelope.id)}
|
||||
>
|
||||
{copiedId === envelope.id ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
|
||||
onClick={() => window.open(link, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,7 @@ interface ManagePageProps<T> {
|
||||
emptyDescription?: string
|
||||
loadingDescription?: string
|
||||
getId: (item: T) => string | number
|
||||
headerExtra?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ManagePage<T>({
|
||||
@@ -72,7 +73,8 @@ export function ManagePage<T>({
|
||||
emptyIcon = Layers,
|
||||
emptyDescription = "暂无数据",
|
||||
loadingDescription = "加载中",
|
||||
getId
|
||||
getId,
|
||||
headerExtra
|
||||
}: ManagePageProps<T>) {
|
||||
/** 悬停状态 */
|
||||
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
|
||||
@@ -199,8 +201,9 @@ export function ManagePage<T>({
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex border-b border-border pb-2 mb-6">
|
||||
<div className="flex border-b border-border pb-2 mb-6 items-center justify-between">
|
||||
<div className="text-2xl font-semibold">{title}</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
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"
|
||||
@@ -1,721 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Filter, CalendarIcon, X, Search, ChevronLeft, ChevronRight, ChevronDown, Loader2 } from "lucide-react"
|
||||
import { zhCN } from "date-fns/locale"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { OrderType, OrderStatus, TransactionQueryParams } from "@/lib/services"
|
||||
|
||||
/* 类型标签配置 */
|
||||
export const typeConfig: Record<OrderType, { label: string; color: string }> = {
|
||||
receive: { label: '积分收益', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
payment: { label: '积分消耗', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||
online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
|
||||
distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
|
||||
test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' },
|
||||
red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
red_envelope_refund: { label: '红包退款', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' }
|
||||
}
|
||||
|
||||
/* 状态标签配置 */
|
||||
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
|
||||
success: { label: '成功', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
pending: { label: '处理中', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
failed: { label: '失败', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
expired: { label: '已过期', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
|
||||
disputing: { label: '争议中', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
|
||||
refund: { label: '已退回', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
|
||||
refused: { label: '已拒绝', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }
|
||||
}
|
||||
|
||||
export type DisplayOrderStatus = OrderStatus | 'transfer_pending'
|
||||
|
||||
export const displayStatusConfig: Record<DisplayOrderStatus, { label: string; color: string }> = {
|
||||
pending: statusConfig.pending,
|
||||
success: statusConfig.success,
|
||||
transfer_pending: { label: '结算中', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
failed: statusConfig.failed,
|
||||
expired: statusConfig.expired,
|
||||
disputing: statusConfig.disputing,
|
||||
refund: statusConfig.refund,
|
||||
refused: statusConfig.refused,
|
||||
}
|
||||
|
||||
export function mapDisplayStatusToQuery(
|
||||
selectedStatus: DisplayOrderStatus | null
|
||||
): Pick<TransactionQueryParams, 'statuses' | 'payee_transfer_status'> {
|
||||
if (!selectedStatus) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (selectedStatus === 'transfer_pending') {
|
||||
return {
|
||||
payee_transfer_status: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statuses: [selectedStatus],
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间范围选项 */
|
||||
export const timeRangeOptions = [
|
||||
{
|
||||
label: "今天", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from: today, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 7 天", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 6)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 1 个月", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 6 个月", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setMonth(from.getMonth() - 6)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "本月至今", getValue: () => {
|
||||
const today = new Date()
|
||||
const from = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
from.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "本季至今", getValue: () => {
|
||||
const today = new Date()
|
||||
const quarter = Math.floor(today.getMonth() / 3)
|
||||
const from = new Date(today.getFullYear(), quarter * 3, 1) // 本季第一天 00:00:00
|
||||
from.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{ label: "所有时间", getValue: () => null },
|
||||
]
|
||||
|
||||
export interface TableFilterProps {
|
||||
/* 启用的筛选类型 */
|
||||
enabledFilters?: {
|
||||
type?: boolean
|
||||
status?: boolean
|
||||
timeRange?: boolean
|
||||
search?: boolean
|
||||
}
|
||||
|
||||
/* 当前选中的值 */
|
||||
selectedTypes?: OrderType[]
|
||||
selectedStatus?: DisplayOrderStatus | null
|
||||
selectedTimeRange?: { from: Date; to: Date } | null
|
||||
selectedQuickSelection?: string | null
|
||||
/* 搜索值 */
|
||||
searchValues?: SearchValues
|
||||
|
||||
/* 回调函数 */
|
||||
onTypeChange?: (types: OrderType[]) => void
|
||||
onStatusChange?: (status: DisplayOrderStatus | null) => void
|
||||
onTimeRangeChange?: (range: { from: Date; to: Date } | null) => void
|
||||
onQuickSelectionChange?: (selection: string | null) => void
|
||||
onSearch?: (values: SearchValues) => void
|
||||
|
||||
// 其他选项
|
||||
showClearButton?: boolean
|
||||
onClearAll?: () => void
|
||||
|
||||
// 分页控制(可选)
|
||||
enablePagination?: boolean
|
||||
currentPage?: number
|
||||
totalPages?: number
|
||||
pageSize?: number
|
||||
total?: number
|
||||
onPageChange?: (page: number) => void
|
||||
onPageSizeChange?: (size: number) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的筛选组件
|
||||
* 支持类型、状态、时间范围筛选
|
||||
*/
|
||||
export function TableFilter({
|
||||
enabledFilters = { type: true, status: true, timeRange: true, search: true },
|
||||
selectedTypes = [],
|
||||
selectedStatus = null,
|
||||
selectedTimeRange = null,
|
||||
selectedQuickSelection = null,
|
||||
searchValues = {},
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
onTimeRangeChange,
|
||||
onQuickSelectionChange,
|
||||
onSearch,
|
||||
showClearButton = true,
|
||||
onClearAll,
|
||||
enablePagination = false,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
pageSize = 20,
|
||||
total = 0,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onRefresh,
|
||||
loading = false,
|
||||
}: TableFilterProps) {
|
||||
/* 通用切换筛选函数 */
|
||||
const handleToggle = <T extends string>(
|
||||
value: T,
|
||||
selectedValues: T[],
|
||||
onChange?: (values: T[]) => void
|
||||
) => {
|
||||
if (!onChange) return
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value]
|
||||
onChange(newValues)
|
||||
}
|
||||
|
||||
/* 切换类型筛选 */
|
||||
const toggleType = (type: OrderType) => handleToggle(type, selectedTypes, onTypeChange)
|
||||
|
||||
/* 切换状态筛选 */
|
||||
const toggleStatus = (status: DisplayOrderStatus) => {
|
||||
if (!onStatusChange) return
|
||||
onStatusChange(selectedStatus === status ? null : status)
|
||||
}
|
||||
|
||||
/* 处理时间范围变化 */
|
||||
const handleTimeRangeChange = (range: { from: Date; to: Date } | null) => {
|
||||
onTimeRangeChange?.(range)
|
||||
}
|
||||
|
||||
/* 是否有激活的筛选 */
|
||||
const hasActiveFilters =
|
||||
(enabledFilters.type && selectedTypes.length > 0) ||
|
||||
(enabledFilters.status && selectedStatus !== null) ||
|
||||
(enabledFilters.timeRange && selectedTimeRange !== null) ||
|
||||
(enabledFilters.search && Object.values(searchValues || {}).some(v => v))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{enabledFilters.search && (
|
||||
<SearchFilter
|
||||
values={searchValues}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.type && (
|
||||
<FilterSelect<OrderType>
|
||||
label="类型"
|
||||
selectedValues={selectedTypes}
|
||||
options={typeConfig}
|
||||
onToggleValue={toggleType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.status && (
|
||||
<SingleFilterSelect<DisplayOrderStatus>
|
||||
label="状态"
|
||||
selectedValue={selectedStatus}
|
||||
options={displayStatusConfig}
|
||||
onSelectValue={toggleStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.timeRange && (
|
||||
<TimeRangeFilter
|
||||
selectedQuickSelection={selectedQuickSelection}
|
||||
selectedTimeRange={selectedTimeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onQuickSelectionChange={onQuickSelectionChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClearButton && hasActiveFilters && onClearAll && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enablePagination && onPageChange && onPageSizeChange && (
|
||||
<Separator className="lg:hidden" />
|
||||
)}
|
||||
|
||||
{enablePagination && onPageChange && onPageSizeChange && (
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
onRefresh={onRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface TablePaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onRefresh,
|
||||
loading = false
|
||||
}: TablePaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap hidden sm:inline-block">
|
||||
{total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center border border-dashed rounded-md shadow-none bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed h-5.5 flex items-center">
|
||||
{currentPage}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
|
||||
{pageSize}条/页
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => onPageSizeChange(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的筛选选择器组件
|
||||
*/
|
||||
export function FilterSelect<T extends string>({ label, selectedValues, options, onToggleValue }: {
|
||||
label: string
|
||||
selectedValues: T[]
|
||||
options: Record<T, { label: string; color: string }>
|
||||
onToggleValue: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
selectedValues.length > 0 && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
{label}
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
{(Object.keys(options) as T[]).map((value) => {
|
||||
const isSelected = selectedValues.includes(value)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
onToggleValue(value)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
</div>
|
||||
<span className="text-xs">{options[value].label}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SingleFilterSelect<T extends string>({ label, selectedValue, options, onSelectValue }: {
|
||||
label: string
|
||||
selectedValue: T | null
|
||||
options: Record<T, { label: string; color: string }>
|
||||
onSelectValue: (value: T) => void
|
||||
}) {
|
||||
const activeLabel = selectedValue ? options[selectedValue].label : null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
selectedValue && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
{activeLabel ? `${label}: ${activeLabel}` : label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[160px]" align="start">
|
||||
{(Object.keys(options) as T[]).map((value) => {
|
||||
const isSelected = selectedValue === value
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectValue(value)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-full border border-primary",
|
||||
isSelected ? "bg-primary" : "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">{options[value].label}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimeRangeFilterProps {
|
||||
selectedQuickSelection: string | null
|
||||
selectedTimeRange: { from: Date; to: Date } | null
|
||||
onTimeRangeChange: (range: { from: Date; to: Date } | null) => void
|
||||
onQuickSelectionChange?: (selection: string | null) => void
|
||||
}
|
||||
|
||||
function TimeRangeFilter({
|
||||
selectedQuickSelection,
|
||||
selectedTimeRange,
|
||||
onTimeRangeChange,
|
||||
onQuickSelectionChange
|
||||
}: TimeRangeFilterProps) {
|
||||
/* 处理日历选择 */
|
||||
const handleCalendarSelect = (range: { from?: Date; to?: Date } | undefined) => {
|
||||
if (range?.from) {
|
||||
const from = new Date(range.from)
|
||||
from.setHours(0, 0, 0, 0)
|
||||
|
||||
if (range.to) {
|
||||
const to = new Date(range.to)
|
||||
to.setHours(0, 0, 0, 0)
|
||||
onTimeRangeChange({ from, to })
|
||||
onQuickSelectionChange?.(null)
|
||||
} else if (range.from.getTime() === selectedTimeRange?.from?.getTime()) {
|
||||
const to = new Date(from)
|
||||
to.setHours(0, 0, 0, 0)
|
||||
onTimeRangeChange({ from, to })
|
||||
onQuickSelectionChange?.(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
(selectedQuickSelection || selectedTimeRange) && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 size-3" />
|
||||
{selectedQuickSelection || (selectedTimeRange ? "自定义时间" : "时间范围")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto p-0" align="start">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="flex flex-col p-2 gap-1 min-w-[120px] border-r">
|
||||
{timeRangeOptions.map((selection) => (
|
||||
<Button
|
||||
key={selection.label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const range = selection.getValue()
|
||||
onTimeRangeChange(range)
|
||||
onQuickSelectionChange?.(selection.label)
|
||||
}}
|
||||
className={cn(
|
||||
"justify-start text-xs font-normal h-8",
|
||||
selectedQuickSelection === selection.label && "bg-accent text-accent-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{selection.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={selectedTimeRange ? { from: selectedTimeRange.from, to: selectedTimeRange.to } : undefined}
|
||||
onSelect={handleCalendarSelect}
|
||||
numberOfMonths={2}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SearchValues {
|
||||
id?: string
|
||||
order_name?: string
|
||||
payer_username?: string
|
||||
payee_username?: string
|
||||
}
|
||||
|
||||
export function SearchFilter({
|
||||
values,
|
||||
onSearch
|
||||
}: {
|
||||
values?: SearchValues
|
||||
onSearch?: (values: SearchValues) => void
|
||||
}) {
|
||||
const [localValues, setLocalValues] = React.useState<SearchValues>(values || {})
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValues(values || {})
|
||||
}, [values, isOpen])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSearch?.(localValues)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch?.(localValues)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
const empty = { id: '', order_name: '', payer_username: '', payee_username: '' }
|
||||
setLocalValues(empty)
|
||||
onSearch?.(empty)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const hasSearchValues = Object.values(values || {}).some(v => v)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
hasSearchValues && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Search className="size-3" />
|
||||
搜索
|
||||
{hasSearchValues && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-3" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[280px] p-4" align="start">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">编号 ID</Label>
|
||||
<Input
|
||||
value={localValues.id || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, id: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="精确匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">活动名称</Label>
|
||||
<Input
|
||||
value={localValues.order_name || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, order_name: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">消费方</Label>
|
||||
<Input
|
||||
value={localValues.payer_username || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, payer_username: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">服务方</Label>
|
||||
<Input
|
||||
value={localValues.payee_username || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, payee_username: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs" onClick={handleClear}>
|
||||
重置
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user