mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56: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,260 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, XAxis } from "recharts"
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Info } from "lucide-react"
|
||||
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { DashboardService } from "@/lib/services"
|
||||
import type { DailyStatsItem } from "@/lib/services"
|
||||
|
||||
const chartConfig = {
|
||||
total: {
|
||||
label: "总额",
|
||||
color: "hsl(217, 91%, 60%)",
|
||||
},
|
||||
income: {
|
||||
label: "收入",
|
||||
color: "#10b981",
|
||||
},
|
||||
expense: {
|
||||
label: "支出",
|
||||
color: "#f43f5e",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
function ResponsiveInfoTip({ children }: { children: React.ReactNode }) {
|
||||
const triggerClassName =
|
||||
"shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" aria-label="查看详情" className={`inline-flex ${triggerClassName} md:hidden`}>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="w-fit max-w-[calc(100vw-2rem)] p-3 md:hidden">
|
||||
<p className="text-xs break-words">{children}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" aria-label="查看详情" className={`hidden ${triggerClassName} md:inline-flex`}>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="w-fit max-w-[calc(100vw-2rem)] px-2.5 py-1.5">
|
||||
<p className="text-xs leading-5 break-words">{children}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面板组件
|
||||
* 展示用户数据和积分余额
|
||||
* @returns {React.ReactNode} 数据面板组件
|
||||
*/
|
||||
export function DataPanel() {
|
||||
const { user, loading: userLoading } = useUser()
|
||||
const [dailyStats, setDailyStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
|
||||
const availableBalance = parseFloat(user?.available_balance || '0')
|
||||
const pendingBalance = parseFloat(user?.pending_balance || '0')
|
||||
const remainQuota = parseFloat(user?.remain_quota || '0')
|
||||
|
||||
/* 获取每日统计数据 */
|
||||
React.useEffect(() => {
|
||||
const fetchDailyStats = async () => {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(7)
|
||||
setDailyStats(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!userLoading && user) {
|
||||
fetchDailyStats()
|
||||
}
|
||||
}, [userLoading, user])
|
||||
|
||||
/* 根据当前余额和每日收支反推历史积分总额 */
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!dailyStats.length || userLoading) return []
|
||||
|
||||
const sortedStats = [...dailyStats].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
|
||||
let currentBalance = availableBalance
|
||||
const balanceHistory: Array<{ date: string; total: number; income: number; expense: number }> = []
|
||||
|
||||
for (let i = 0; i < sortedStats.length; i++) {
|
||||
const stat = sortedStats[i]
|
||||
const income = parseFloat(stat.income || '0')
|
||||
const expense = parseFloat(stat.expense || '0')
|
||||
|
||||
balanceHistory.unshift({
|
||||
date: new Date(stat.date).toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
}),
|
||||
total: Math.max(0, currentBalance),
|
||||
income,
|
||||
expense
|
||||
})
|
||||
|
||||
currentBalance = currentBalance - income + expense
|
||||
}
|
||||
|
||||
return balanceHistory
|
||||
}, [dailyStats, availableBalance, userLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:grid md:grid-cols-3 gap-8 md:gap-12">
|
||||
<div className="md:col-span-2 order-1 md:order-none">
|
||||
<h3 className="text-sm text-muted-foreground font-medium mb-2">积分趋势</h3>
|
||||
|
||||
{loading || userLoading ? (
|
||||
<div className="w-full h-[240px] flex items-center justify-center">
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</div>
|
||||
) : chartData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-[240px] font-bold">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 6,
|
||||
right: 6,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" hide />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="total"
|
||||
type="monotone"
|
||||
fill="url(#colorTotal)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-total)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "hsl(217, 91%, 60%)",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="income"
|
||||
type="monotone"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "#10b981",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="expense"
|
||||
type="monotone"
|
||||
fill="none"
|
||||
stroke="#f43f5e"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "#f43f5e",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="w-full h-[240px] flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1 order-2 md:order-none flex flex-col divide-y divide-border/70 md:divide-y-0 md:pt-px">
|
||||
<div className="py-3 first:pt-0 md:border-b md:pb-4 md:pt-0">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
|
||||
<span className="min-[400px]:hidden">可用LDC</span>
|
||||
<span className="hidden min-[400px]:inline">可用 LINUX DO Credits</span>
|
||||
<ResponsiveInfoTip>
|
||||
当前可立即使用的积分,不包含延迟到账中的未来积分。
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : <CountingNumber number={availableBalance} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-3 md:border-b md:pt-4 md:pb-4">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
|
||||
<span className="min-[400px]:hidden">未来LDC</span>
|
||||
<span className="hidden min-[400px]:inline">未来 LINUX DO Credits</span>
|
||||
<ResponsiveInfoTip>
|
||||
流转后延迟到账的积分,到期后自动转入可用积分。
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : <CountingNumber number={pendingBalance} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-3 last:pb-0 md:pt-4 md:pb-0">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap">
|
||||
今日剩余额度
|
||||
<ResponsiveInfoTip>
|
||||
每日积分消耗额度限制,每日 0 点自动重置
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : remainQuota < 0 ? "无限制" : <CountingNumber number={remainQuota} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { TransactionService } from "@/lib/services"
|
||||
import type { Order } from "@/lib/services"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const DISPUTE_PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* 服务争议对话框模式
|
||||
*/
|
||||
type DisputeDialogMode = 'pending' | 'my-disputes'
|
||||
|
||||
/**
|
||||
* 服务争议对话框属性
|
||||
*/
|
||||
interface DisputeDialogProps {
|
||||
/** 对话框模式 */
|
||||
mode: DisputeDialogMode
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 打开状态变化回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 争议对话框组件
|
||||
* 支持两种模式:待处理的服务争议(服务提供者)和我发起的服务争议(积分消费者)
|
||||
*/
|
||||
export function DisputeDialog({ mode, open, onOpenChange }: DisputeDialogProps) {
|
||||
const [disputes, setDisputes] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchDisputes = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
let result
|
||||
let filteredOrders: Order[] = []
|
||||
|
||||
if (mode === 'pending') {
|
||||
result = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: DISPUTE_PAGE_SIZE,
|
||||
types: ['receive'],
|
||||
statuses: ['disputing']
|
||||
})
|
||||
filteredOrders = result.orders
|
||||
const uniqueOrders = new Map()
|
||||
filteredOrders.forEach(order => uniqueOrders.set(order.id, order))
|
||||
filteredOrders = Array.from(uniqueOrders.values())
|
||||
} else {
|
||||
const paymentResult = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
|
||||
types: ['payment']
|
||||
})
|
||||
|
||||
const onlineResult = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
|
||||
types: ['online']
|
||||
})
|
||||
|
||||
const allOrders = [...paymentResult.orders, ...onlineResult.orders]
|
||||
const uniqueOrders = new Map()
|
||||
allOrders.forEach(order => uniqueOrders.set(order.id, order))
|
||||
|
||||
filteredOrders = Array.from(uniqueOrders.values()).filter((order: Order) =>
|
||||
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
|
||||
)
|
||||
|
||||
filteredOrders.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
const startIndex = (page - 1) * DISPUTE_PAGE_SIZE
|
||||
filteredOrders = filteredOrders.slice(startIndex, startIndex + DISPUTE_PAGE_SIZE)
|
||||
|
||||
result = {
|
||||
orders: filteredOrders,
|
||||
total: allOrders.filter((order: Order) =>
|
||||
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
|
||||
).length,
|
||||
page,
|
||||
page_size: DISPUTE_PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
setDisputes(filteredOrders)
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
setError(error)
|
||||
toast.error('获取争议数据失败', {
|
||||
description: error.message || '无法加载争议列表'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
fetchDisputes(1)
|
||||
} else {
|
||||
setDisputes([])
|
||||
setError(null)
|
||||
}
|
||||
}, [open, fetchDisputes])
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchDisputes(1)
|
||||
}
|
||||
|
||||
const dialogTitle = mode === 'pending' ? '待处理的争议' : '我发起的争议'
|
||||
const dialogDescription = mode === 'pending'
|
||||
? '您作为服务提供者,需要处理所有争议中的活动'
|
||||
: '您作为积分消费者发起的所有争议'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={disputes}
|
||||
onRetry={handleRetry}
|
||||
emptyIcon={AlertTriangle}
|
||||
emptyDescription={mode === 'pending' ? '暂无待处理的争议' : '暂无您发起的争议'}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { TransactionProvider } from "@/contexts/transaction-context"
|
||||
|
||||
import { DataPanel } from "@/components/common/home/data-panel"
|
||||
import { OverviewPanel } from "@/components/common/home/overview-panel"
|
||||
import { motion } from "motion/react"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
User,
|
||||
Key,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
Layers,
|
||||
ArrowRight,
|
||||
Shield,
|
||||
HelpCircle,
|
||||
Clock,
|
||||
ExternalLink
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export function HomeMain() {
|
||||
return (
|
||||
<TransactionProvider>
|
||||
<div className="py-6 space-y-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold border-b pb-2 mb-6">今天</h1>
|
||||
<DataPanel />
|
||||
</div>
|
||||
const { user, getTrustLevelLabel } = useUser()
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold border-b pb-2">近期概览</h1>
|
||||
<OverviewPanel />
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "个人资料",
|
||||
description: "管理您的个人账户信息及个性化配置",
|
||||
icon: User,
|
||||
url: "/settings/profile",
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
borderColor: "hover:border-blue-500/30",
|
||||
},
|
||||
{
|
||||
title: "开发接口文档",
|
||||
description: "查看开放平台的 RESTful 接口规格说明",
|
||||
icon: FileText,
|
||||
url: "/docs/api",
|
||||
color: "text-emerald-500",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
borderColor: "hover:border-emerald-500/30",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "使用文档",
|
||||
description: "学习如何集成 API 及日常操作帮助指南",
|
||||
icon: HelpCircle,
|
||||
url: "/docs/how-to-use",
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
borderColor: "hover:border-purple-500/30",
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const adminLinks = [
|
||||
{
|
||||
title: "全局系统配置",
|
||||
description: "动态管理平台运行时核心配置参数",
|
||||
icon: ShieldCheck,
|
||||
url: "/admin/system",
|
||||
color: "text-indigo-500",
|
||||
bgColor: "bg-indigo-500/10",
|
||||
borderColor: "hover:border-indigo-500/30",
|
||||
},
|
||||
{
|
||||
title: "用户权限管理",
|
||||
description: "集中查询并管理系统注册用户的启用状态",
|
||||
icon: User,
|
||||
url: "/admin/users",
|
||||
color: "text-rose-500",
|
||||
bgColor: "bg-rose-500/10",
|
||||
borderColor: "hover:border-rose-500/30",
|
||||
},
|
||||
{
|
||||
title: "后台任务调度",
|
||||
description: "分发与观测系统异步定时任务的执行情况",
|
||||
icon: Layers,
|
||||
url: "/admin/tasks",
|
||||
color: "text-teal-500",
|
||||
bgColor: "bg-teal-500/10",
|
||||
borderColor: "hover:border-teal-500/30",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-8 max-w-6xl mx-auto">
|
||||
{/* 快捷导航 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">常用功能导航</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{quickLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: idx * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1 flex items-center gap-1">
|
||||
{link.title}
|
||||
{link.external && <ExternalLink className="size-3 text-muted-foreground" />}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
{link.external ? (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
立即跳转 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url}>
|
||||
立即进入 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TransactionProvider>
|
||||
|
||||
{/* 管理面板 (仅管理员可见) */}
|
||||
{user?.is_admin && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5">
|
||||
<Shield className="size-5" />
|
||||
后台管理控制台
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{adminLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: (idx + 4) * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1">
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url}>
|
||||
管理配置 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,746 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { DisputeService, TransactionService, DashboardService } from "@/lib/services"
|
||||
import type { DisputeWithOrder, Order, DailyStatsItem, TopCustomer } from "@/lib/services"
|
||||
import { RefundReviewDialog, CancelDisputeDialog } from "@/components/common/general/table-data"
|
||||
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
|
||||
import { useDisputeData } from "@/hooks/use-dispute"
|
||||
import { DisputeDialog } from "@/components/common/home/dispute-dialog"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
function AnimatedProgressBar({ value, className }: { value: number, className?: string }) {
|
||||
const [progress, setProgress] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setProgress(value), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Progress
|
||||
value={progress}
|
||||
className={cn("h-2 bg-muted", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface OverviewCardProps {
|
||||
title: string
|
||||
titleExtra?: React.ReactNode
|
||||
headerExtra?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
loading?: boolean
|
||||
onRefresh?: () => void
|
||||
onViewAll?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function OverviewCard({
|
||||
title,
|
||||
titleExtra,
|
||||
headerExtra,
|
||||
action,
|
||||
loading,
|
||||
onRefresh,
|
||||
onViewAll,
|
||||
children,
|
||||
className
|
||||
}: OverviewCardProps) {
|
||||
return (
|
||||
<Card className={cn("bg-background border-0 shadow-none rounded-lg min-h-[200px] flex flex-col h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 h-6">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{titleExtra && <div className="font-semibold">{titleExtra}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onRefresh && (
|
||||
<Button variant="ghost" size="icon" className="size-4" onClick={onRefresh} disabled={loading}>
|
||||
<Spinner className={cn("size-3.5", !loading && "animate-none")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra && <div className="pt-1">{headerExtra}</div>}
|
||||
</CardHeader>
|
||||
<CardContent className="relative flex-1 -mt-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t h-8">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground w-full">
|
||||
<span>更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{onViewAll && (
|
||||
<Button variant="link" className="h-4 px-0 text-xs text-blue-600" disabled={loading} onClick={onViewAll}>
|
||||
查看全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建争议对象
|
||||
* @param dispute 争议对象
|
||||
* @param type 争议类型
|
||||
* @returns 争议对象
|
||||
*/
|
||||
const createDisputeOrder = (dispute: DisputeWithOrder, type: 'receive' | 'payment'): Order => ({
|
||||
id: dispute.order_id,
|
||||
dispute_id: dispute.id,
|
||||
type,
|
||||
status: 'disputing' as const,
|
||||
order_no: '',
|
||||
order_name: dispute.order_name,
|
||||
merchant_order_no: '',
|
||||
payer_user_id: '0',
|
||||
payee_user_id: '0',
|
||||
payer_username: '',
|
||||
payee_username: dispute.payee_username,
|
||||
amount: dispute.amount,
|
||||
remark: '',
|
||||
client_id: '',
|
||||
trade_time: '',
|
||||
expires_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
payment_type: '',
|
||||
payee_transfer_status: 'completed' as const,
|
||||
payee_transfer_at: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 争议列表骨架屏
|
||||
* 用于显示争议列表的加载状态
|
||||
*
|
||||
* @returns 争议列表骨架屏
|
||||
*/
|
||||
const DisputeListSkeleton = () => (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-1" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 争议列表数据骨架屏
|
||||
*/
|
||||
const PaymentListSkeleton = () => (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`payment-skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-1" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取活动状态显示文本
|
||||
*/
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
success: '成功',
|
||||
pending: '待处理',
|
||||
failed: '失败',
|
||||
expired: '已过期',
|
||||
disputing: '争议中',
|
||||
refund: '已退回',
|
||||
refused: '已拒绝'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动状态样式
|
||||
*/
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'default'
|
||||
case 'pending':
|
||||
return 'secondary'
|
||||
case 'failed':
|
||||
case 'refused':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动卡片
|
||||
* 用于显示活动列表数据
|
||||
*
|
||||
* @returns 活动列表卡片
|
||||
*/
|
||||
function PaymentCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const [isHidden, setIsHidden] = React.useState(false)
|
||||
const [payments, setPayments] = React.useState<Order[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [total, setTotal] = React.useState(0)
|
||||
|
||||
const fetchPayments = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await TransactionService.getTransactions({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
types: ['payment'],
|
||||
statuses: ['success']
|
||||
})
|
||||
setPayments(response.orders)
|
||||
setTotal(response.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch payments:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchPayments()
|
||||
}, [fetchPayments])
|
||||
|
||||
return (
|
||||
|
||||
<OverviewCard
|
||||
title="活动"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={total} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={fetchPayments}
|
||||
onViewAll={onViewAll}
|
||||
action={
|
||||
<Button variant="ghost" size="icon" className="size-4" onClick={() => setIsHidden(!isHidden)}>
|
||||
{isHidden ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<PaymentListSkeleton />
|
||||
) : payments.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{payments.map((payment) => (
|
||||
<div key={`payment-${ payment.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{payment.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">LDC {payment.amount}</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(payment.status)} className="text-[10px] px-1.5 py-0 ml-2">
|
||||
{getStatusText(payment.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无积分活动记录</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{isHidden && (
|
||||
<div className="absolute inset-0 backdrop-blur-md bg-background/30 rounded-lg flex items-center justify-center">
|
||||
<EyeOff className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 收入统计卡片
|
||||
* 用于显示每日收入统计
|
||||
*
|
||||
* @returns 收入统计卡片
|
||||
*/
|
||||
function IncomeStatsCard() {
|
||||
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
|
||||
const fetchDailyStats = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(days)
|
||||
//setStats(data)
|
||||
const newStats: DailyStatsItem[] = []
|
||||
data.forEach((stat, i) => {
|
||||
let income = parseFloat(stat.income || "0");
|
||||
if (income < 0) {
|
||||
income = 0;
|
||||
}
|
||||
newStats[i] = {
|
||||
date: stat.date,
|
||||
income: String(income),
|
||||
expense: stat.expense
|
||||
}
|
||||
});
|
||||
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDailyStats()
|
||||
}, [fetchDailyStats])
|
||||
|
||||
// 计算总收入
|
||||
const totalIncome = stats.reduce((sum, stat) => sum + parseFloat(stat.income || '0'), 0)
|
||||
|
||||
// 找出最大收入值用于比例计算
|
||||
const maxIncome = Math.max(...stats.map(s => parseFloat(s.income || '0')))
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="7天收入统计"
|
||||
headerExtra={
|
||||
<div className="text-xl font-semibold">
|
||||
{loading ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : (
|
||||
<>LDC <CountingNumber number={totalIncome} decimalPlaces={2} /></>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
onRefresh={fetchDailyStats}
|
||||
>
|
||||
{loading ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`income-skeleton-${ index }`} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : stats.length > 0 ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{stats.slice().reverse().map((stat, index) => {
|
||||
const income = parseFloat(stat.income || '0')
|
||||
const incomeWidth = maxIncome > 0 ? (income / maxIncome) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={stat.date || index} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
+{income.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted rounded-sm overflow-hidden h-2">
|
||||
<AnimatedProgressBar
|
||||
value={incomeWidth}
|
||||
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-green-500/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 支出统计卡片
|
||||
* 用于显示每日支出统计
|
||||
*
|
||||
* @returns 支出统计卡片
|
||||
*/
|
||||
function ExpenseStatsCard() {
|
||||
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
|
||||
const fetchDailyStats = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(days)
|
||||
//setStats(data)
|
||||
const newStats: DailyStatsItem[] = []
|
||||
data.forEach((stat, i) => {
|
||||
let income = parseFloat(stat.income || '0');
|
||||
let expense = parseFloat(stat.expense || '0');
|
||||
if (income < 0) {
|
||||
expense = expense - income;
|
||||
income = 0;
|
||||
}
|
||||
newStats[i] = {
|
||||
date: stat.date,
|
||||
income: String(income),
|
||||
expense: String(expense),
|
||||
}
|
||||
})
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDailyStats()
|
||||
}, [fetchDailyStats])
|
||||
|
||||
/* 计算总支出 */
|
||||
const totalExpense = stats.reduce((sum, stat) => sum + parseFloat(stat.expense || '0'), 0)
|
||||
|
||||
/* 找出最大支出值用于比例计算 */
|
||||
const maxExpense = Math.max(...stats.map(s => parseFloat(s.expense || '0')))
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="7天支出统计"
|
||||
headerExtra={
|
||||
<div className="text-xl font-semibold">
|
||||
{loading ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : (
|
||||
<>LDC <CountingNumber number={totalExpense} decimalPlaces={2} /></>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
onRefresh={fetchDailyStats}
|
||||
>
|
||||
{loading ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`expense-skeleton-${ index }`} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : stats.length > 0 ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{stats.slice().reverse().map((stat, index) => {
|
||||
const expense = parseFloat(stat.expense || '0')
|
||||
const expenseWidth = maxExpense > 0 ? (expense / maxExpense) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={stat.date || index} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-[10px] text-red-600 font-medium">
|
||||
-{expense.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted rounded-sm overflow-hidden h-2">
|
||||
<AnimatedProgressBar
|
||||
value={expenseWidth}
|
||||
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-red-500/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Top 客户卡片
|
||||
* 用于显示向当前用户付款最多的客户排行
|
||||
*
|
||||
* @returns Top 客户卡片
|
||||
*/
|
||||
function TopCustomersCard() {
|
||||
const [customers, setCustomers] = React.useState<TopCustomer[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
const [limit] = React.useState(5)
|
||||
|
||||
const fetchTopCustomers = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getTopCustomers(days, limit)
|
||||
setCustomers(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch top customers:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days, limit])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchTopCustomers()
|
||||
}, [fetchTopCustomers])
|
||||
|
||||
/* 找出最大金额用于显示进度条 */
|
||||
const maxAmount = customers && customers.length > 0
|
||||
? Math.max(...customers.map(c => parseFloat(c.total_amount || '0')))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="Top 客户统计"
|
||||
loading={loading}
|
||||
onRefresh={fetchTopCustomers}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`customer-skeleton-${ index }`} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-6" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : customers && customers.length > 0 ? (
|
||||
<div className="space-y-2 pr-3">
|
||||
{customers.map((customer, index) => {
|
||||
const amount = parseFloat(customer.total_amount || '0')
|
||||
const percentage = maxAmount > 0 ? (amount / maxAmount) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={customer.user_id} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground shrink-0">#{index + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{customer.username}</span>
|
||||
</div>
|
||||
<div className="text-xs font-semibold shrink-0 ml-2">
|
||||
{amount.toFixed(2)} LDC
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<AnimatedProgressBar
|
||||
value={percentage}
|
||||
className="h-2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-right">
|
||||
{customer.order_count}单
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">暂无客户数据</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 待处理的争议卡片
|
||||
* 用于显示待处理的争议数据
|
||||
*
|
||||
* @returns 待处理的争议卡片
|
||||
*/
|
||||
function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
|
||||
fetchFn: (params) => DisputeService.listMerchantDisputes(params)
|
||||
})
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="待处理的争议"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
onViewAll={onViewAll}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<DisputeListSkeleton />
|
||||
) : disputes.list.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{disputes.list.map((dispute) => (
|
||||
<div key={`merchant-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">{dispute.payee_username}</p>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<RefundReviewDialog order={createDisputeOrder(dispute, 'receive')} onSuccess={refetchData} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无待处理的积分活动争议</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 我发起的争议卡片
|
||||
* 用于显示我发起的争议数据
|
||||
*
|
||||
* @returns 我发起的争议卡片
|
||||
*/
|
||||
function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
|
||||
fetchFn: (params) => DisputeService.listDisputes(params)
|
||||
})
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="我发起的争议"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
onViewAll={onViewAll}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<DisputeListSkeleton />
|
||||
) : disputes.list.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{disputes.list.map((dispute) => (
|
||||
<div key={`user-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">积分活动发起者正在处理争议</p>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<CancelDisputeDialog order={createDisputeOrder(dispute, 'payment')} onSuccess={refetchData} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无我发起的积分活动争议</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览面板组件
|
||||
* 用于显示概览面板
|
||||
*
|
||||
* @returns 概览面板组件
|
||||
*/
|
||||
export function OverviewPanel() {
|
||||
const router = useRouter()
|
||||
const [disputeDialogOpen, setDisputeDialogOpen] = React.useState(false)
|
||||
const [disputeDialogMode, setDisputeDialogMode] = React.useState<'pending' | 'my-disputes'>('pending')
|
||||
|
||||
const handleViewAllPending = () => {
|
||||
setDisputeDialogMode('pending')
|
||||
setDisputeDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewAllMyDisputes = () => {
|
||||
setDisputeDialogMode('my-disputes')
|
||||
setDisputeDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewAllPayments = () => {
|
||||
router.push('/trade?type=payment')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-muted rounded-lg p-2 mt-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<PaymentCard onViewAll={handleViewAllPayments} />
|
||||
<IncomeStatsCard />
|
||||
<ExpenseStatsCard />
|
||||
<TopCustomersCard />
|
||||
<PendingDisputesCard onViewAll={handleViewAllPending} />
|
||||
<MyDisputesCard onViewAll={handleViewAllMyDisputes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisputeDialog
|
||||
mode={disputeDialogMode}
|
||||
open={disputeDialogOpen}
|
||||
onOpenChange={setDisputeDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user