压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,760 @@
"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>
</>
)
}
@@ -0,0 +1,241 @@
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>
)
}
@@ -0,0 +1,393 @@
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { ErrorInline } from "@/components/layout/error"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
interface ManagePageProps<T> {
title: string
data: T[]
loading: boolean
error: Error | null
onReload: () => void
/** 获取初始编辑数据 */
getInitialEditData: (item: T) => Partial<T>
onSave: (item: T, editData: Partial<T>) => Promise<void>
onDelete: (item: T) => Promise<void>
/** 渲染表格 (Config-Driven) */
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
/** 渲染详情 */
renderDetail: (props: {
selected: T | null
hovered: T | null
editData: Partial<T>
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
onSave: () => void
saving: boolean
}) => React.ReactNode
/** 空状态图标 */
emptyIcon?: LucideIcon
emptyDescription?: string
loadingDescription?: string
getId: (item: T) => string | number
}
export function ManagePage<T>({
title,
data,
loading,
error,
onReload,
getInitialEditData,
onSave,
onDelete,
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
getId
}: ManagePageProps<T>) {
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
const [selectedItem, setSelectedItem] = useState<T | null>(null)
const [editData, setEditData] = useState<Partial<T>>({})
const [saving, setSaving] = useState(false)
const [deletingItem, setDeletingItem] = useState<T | null>(null)
/** 悬停处理 */
const handleHover = (item: T | null) => {
setHoveredItem(item)
}
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
}
setHoveredItem(null)
}
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
...prev,
[field]: value
}))
}
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
} finally {
setSaving(false)
}
}
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
const handleConfirmDelete = async () => {
if (!deletingItem) return
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
}
}
/** 渲染内容 */
const renderContent = () => {
if (loading && (!data || data.length === 0)) {
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
/>
</div>
)
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<ManageTable
data={data}
columns={columns}
selected={selectedItem}
hovered={hoveredItem}
onSelect={handleSelect}
onHover={handleHover}
onDelete={handleDeleteClick}
getId={getId}
/>
)
}
return (
<div className="py-6">
<div className="flex border-b border-border pb-2 mb-6">
<div className="text-2xl font-semibold">{title}</div>
</div>
<div className="space-y-6">
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
</div>
{renderContent()}
</div>
<div>
{renderDetail({
selected: selectedItem,
hovered: hoveredItem,
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
/** 详情面板 props */
interface ManageDetailPanelProps {
title?: string
isEmpty: boolean
emptyDescription?: string
onSave?: () => void
saving?: boolean
children: React.ReactNode
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
isEmpty,
emptyDescription = "请选择配置查看详情",
onSave,
saving = false,
children
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
</div>
)
}
return (
<div className="space-y-4 sticky top-6">
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</div>
{onSave && (
<Button
onClick={onSave}
disabled={saving}
size="sm"
className="px-3 h-7 text-xs"
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
</Button>
)}
</div>
{children}
</div>
</div>
)
}
/** 表格面板 */
export function ManageTable<T>({
data,
columns,
selected,
hovered,
onSelect,
onHover,
onDelete,
getId
}: {
data: T[]
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
selected: T | null
hovered: T | null
onSelect: (item: T) => void
onHover: (item: T | null) => void
onDelete: (item: T) => void
getId: (item: T) => string | number
}) {
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea className="w-full">
<div className="relative w-full">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow className="border-b border-dashed">
{columns.map((col, index) => (
<TableHead
key={index}
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.header}
</TableHead>
))}
<TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
{data.map((item) => {
const id = getId(item)
const isSelected = selected && getId(selected) === id
const isHovered = hovered && getId(hovered) === id
return (
<TableRow
key={id}
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
? 'bg-primary/5 hover:bg-primary/10'
: isHovered
? 'bg-gray-100 hover:bg-muted/50'
: 'hover:bg-gray-100'
}`}
onMouseEnter={() => onHover(item)}
onMouseLeave={() => onHover(null)}
onClick={() => onSelect(item)}
>
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.cell(item)}
</TableCell>
))}
<TableCell className="text-xs py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation()
onDelete(item)
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}
@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
interface PasswordDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: (password: string) => void
loading?: boolean
title?: string
description?: string
}
/**
* 密码输入对话框
*
* 用于敏感操作前的二次确认
*/
export function PasswordDialog({
isOpen,
onOpenChange,
onConfirm,
loading = false,
title = "请输入密码",
description = "为了您的账户安全,请输入6位认证密码进行验证"
}: PasswordDialogProps) {
const [password, setPassword] = useState("")
const handleConfirm = () => {
if (password.length === 6) {
onConfirm(password)
}
}
/* 当对话框关闭时重置密码*/
React.useEffect(() => {
if (!isOpen) {
setPassword("")
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="w-xs sm:w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<div className="py-2 flex justify-center">
<InputOTP
maxLength={6}
value={password}
onChange={setPassword}
disabled={loading}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 6 && !loading) {
handleConfirm()
}
}}
>
<InputOTPGroup className="gap-2">
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-8 text-xs"
>
取消
</Button>
<Button
type="button"
className="bg-primary hover:bg-primary/90 h-8 text-xs"
onClick={handleConfirm}
disabled={password.length !== 6 || loading}
>
{loading ? (
<>
<Spinner className="mr-2 h-4 w-4" />
验证中...
</>
) : (
"确认"
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,368 @@
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"
@@ -0,0 +1,721 @@
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>
)
}