Files
OpenFlare/frontend/components/common/general/dispute-dialog.tsx
T
2026-06-08 20:34:27 +08:00

761 lines
26 KiB
TypeScript

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