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:
ryan
2026-06-07 15:32:14 +08:00
parent 8a782525de
commit 48d414e197
180 changed files with 1538 additions and 27463 deletions
+1
View File
@@ -43,3 +43,4 @@ main
uploads/*
s3_cache
/frontend/.next/
+57 -2034
View File
File diff suppressed because it is too large Load Diff
+57 -2034
View File
File diff suppressed because it is too large Load Diff
+50 -1340
View File
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,6 @@ export default function TermsOfServicePage() {
sections={termsSections}
description={
<span>
欢迎使用 LINUX DO Credit。本协议详述了您在使用本平台服务时的权利与义务。
<br className="hidden md:block" />
为了保障您的合法权益,请您仔细阅读以下条款。
</span>
}
-29
View File
@@ -1,29 +0,0 @@
"use client"
import { useEffect } from "react"
import { UserPayConfigs } from "@/components/common/admin/credit"
import { useUser } from "@/contexts/user-context"
import { AdminProvider, useAdmin } from "@/contexts/admin-context"
/* 用户积分配置页面 */
export default function UserPayConfigPage() {
return (
<AdminProvider>
<UserPayConfigPageContent />
</AdminProvider>
)
}
/* 用户积分配置页面内容 */
function UserPayConfigPageContent() {
const { user } = useUser()
const { refetchUserPayConfigs } = useAdmin()
useEffect(() => {
if (user?.is_admin) {
refetchUserPayConfigs()
}
}, [user?.is_admin, refetchUserPayConfigs])
return <UserPayConfigs />
}
-5
View File
@@ -1,5 +0,0 @@
import { BalanceMain } from "@/components/common/balance/balance-main"
export default function BalancePage() {
return <BalanceMain />
}
+751
View File
@@ -0,0 +1,751 @@
"use client"
import * as React from "react"
import { useState, useMemo } from "react"
import { motion, AnimatePresence } from "motion/react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import {
Coins,
Gift,
Inbox,
Layers,
Shield,
Info,
ArrowRight,
Terminal,
Activity,
User,
ExternalLink,
Code2,
Copy,
Check
} from "lucide-react"
import {
Area,
AreaChart,
XAxis,
ResponsiveContainer,
Tooltip as RechartsTooltip
} from "recharts"
import { toast } from "sonner"
// ==========================================
// 1. 卡片类组件 (Cards)
// ==========================================
function FeatureCard({
title,
description,
linkText,
href
}: {
title: string
description: string
linkText: string
href?: string
}) {
return (
<Card className="bg-card border border-dashed shadow-none hover:shadow-sm transition-all duration-300">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardDescription className="text-xs text-muted-foreground leading-normal">
{description}
</CardDescription>
<Button
variant="link"
className="p-0 h-auto text-xs text-indigo-500 font-medium hover:text-indigo-600 flex items-center gap-1"
onClick={() => {
if (href) {
toast.success(`模拟跳转到: ${href}`)
} else {
toast.info("此按钮为静态演示,无实际链接")
}
}}
>
{linkText} <ArrowRight className="size-3" />
</Button>
</CardContent>
</Card>
)
}
function ReceiveBanner() {
const [isVisible, setIsVisible] = useState(true)
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden mb-6"
>
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative">
<div className="max-w-xl space-y-3">
<h3 className="text-lg font-bold text-indigo-600 dark:text-indigo-400">获得积分收益</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。集成方式多样,满足各种场景需求。
</p>
<div className="flex gap-2.5">
<Button
size="sm"
className="bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm"
onClick={() => toast.success("正在进入积分探索模块...")}
>
开始使用
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setIsVisible(false)}
>
隐藏提示
</Button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
// ==========================================
// 2. 仪表盘组件 (Dashboards)
// ==========================================
const MOCK_CHART_DATA = [
{ date: "06-01", total: 12000, income: 400, expense: 200 },
{ date: "06-02", total: 13500, income: 1500, expense: 300 },
{ date: "06-03", total: 12800, income: 300, expense: 1000 },
{ date: "06-04", total: 15000, income: 2500, expense: 300 },
{ date: "06-05", total: 14200, income: 200, expense: 1000 },
{ date: "06-06", total: 16800, income: 3000, expense: 400 },
{ date: "06-07", total: 17500, income: 1200, expense: 500 },
]
function MockTrendChart() {
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">积分变动趋势 (静态模拟)</CardTitle>
</CardHeader>
<div className="w-full h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={MOCK_CHART_DATA} margin={{ left: -10, right: 10, top: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(99, 102, 241)" stopOpacity={0.2} />
<stop offset="95%" stopColor="rgb(99, 102, 241)" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false} />
<RechartsTooltip
contentStyle={{ background: "rgba(15, 23, 42, 0.9)", border: "none", borderRadius: "8px", color: "#fff" }}
labelStyle={{ fontWeight: "bold", fontSize: "11px", marginBottom: "4px" }}
itemStyle={{ fontSize: "11px", padding: "2px 0" }}
/>
<Area
name="总积分"
dataKey="total"
type="monotone"
fill="url(#colorTotal)"
stroke="rgb(99, 102, 241)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</Card>
)
}
function MockIncomeStats() {
const stats = [
{ date: "06-07", amount: 1200, percentage: 80 },
{ date: "06-06", amount: 3000, percentage: 100 },
{ date: "06-05", amount: 200, percentage: 15 },
{ date: "06-04", amount: 2500, percentage: 90 },
{ date: "06-03", amount: 300, percentage: 20 },
{ date: "06-02", amount: 1500, percentage: 65 },
{ date: "06-01", amount: 400, percentage: 25 },
]
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">7天收入统计 (静态模拟)</CardTitle>
<div className="text-lg font-bold mt-1">LDC 9,100.00</div>
</div>
<Button variant="outline" size="sm" className="h-6 text-[10px] px-2" onClick={() => toast.success("正在刷新统计数据...")}>
刷新
</Button>
</CardHeader>
<div className="space-y-2">
{stats.map((s) => (
<div key={s.date} className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">{s.date}</span>
<span className="text-emerald-500 font-semibold">+{s.amount.toFixed(2)}</span>
</div>
<Progress value={s.percentage} className="h-1.5 [&>[data-slot=progress-indicator]]:bg-emerald-500" />
</div>
))}
</div>
</Card>
)
}
function MockTopCustomers() {
const customers = [
{ rank: 1, name: "dev_user", count: 42, amount: 28500 },
{ rank: 2, name: "linux_do_fan", count: 28, amount: 19800 },
{ rank: 3, name: "gorm_expert", count: 19, amount: 12400 },
{ rank: 4, name: "nextjs_coder", count: 12, amount: 8500 },
{ rank: 5, name: "redis_helper", count: 8, amount: 5000 },
]
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Top 客户统计 (静态模拟)</CardTitle>
</CardHeader>
<div className="space-y-3">
{customers.map((c) => (
<div key={c.rank} className="space-y-1">
<div className="flex justify-between items-center text-xs">
<div className="flex items-center gap-2">
<span className="font-bold text-muted-foreground/80 w-4">#{c.rank}</span>
<span className="font-medium">{c.name}</span>
</div>
<div className="font-semibold text-right">
{c.amount.toLocaleString()} LDC
</div>
</div>
<div className="flex items-center gap-2">
<Progress value={(c.amount / 28500) * 100} className="h-1.5" />
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-right">{c.count}单</span>
</div>
</div>
))}
</div>
</Card>
)
}
// ==========================================
// 3. 数据表格组件 (Tables)
// ==========================================
interface MockOrder {
id: string
order_name: string
amount: number
type: 'payment' | 'transfer' | 'community' | 'red_envelope'
status: 'success' | 'pending' | 'expired' | 'disputing' | 'refund'
order_no: string
created_at: string
}
const MOCK_ORDERS: MockOrder[] = [
{ id: "1", order_name: "API 接口充值积分", amount: 1000, type: "payment", status: "success", order_no: "ORD202606070001", created_at: "2026-06-07 14:20:15" },
{ id: "2", order_name: "退款原路退回", amount: 500, type: "payment", status: "refund", order_no: "ORD202606070002", created_at: "2026-06-07 12:05:44" },
{ id: "3", order_name: "拼手气红包分配", amount: 200, type: "red_envelope", status: "success", order_no: "ORD202606060012", created_at: "2026-06-06 18:30:00" },
{ id: "4", order_name: "在线流转纠纷处理", amount: 1500, type: "transfer", status: "disputing", order_no: "ORD202606050025", created_at: "2026-06-05 09:12:10" },
{ id: "5", order_name: "社区绑定自动转入", amount: 350, type: "community", status: "pending", order_no: "ORD202606040081", created_at: "2026-06-04 23:45:19" },
{ id: "6", order_name: "红包超时未领过期退回", amount: 100, type: "red_envelope", status: "expired", order_no: "ORD202606030009", created_at: "2026-06-03 15:00:00" },
]
function MockTransactionTable() {
const getStatusBadge = (status: MockOrder["status"]) => {
const map = {
success: { text: "交易成功", class: "bg-green-500/10 text-green-600 border-green-500/20" },
pending: { text: "等待付款", class: "bg-amber-500/10 text-amber-600 border-amber-500/20" },
expired: { text: "订单过期", class: "bg-slate-500/10 text-slate-500 border-slate-500/20" },
disputing: { text: "争议中", class: "bg-rose-500/10 text-rose-600 border-rose-500/20" },
refund: { text: "交易退款", class: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
}
return (
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 font-medium ${map[status].class}`}>
{map[status].text}
</Badge>
)
}
const getTypeBadge = (type: MockOrder["type"]) => {
const map = {
payment: { text: "消耗", class: "bg-orange-500/10 text-orange-600" },
transfer: { text: "流转", class: "bg-blue-500/10 text-blue-600" },
community: { text: "社区", class: "bg-purple-500/10 text-purple-600" },
red_envelope: { text: "红包", class: "bg-red-500/10 text-red-600" },
}
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${map[type].class}`}>
{map[type].text}
</span>
)
}
return (
<Card className="border border-dashed shadow-none rounded-lg overflow-hidden">
<div className="w-full overflow-x-auto">
<table className="w-full caption-bottom text-xs">
<thead>
<tr className="border-b border-dashed bg-muted/20">
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-[180px]">名称</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[80px]">类型</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[90px]">额度</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[90px]">状态</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground">流水编号</th>
<th className="h-10 px-4 text-left font-medium text-muted-foreground w-[150px]">交易时间</th>
<th className="h-10 px-4 text-center font-medium text-muted-foreground w-[80px]">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed">
{MOCK_ORDERS.map((order) => (
<tr key={order.id} className="hover:bg-muted/30 transition-colors">
<td className="p-4 font-medium">{order.order_name}</td>
<td className="p-4 text-center">{getTypeBadge(order.type)}</td>
<td className="p-4 text-center font-semibold text-slate-800 dark:text-slate-200">
{order.amount.toFixed(2)}
</td>
<td className="p-4 text-center">{getStatusBadge(order.status)}</td>
<td className="p-4 font-mono text-muted-foreground">{order.order_no}</td>
<td className="p-4 text-muted-foreground">{order.created_at}</td>
<td className="p-4 text-center">
<Button
variant="link"
className="p-0 h-auto text-[11px] text-indigo-500"
onClick={() => toast.success(`查看详情: ${order.order_no}`)}
>
详情
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)
}
// ==========================================
// 4. Tab 导航组件 (Tabs)
// ==========================================
function MockTradeTabs() {
const [activeTab, setActiveTab] = useState<string>("receive")
const tabContent = {
receive: {
title: "积分收益 (Receive)",
description: "本模块汇集用户所有的进账记录。可通过 API、活动赚取等形式增加积分值。",
icon: Coins,
color: "text-emerald-500",
bgColor: "bg-emerald-500/10",
},
payment: {
title: "积分消耗 (Payment)",
description: "本模块汇总用户的出账流水。支持兑换、系统扣减与在线付款场景的费用记账。",
icon: Gift,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
community: {
title: "社区划转 (Community)",
description: "对接 Discourse 外部论坛同步时自动调度的积分余额转换事件。",
icon: Shield,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
online: {
title: "在线流转 (Online)",
description: "指代用户通过付款链接或集市网关主动进行的收单支付流转活动记录。",
icon: Activity,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
all: {
title: "所有活动 (All Activities)",
description: "汇总上述所有分类的完整历史日志,支持按分类检索和导出报表。",
icon: Layers,
color: "text-slate-500",
bgColor: "bg-slate-500/10",
},
}
const activeInfo = tabContent[activeTab as keyof typeof tabContent] || tabContent.receive
return (
<Card className="border border-dashed shadow-none rounded-lg p-5">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
<TabsTrigger
value="receive"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
积分收益
</TabsTrigger>
<TabsTrigger
value="payment"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
积分消耗
</TabsTrigger>
<TabsTrigger
value="community"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
社区划转
</TabsTrigger>
<TabsTrigger
value="online"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
在线流转
</TabsTrigger>
<TabsTrigger
value="all"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-indigo-500 rounded-none border-b-2 border-transparent px-0 pb-2 text-xs font-bold text-muted-foreground data-[state=active]:text-indigo-500 -mb-[2px] transition-colors"
>
所有活动
</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="pt-4 animate-in fade-in duration-200">
<div className="flex gap-4 items-start p-4 border border-dashed rounded-lg bg-muted/20">
<div className={`size-10 rounded-lg flex items-center justify-center shrink-0 ${activeInfo.bgColor} ${activeInfo.color}`}>
<activeInfo.icon className="size-5" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-semibold">{activeInfo.title}</h4>
<p className="text-xs text-muted-foreground leading-normal">{activeInfo.description}</p>
</div>
</div>
</TabsContent>
</Tabs>
</Card>
)
}
// ==========================================
// 5. 代码片段复制辅助组件
// ==========================================
function CodeShowcase({ code, title }: { code: string; title: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("代码片段已复制到剪贴板")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("复制失败")
}
}
return (
<Card className="border border-dashed shadow-none rounded-lg bg-slate-950 text-slate-200 overflow-hidden">
<CardHeader className="py-2.5 px-4 bg-slate-900 border-b border-slate-800 flex flex-row items-center justify-between">
<div className="text-[11px] font-mono text-slate-400 flex items-center gap-1.5">
<Code2 className="size-3.5" />
{title}
</div>
<Button variant="ghost" size="icon" className="size-6 text-slate-400 hover:text-white" onClick={handleCopy}>
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
</Button>
</CardHeader>
<pre className="p-4 font-mono text-[10px] overflow-x-auto leading-relaxed max-h-[300px] select-all [&::-webkit-scrollbar]:hidden">
<code>{code}</code>
</pre>
</Card>
)
}
// ==========================================
// 主组件 (Main Showcase)
// ==========================================
export default function ComponentLibraryPage() {
const [activeCategory, setActiveCategory] = useState<string>("cards")
const categories = [
{ id: "cards", title: "卡片组件 (Cards)", desc: "页面核心功能聚合引导卡片", icon: User },
{ id: "dashboards", title: "大盘统计 (Dashboards)", desc: "交易波动与客户数据展示图表", icon: Activity },
{ id: "tables", title: "数据表格 (Tables)", desc: "支持多类型与状态的明细流水表", icon: Layers },
{ id: "tabs", title: "多标签页 (Tabs)", desc: "分类管理多页签内容切换", icon: Info },
]
const codeSnippets = useMemo(() => {
return {
cards: `// FeatureCard: 三栏网格功能展示卡片
interface FeatureCardProps {
title: string;
description: string;
linkText: string;
href?: string;
}
export function FeatureCard({ title, description, linkText, href }: FeatureCardProps) {
return (
<Card className="bg-card border border-dashed shadow-none hover:shadow-sm transition-all duration-300">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardDescription className="text-xs text-muted-foreground leading-normal">
{description}
</CardDescription>
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500" asChild>
<a href={href || "#"}>{linkText} &rarr;</a>
</Button>
</CardContent>
</Card>
);
}`,
dashboards: `// 7天收入统计: 进度条数据统计卡片
interface IncomeStatItem {
date: string;
amount: number;
percentage: number;
}
export function IncomeStatsCard({ stats }: { stats: IncomeStatItem[] }) {
return (
<Card className="border border-dashed shadow-none p-5">
<CardHeader className="p-0 pb-4">
<CardTitle className="text-xs text-muted-foreground uppercase">7天收入统计</CardTitle>
</CardHeader>
<div className="space-y-2">
{stats.map(s => (
<div key={s.date} className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">{s.date}</span>
<span className="text-emerald-500 font-semibold">+{s.amount.toFixed(2)}</span>
</div>
<Progress value={s.percentage} className="h-1.5" />
</div>
))}
</div>
</Card>
);
}`,
tables: `// 活动记录表格: 数据明细展现
interface MockOrder {
id: string;
order_name: string;
amount: number;
type: 'payment' | 'transfer' | 'community' | 'red_envelope';
status: 'success' | 'pending' | 'expired' | 'disputing' | 'refund';
order_no: string;
created_at: string;
}
export function TransactionTable({ orders }: { orders: MockOrder[] }) {
return (
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/20">
<th className="p-2 text-left font-medium">名称</th>
<th className="p-2 text-center font-medium">额度</th>
<th className="p-2 text-center font-medium">状态</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id} className="hover:bg-muted/30">
<td className="p-2 font-medium">{order.order_name}</td>
<td className="p-2 text-center font-semibold">{order.amount.toFixed(2)}</td>
<td className="p-2 text-center">{order.status}</td>
</tr>
))}
</tbody>
</table>
);
}`,
tabs: `// Tab 触发器及多模块切换导航
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export function MockTradeTabs() {
return (
<Tabs defaultValue="receive" className="w-full">
<TabsList className="flex gap-4 bg-transparent border-b">
<TabsTrigger value="receive" className="px-0 pb-2 text-xs font-bold">
积分收益
</TabsTrigger>
<TabsTrigger value="payment" className="px-0 pb-2 text-xs font-bold">
积分消耗
</TabsTrigger>
</TabsList>
<TabsContent value="receive">积分收益模块内容...</TabsContent>
<TabsContent value="payment">积分消耗模块内容...</TabsContent>
</Tabs>
);
}`,
}
}, [])
return (
<div className="py-6 space-y-6 max-w-6xl mx-auto">
<div>
<h1 className="text-2xl font-bold tracking-tight">静态组件库 (Component Library)</h1>
<p className="text-xs text-muted-foreground mt-1 leading-normal">
展示项目重构中提取的有价值静态组件资源,提供可视化的静态演示与代码片段,便于平台后续的二次开发集成。
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* 左侧导航 */}
<div className="lg:col-span-1 space-y-2">
{categories.map((cat) => (
<button
key={cat.id}
className={`w-full flex items-center gap-3 p-3 rounded-lg border text-left transition-all duration-200 cursor-pointer ${
activeCategory === cat.id
? "bg-indigo-500/10 border-indigo-500/30 text-indigo-600 dark:text-indigo-400 font-medium shadow-sm"
: "bg-card border-border hover:bg-muted/50 text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveCategory(cat.id)}
>
<div className={`p-1.5 rounded-md ${activeCategory === cat.id ? "bg-indigo-500/20" : "bg-muted"}`}>
<cat.icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold">{cat.title}</div>
<div className="text-[10px] text-muted-foreground truncate">{cat.desc}</div>
</div>
</button>
))}
</div>
{/* 右侧主展示区 */}
<div className="lg:col-span-3 space-y-6">
{activeCategory === "cards" && (
<motion.div
key="cards"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">卡片组件展示</h3>
<ReceiveBanner />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FeatureCard
title="接入官方 API 接口"
description="使用官方 API 接口(兼容易支付),快速接入积分服务,支持异步通知和同步跳转。"
linkText="查看文档"
href="/docs/api"
/>
<FeatureCard
title="探索开放平台"
description="通过开放平台页面浏览可用能力,快速对接通用系统功能。"
linkText="查看入口"
href="/docs/how-to-use"
/>
<FeatureCard
title="面对面服务"
description="通过 LINUX DO Credit 提供的面对面服务功能,扩展您的积分收益渠道,处理面对面服务场景。"
linkText="功能开发中,敬请期待"
/>
</div>
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (FeatureCard)</h3>
<CodeShowcase code={codeSnippets.cards} title="FeatureCard.tsx" />
</div>
</motion.div>
)}
{activeCategory === "dashboards" && (
<motion.div
key="dashboards"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">大盘数据组件展示</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4 md:col-span-2">
<MockTrendChart />
</div>
<MockIncomeStats />
<MockTopCustomers />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (IncomeStats)</h3>
<CodeShowcase code={codeSnippets.dashboards} title="IncomeStatsCard.tsx" />
</div>
</motion.div>
)}
{activeCategory === "tables" && (
<motion.div
key="tables"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">活动明细表格展示</h3>
<MockTransactionTable />
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (TransactionTable)</h3>
<CodeShowcase code={codeSnippets.tables} title="TransactionTable.tsx" />
</div>
</motion.div>
)}
{activeCategory === "tabs" && (
<motion.div
key="tabs"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div>
<h3 className="text-base font-semibold mb-3">多标签导航展示</h3>
<MockTradeTabs />
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold text-muted-foreground">组件代码片段 (MockTradeTabs)</h3>
<CodeShowcase code={codeSnippets.tabs} title="TradeTabs.tsx" />
</div>
</motion.div>
)}
</div>
</div>
</div>
)
}
-3
View File
@@ -8,7 +8,6 @@ import { SiteHeader } from "@/components/layout/header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { UserProvider } from "@/contexts/user-context"
import { MerchantProvider } from "@/contexts/merchant-context"
export default function MainLayout({
@@ -21,7 +20,6 @@ export default function MainLayout({
return (
<UserProvider>
<MerchantProvider>
<SidebarProvider
className="h-screen"
style={
@@ -51,7 +49,6 @@ export default function MainLayout({
</div>
</SidebarInset>
</SidebarProvider>
</MerchantProvider>
</UserProvider>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { LeaderboardMain } from "@/components/common/leaderboard/leaderboard-main"
export default function LeaderboardPage() {
return <LeaderboardMain />
}
@@ -1,10 +0,0 @@
import { Suspense } from "react"
import { MerchantOnline } from "@/components/common/merchant/merchant-online"
export default function Page() {
return (
<Suspense>
<MerchantOnline />
</Suspense>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { MerchantMain } from "@/components/common/merchant/merchant-main"
export default function MerchantPage() {
return <MerchantMain />
}
-5
View File
@@ -1,5 +0,0 @@
import { TradeMain } from "@/components/common/trade/trade-main"
export default function TradePage() {
return <TradeMain />
}
-22
View File
@@ -1,22 +0,0 @@
"use client"
import { MerchantProvider } from "@/contexts/merchant-context"
import { UserProvider } from "@/contexts/user-context"
/**
* 积分服务页面布局
* 为积分服务相关的页面提供社区用户和开发者上下文
*/
export default function PayLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<UserProvider>
<MerchantProvider>
{children}
</MerchantProvider>
</UserProvider>
)
}
-10
View File
@@ -1,10 +0,0 @@
import { Suspense } from "react"
import { PayingOnline } from "@/components/common/pay/paying/paying-online"
export default function Page() {
return (
<Suspense>
<PayingOnline />
</Suspense >
)
}
-10
View File
@@ -1,10 +0,0 @@
import { Suspense } from "react"
import { PayingMain } from "@/components/common/pay/paying/paying-main"
export default function Page() {
return (
<Suspense>
<PayingMain />
</Suspense>
)
}
-16
View File
@@ -1,16 +0,0 @@
/**
* 红包页面布局
* 提供简洁的布局,不包含侧边栏和用户上下文
* 允许未登录用户访问红包详情
*/
export default function RedEnvelopeLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen w-full">
{children}
</div>
)
}
@@ -1,32 +0,0 @@
import type { Metadata } from "next"
import { headers } from "next/headers"
import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim"
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const requestHeaders = await headers()
const protocol = requestHeaders.get("x-forwarded-proto") ?? "https"
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? ""
const baseUrl = host ? `${protocol}://${host}` : ""
const title = "你收到一个LDC红包"
const description = "点击打开领取"
const url = baseUrl ? `${baseUrl}/redenvelope/${id}` : undefined
const image = baseUrl ? `${baseUrl}/red-envelope.svg` : "/red-envelope.svg"
return {
title,
description,
openGraph: {
title,
description,
url,
type: "website",
images: [{ url: image, width: 384, height: 512, alt: "红包" }],
}
}
}
export default async function RedEnvelopePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <RedEnvelopeClaimPage id={id} />
}
+4 -25
View File
@@ -1,34 +1,14 @@
import type { Metadata } from "next";
import { Inter, Noto_Sans_SC, Geist_Mono } from 'next/font/google';
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { CustomThemeProvider } from "@/lib/theme";
import { BellRingProvider } from "@/contexts/bell-ring-context";
import { NotificationSettingsProvider } from "@/contexts/notification-settings-context";
import { RiskBlockDialog } from "@/components/common/risk/risk-block-dialog";
import "./globals.css";
const inter = Inter({
variable: '--font-inter',
subsets: ['latin'],
display: 'swap',
});
const notoSansSC = Noto_Sans_SC({
variable: '--font-noto-sans-sc',
subsets: ['latin'],
display: 'swap',
weight: ['300', '400', '500', '600', '700']
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: "LINUX DO Credit",
description: "Linux Do 社区积分服务平台",
title: "Refreshing Platform",
description: "通用二次开发标准范式平台",
};
export default function RootLayout({
@@ -39,11 +19,11 @@ export default function RootLayout({
return (
<html
lang="zh-CN"
className={`${ inter.variable } ${ notoSansSC.variable } ${ geistMono.variable } hide-scrollbar font-sans`}
className="hide-scrollbar font-sans"
suppressHydrationWarning
>
<body
className={`${ inter.variable } ${ notoSansSC.variable } ${ geistMono.variable } hide-scrollbar font-sans antialiased`}
className="hide-scrollbar font-sans antialiased"
>
<ThemeProvider
attribute="class"
@@ -55,7 +35,6 @@ export default function RootLayout({
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<RiskBlockDialog />
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
+5 -178
View File
@@ -7,7 +7,6 @@ import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { LoginForm } from "@/components/auth/login-form"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Check } from "lucide-react"
import { AuroraBackground } from "@/components/ui/aurora-background"
@@ -38,16 +37,6 @@ export function LoginPage() {
const [isCheckingSession, setIsCheckingSession] = useState(() => !searchParams.get('state') || !searchParams.get('code'))
const [loginSuccess, setLoginSuccess] = useState(false)
const [needsPayKeySetup, setNeedsPayKeySetup] = useState(false)
const [payKey, setPayKey] = useState("")
const [confirmPayKey, setConfirmPayKey] = useState("")
const [isSubmittingPayKey, setIsSubmittingPayKey] = useState(false)
const [setupStep, setSetupStep] = useState<'password' | 'confirm'>('password')
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl')
@@ -61,18 +50,6 @@ export function LoginPage() {
return target
}, [searchParams])
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setConfirmPayKey(numericValue)
}
/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
@@ -133,19 +110,12 @@ export function LoginPage() {
setIsProcessingCallback(true)
try {
await services.auth.handleCallback({ state, code })
setLoginSuccess(true)
toast.success("登录成功")
const user = await services.auth.getUserInfo()
if (!user.is_pay_key) {
setNeedsPayKeySetup(true)
} else {
setLoginSuccess(true)
toast.success("登录成功")
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
}
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
} catch (error) {
console.error('OAuth callback error:', error)
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
@@ -157,147 +127,6 @@ export function LoginPage() {
handleOAuthCallback()
}, [searchParams, router, resolveRedirectTarget])
/* 安全密码设置 */
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmittingPayKey(true)
try {
await services.user.updatePayKey(payKey)
toast.success("安全密码设置成功")
setNeedsPayKeySetup(false)
setLoginSuccess(true)
setPayKey("")
setConfirmPayKey("")
setSetupStep('password')
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "设置安全密码失败"
toast.error(errorMessage)
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmittingPayKey(false)
}
}
}
/* 渲染 PayKey 设置界面 */
const renderPayKeySetup = (key: string) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="w-full space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-base font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置安全密码' : '确认安全密码'}
</h3>
<p className="text-xs text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请设置6位数字安全密码,用于安全操作'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 border-input" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 text-lg border-input" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 mx-12">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-9 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className="flex-1 h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmittingPayKey || !isConfirmValid
}
>
{isSubmittingPayKey && <Spinner className="mr-1" />}
{setupStep === 'password' ? '继续' : '完成'}
</Button>
</div>
</form>
</motion.div>
)
return (
<AuroraBackground>
<motion.div
@@ -339,8 +168,6 @@ export function LoginPage() {
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
</div>
</div>
) : needsPayKeySetup ? (
renderPayKeySetup("oauth-pay-key-setup")
) : loginSuccess ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<motion.div
-378
View File
@@ -1,378 +0,0 @@
"use client"
import * as React from "react"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { Input } from "@/components/ui/input"
import { formatDateTime } from "@/lib/utils"
import type { UserPayConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 积分配置详情面板组件
* 显示积分配置的详细信息和编辑面板
*
* @example
* ```tsx
* <PayConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {UserPayConfig} config - 积分配置
* @param {Partial<UserPayConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 积分配置详情面板组件
*/
function PayConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: UserPayConfig | null
editData: Partial<UserPayConfig>
onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-2 gap-0">
<div className="border border-dashed rounded-l-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置等级</label>
<p className="text-xs text-muted-foreground">Level {config?.level}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置ID</label>
<p className="text-xs text-muted-foreground">{config?.id}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed">
<label className="text-xs font-medium text-muted-foreground">更新时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.updated_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
</div>
<div className="border border-dashed rounded-r-lg border-l-0">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最低分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.min_score !== undefined ? editData.min_score.toString() : (config?.min_score?.toString() || '')}
placeholder={editData.min_score === undefined && !config?.min_score ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('min_score', 0)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('min_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最高分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.max_score !== undefined ? (editData.max_score?.toString() || '') : (config?.max_score?.toString() || '')}
placeholder={(editData.max_score === null || editData.max_score === undefined) && (config?.max_score === null || config?.max_score === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('max_score', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('max_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">手续费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.fee_rate !== undefined
? (parseFloat(editData.fee_rate.toString()) * 100).toString()
: (config?.fee_rate ? (parseFloat(config.fee_rate.toString()) * 100).toString() : '')
}
placeholder={editData.fee_rate === undefined && !config?.fee_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('fee_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('fee_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分数转化率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.score_rate !== undefined
? (parseFloat(editData.score_rate.toString()) * 100).toString()
: (config?.score_rate ? (parseFloat(config.score_rate.toString()) * 100).toString() : '')
}
placeholder={editData.score_rate === undefined && !config?.score_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('score_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('score_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分发费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.distribute_rate !== undefined
? (parseFloat(editData.distribute_rate.toString()) * 100).toString()
: (config?.distribute_rate ? (parseFloat(config.distribute_rate.toString()) * 100).toString() : '')
}
placeholder={editData.distribute_rate === undefined && !config?.distribute_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('distribute_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('distribute_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">每日支付上限</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.daily_limit !== undefined ? (editData.daily_limit?.toString() || '') : (config?.daily_limit?.toString() || '')}
placeholder={(editData.daily_limit === null || editData.daily_limit === undefined) && (config?.daily_limit === null || config?.daily_limit === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('daily_limit', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('daily_limit', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 用户积分配置管理组件
*
* @example
* ```tsx
* <UserPayConfigs />
* ```
* @returns {React.ReactNode} 用户积分配置管理组件
*/
export function UserPayConfigs() {
const {
userPayConfigs: configs,
userPayConfigsLoading: loading,
userPayConfigsError: error,
refetchUserPayConfigs,
updateUserPayConfig,
deleteUserPayConfig
} = useAdmin()
const getInitialEditData = (config: UserPayConfig) => ({
min_score: config.min_score,
max_score: config.max_score,
daily_limit: config.daily_limit,
fee_rate: config.fee_rate.toString(),
score_rate: config.score_rate.toString(),
distribute_rate: config.distribute_rate.toString(),
})
const handleSave = async (config: UserPayConfig, editData: Partial<UserPayConfig>) => {
if (!config) return
await updateUserPayConfig(config.id, {
min_score: editData.min_score ?? config.min_score,
max_score: editData.max_score,
daily_limit: editData.daily_limit,
fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(),
score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(),
distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(),
})
await refetchUserPayConfigs()
}
const handleDelete = async (config: UserPayConfig) => {
await deleteUserPayConfig(config.id)
}
return (
<ManagePage<UserPayConfig>
title="积分配置"
data={configs}
loading={loading}
error={error}
onReload={refetchUserPayConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.id}
emptyDescription="未发现积分配置"
loadingDescription="配置加载中"
columns={[
{ header: "等级", cell: (item) => <span className="font-medium">Level {item.level}</span>, width: "min-w-[80px]", align: "left" },
{ header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" },
{ header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" },
{ header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" },
{ header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "更新时间", cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span>, width: "min-w-[200px]", align: "left" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<PayConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+43 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { formatDateTime } from "@/lib/utils"
import type { SystemConfig } from "@/lib/services"
@@ -70,7 +71,22 @@ function SystemConfigDetailPanel({
/>
) : (
<Input
type="number"
type={
config?.key && (
config.key.endsWith('_limit') ||
config.key.endsWith('_minutes') ||
config.key.endsWith('_days') ||
config.key.endsWith('_seconds') ||
config.key.endsWith('_hours') ||
config.key.endsWith('_ttl') ||
config.key.endsWith('_max') ||
config.key.endsWith('_min') ||
config.key.includes('max_') ||
config.key.includes('min_') ||
config.key.includes('limit') ||
config.key.includes('count')
) ? "number" : "text"
}
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
@@ -108,6 +124,17 @@ function SystemConfigDetailPanel({
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置类型</label>
<span className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
config?.type === 'system'
? 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}>
{config?.type === 'system' ? '系统配置' : '业务配置'}
</span>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
@@ -139,6 +166,12 @@ export function SystemConfigs() {
deleteSystemConfig
} = useAdmin()
const [activeTab, setActiveTab] = React.useState<'system' | 'business'>('business')
React.useEffect(() => {
refetchSystemConfigs(activeTab)
}, [activeTab, refetchSystemConfigs])
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
description: config.description
@@ -151,7 +184,6 @@ export function SystemConfigs() {
value: editData.value ?? config.value,
description: editData.description ?? config.description
})
await refetchSystemConfigs()
}
const handleDelete = async (config: SystemConfig) => {
@@ -164,13 +196,21 @@ export function SystemConfigs() {
data={configs}
loading={loading}
error={error}
onReload={refetchSystemConfigs}
onReload={() => refetchSystemConfigs(activeTab)}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
headerExtra={
<Tabs value={activeTab} onValueChange={(val) => setActiveTab(val as 'system' | 'business')} className="w-[180px]">
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsTrigger value="business" className="text-[11px] h-7">业务配置</TabsTrigger>
<TabsTrigger value="system" className="text-[11px] h-7">系统配置</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
+6 -117
View File
@@ -8,10 +8,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users, Loader2 } from "lucide-react"
import { Layers, Search, UserX, UserCheck, Eye, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services"
import { AdminUser, AdminService } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
@@ -43,7 +43,6 @@ export function UsersManager() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
useEffect(() => {
fetchUsers()
@@ -62,25 +61,7 @@ export function UsersManager() {
setDetailOpen(true)
}
const handleUpdateCredits = async (user: AdminUser) => {
try {
setUpdatingUserId(user.id)
const params: DispatchTaskRequest = {
task_type: 'user_gamification',
user_id: user.id
}
await AdminService.dispatchTask(params)
toast.success('积分更新任务已下发', {
description: `正在更新用户 ${ user.username } 的积分数据`
})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setUpdatingUserId(null)
}
}
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
@@ -315,12 +296,7 @@ export function UsersManager() {
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">余额</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">基准分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">支付分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总收益</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总消耗</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono is-last-money">总划转</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
@@ -358,24 +334,7 @@ export function UsersManager() {
</div>
</div>
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 dark:text-green-500 font-medium py-1">
{Number(user.available_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.community_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.pay_score).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 py-1">
{Number(user.total_receive).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-red-500 py-1">
{Number(user.total_payment).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-purple-500 py-1 is-last-money">
{Number(user.total_community).toFixed(2)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
@@ -404,24 +363,7 @@ export function UsersManager() {
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleUpdateCredits(user)}
disabled={updatingUserId === user.id}
>
<Loader2 className={cn("size-3", updatingUserId === user.id && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
更新积分
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -487,60 +429,7 @@ export function UsersManager() {
</div>
</div>
<div className="p-6">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-1">核心资产</h4>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Wallet className="w-4 h-4" />
<span className="text-[10px] font-medium">可用余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.available_balance).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<CreditCard className="w-4 h-4" />
<span className="text-[10px] font-medium">支付积分</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.pay_score).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Users className="w-4 h-4" />
<span className="text-[10px] font-medium">社区余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.community_balance).toFixed(2)}
</div>
</div>
</div>
</div>
<Separator className="mx-6 w-auto opacity-50" />
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">财务概况</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收益</span>
<span className="font-mono text-[10px] font-medium text-purple-600 dark:text-purple-400">+{Number(selectedUser.total_community).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收入</span>
<span className="font-mono text-[10px] font-medium text-green-600">+{Number(selectedUser.total_receive).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计支出</span>
<span className="font-mono text-[10px] font-medium text-red-500">{Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
@@ -1,54 +0,0 @@
"use client"
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { BalanceReport } from "@/components/common/balance/balance-report"
import { BalanceSummary } from "@/components/common/balance/balance-summary"
import { BalanceTable } from "@/components/common/balance/balance-table"
import { useUser } from "@/contexts/user-context"
/**
* 积分余额主页面组件
*
* 负责组装积分余额页面的各个子组件,包括积分余额总览、积分余额摘要、近期活动和报告侧边栏
*/
export function BalanceMain() {
const { user, loading } = useUser()
/** 计算总余额 */
const totalBalance = React.useMemo(() => {
return parseFloat(user?.available_balance || '0')
}, [user?.available_balance])
return (
<div className="py-6">
<div className="flex items-center gap-2 pb-2">
<h1 className="text-3xl">
<span className="font-semibold">积分</span>
<span className="pl-2">LDC</span>
<span className="pl-2">
{loading ? "-" : <CountingNumber number={totalBalance} decimalPlaces={2} />}
</span>
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 pt-4">
<div className="lg:col-span-2 space-y-12">
<section aria-labelledby="balance-summary-title">
<h2 id="balance-summary-title" className="font-semibold mb-4">积分摘要</h2>
<BalanceSummary />
</section>
<section aria-labelledby="recent-activity-title">
<h2 id="recent-activity-title" className="font-semibold mb-2">近期活动</h2>
<BalanceTable />
</section>
</div>
<aside className="lg:col-span-1" aria-labelledby="reports-title">
<BalanceReport />
</aside>
</div>
</div>
)
}
@@ -1,15 +0,0 @@
import * as React from "react"
/**
* 余额报告组件
*
* 显示可下载或查看的报告列表
*/
export function BalanceReport() {
return (
<section className="space-y-4" aria-labelledby="reports-title">
<h2 id="reports-title" className="font-semibold">报告</h2>
<div className="text-sm text-muted-foreground">暂无报告</div>
</section>
)
}
@@ -1,117 +0,0 @@
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { useUser } from "@/contexts/user-context"
/** 颜色配置 - 统一管理主题色 */
const COLORS = {
available: "bg-primary/80",
pending: "bg-zinc-400/80",
emptyState: "bg-muted/50",
} as const
/**
* 计算积分余额百分比
*/
function calculatePercentages(available: number, total: number) {
if (total <= 0) {
return { available: 0, pending: 0 }
}
const pending = total - available
return {
available: (available / total) * 100,
pending: (pending / total) * 100,
}
}
/**
* 安全转换数字,避免 NaN
*/
function safeNumber(value: number): number {
return typeof value === 'number' && !isNaN(value) ? value : 0
}
/**
* 积分余额摘要组件
*
* 显示积分余额的可视化摘要,包括可视化进度条和详细的积分余额分类列表
*/
export function BalanceSummary() {
const { user, loading } = useUser()
const available = parseFloat(user?.available_balance || '0')
const pending = parseFloat(user?.pending_balance || '0')
const total = available + pending
const percentages = React.useMemo(
() => calculatePercentages(available, total),
[available, total]
)
return (
<div className="space-y-4">
<div
className={`w-full h-4 ${ COLORS.emptyState } rounded-sm overflow-hidden flex`}
role="progressbar"
aria-label="积分余额分布"
aria-valuenow={percentages.available}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`${ COLORS.available } transition-all duration-300`}
style={{ width: `${ percentages.available }%` }}
title={`可用: ${ percentages.available.toFixed(1) }%`}
/>
<div
className={`${ COLORS.pending } transition-all duration-300`}
style={{ width: `${ percentages.pending }%` }}
title={`未来积分: ${ percentages.pending.toFixed(1) }%`}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-medium pb-2 border-b border-border/80">
<span>积分类型</span>
<span className="text-muted-foreground">值</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.available } rounded-xs`} aria-hidden="true" />
<span>可用</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(available)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.pending } rounded-xs`} aria-hidden="true" />
<span>未来积分</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(pending)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
</div>
</div>
)
}
@@ -1,150 +0,0 @@
"use client"
import * as React from "react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TransactionTableList } from "@/components/common/general/table-data"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { DEFAULT_ORDER_TYPES, type OrderType } from "@/lib/services"
import { formatLocalDate } from "@/lib/utils"
/** 标签触发器样式 */
const TAB_TRIGGER_STYLES =
"data-[state=active]:bg-transparent " +
"data-[state=active]:shadow-none " +
"data-[state=active]:border-0 " +
"data-[state=active]:border-b-2 " +
"data-[state=active]:border-indigo-500 " +
"bg-transparent " +
"rounded-none " +
"border-0 " +
"border-b-2 " +
"border-transparent " +
"px-0 " +
"text-sm " +
"font-bold " +
"text-muted-foreground " +
"data-[state=active]:text-indigo-500 " +
"-mb-[2px] " +
"relative " +
"hover:text-foreground " +
"transition-colors " +
"flex-none"
/** 标签配置 - 数据驱动渲染 */
const TABS = [
{ value: "receive" as const, label: "积分收益" },
{ value: "payment" as const, label: "积分消耗" },
{ value: "community" as const, label: "社区划转" },
{ value: "online" as const, label: "在线流转" },
{ value: "all" as const, label: "所有活动" },
] as const
/**
* 计算最近30天的时间范围
* 每次调用时重新计算,避免缓存过期时间
*/
function getTimeRange() {
const now = new Date()
now.setHours(23, 59, 59, 999)
const start = new Date(now)
start.setDate(start.getDate() - 30)
start.setHours(0, 0, 0, 0)
return {
startTime: formatLocalDate(start),
endTime: formatLocalDate(now)
}
}
/**
* 积分余额活动表格组件
*
* 显示不同类型的积分余额活动记录,支持多标签切换和分页加载
*/
export function BalanceTable() {
const [activeTab, setActiveTab] = React.useState<OrderType | "all">("all")
const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null)
React.useEffect(() => {
setTimeRange(getTimeRange())
}, [])
if (!timeRange) {
return null
}
return (
<div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as OrderType | "all")}
className="w-full"
>
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={TAB_TRIGGER_STYLES}
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="mt-2">
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: timeRange.startTime,
endTime: timeRange.endTime,
}}
>
<TransactionList
type={activeTab === "all" ? undefined : activeTab}
/>
</TransactionProvider>
</div>
</Tabs>
</div>
)
}
/**
* 积分余额活动列表组件
*
* 负责获取和显示积分余额活动数据
*/
const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) {
const {
transactions,
loading,
error,
lastParams,
fetchTransactions,
} = useTransaction()
/** 当积分余额活动类型变化时重新加载数据 */
React.useEffect(() => {
fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
return (
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})}
/>
)
})
+1 -1
View File
@@ -151,7 +151,7 @@ signBase64 = base64.StdEncoding.EncodeToString(signature)`}
<li><strong>协议:</strong>EasyPay / CodePay / VPay 兼容协议</li>
<li><strong>服务类型:</strong>仅支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=epay</code></li>
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
<li><strong>订单有效期:</strong>取系统配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">merchant_order_expire_minutes</code>(平台端设置)</li>
<li><strong>订单有效期:</strong>取系统配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">order_expire_minutes</code>(平台端设置)</li>
</ul>
<h3 id="2-2-common-errors" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.2 常见错误</h3>
@@ -55,7 +55,7 @@ export const howToUseSections: PolicySection[] = [
<li>
<strong>创建应用</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a></li>
<li>前往 <a href="/home" className="text-primary hover:underline">控制面板</a></li>
<li>点击顶部右侧 <strong>创建应用</strong> 按钮</li>
<li>填写必要信息:应用名称、应用主页、回调地址、通知地址</li>
</ul>
@@ -86,7 +86,7 @@ export const howToUseSections: PolicySection[] = [
<li><strong>适用场景:</strong>无代码开发基础,或只用于简单的积分服务。</li>
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a> 创建应用,获取 API 凭证</li>
<li>前往 <a href="/home" className="text-primary hover:underline">控制面板</a> 创建应用,获取 API 凭证</li>
<li>选择应用,点击 <strong>在线收款</strong> 功能</li>
<li>创建在线积分服务</li>
<li>获取唯一积分服务链接</li>
@@ -101,7 +101,7 @@ export const howToUseSections: PolicySection[] = [
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-2 text-muted-foreground">
<li>
前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a>,点击 <strong>创建应用</strong>,填写 New API 站点信息:
前往 <a href="/home" className="text-primary hover:underline">控制面板</a>,点击 <strong>创建应用</strong>,填写 New API 站点信息:
<div className="my-4">
<DocsTable>
<DocsTableHeader>
@@ -1,760 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Spinner } from "@/components/ui/spinner"
import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import type { Order, DisputeWithOrder, Dispute } from "@/lib/services"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { DisputeService } from "@/lib/services"
import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import { usePublicConfig } from "@/hooks/use-public-config"
import { useTransaction } from "@/contexts/transaction-context"
import { useUser } from "@/contexts/user-context"
import { Textarea } from "@/components/ui/textarea"
import { typeConfig } from "@/components/common/general/table-filter"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
type DisputeViewer = 'payer' | 'payee'
/**
* 活动详情弹窗
*/
export function OrderDetailDialog({ order }: { order: Order }) {
const [open, setOpen] = useState(false)
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full"
onClick={() => setOpen(true)}
>
<Banknote className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>查看详情</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false} className="bg-transparent border-0 shadow-none p-0 w-100">
<VisuallyHidden>
<DialogHeader>
<DialogTitle>积分流转详情</DialogTitle>
<DialogDescription>查看积分活动的详细信息</DialogDescription>
</DialogHeader>
</VisuallyHidden>
<div className="p-5 space-y-5 bg-card border border-border/50 rounded-lg shadow-xl">
<div className="flex flex-col items-center space-y-2 pb-4 border-b-2 border-dashed border-border/50">
<h3 className="font-bold text-2xl tracking-wider uppercase">{order.app_name || 'RECEIPT'}</h3>
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-widest">
{formatDateTime(order.created_at)}
</p>
</div>
<div className="text-center py-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Total Credits</p>
<div className="text-4xl font-black tracking-tighter flex items-start justify-center gap-1">
{parseFloat(order.amount).toFixed(2)}
</div>
</div>
<div className="space-y-0 text-sm border-y-2 border-dashed border-border/50 py-4">
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">编号</span>
<span className="font-mono text-xs font-medium">{order.order_no}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">类型</span>
<span className="text-xs font-medium">{typeConfig[order.type]?.label || order.type}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">消费方</span>
<span className="text-xs font-medium">{order.payer_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">服务方</span>
<span className="text-xs font-medium">{order.payee_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">备注</span>
<span className="text-xs font-medium">{order.remark}</span>
</div>
{(order.status === 'success' || order.status === 'refund') && (
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">时间</span>
<span className="font-mono text-xs">{formatDateTime(order.trade_time)}</span>
</div>
)}
</div>
<div className="pt-2 flex flex-col items-center space-y-3">
<div className="h-8 w-full max-w-[200px] flex items-end justify-between opacity-40">
{[...Array(30)].map((_, i) => (
<div
key={i}
className="bg-foreground w-[2px]"
style={{ height: `${ Math.max(40, Math.random() * 100) }%` }}
/>
))}
</div>
<p className="text-[10px] text-muted-foreground font-mono tracking-wider text-center">
LINUX DO Credit
</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
/**
* 发起争议弹窗
*/
export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const isMobile = useIsMobile()
const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig()
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [reason, setReason] = useState("")
const resetForm = () => {
setReason("")
}
const handleButtonClick = async () => {
if (configLoading) {
try {
await new Promise(resolve => setTimeout(resolve, 100))
if (configLoading) {
toast.error('配置加载中', { description: '请稍候再试' })
return
}
} catch {
toast.error('配置加载失败', { description: '请刷新页面重试' })
return
}
}
if (configError || !publicConfig) {
toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' })
return
}
const tradeTime = new Date(order.trade_time)
const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000)
const now = new Date()
if (now > expiryTime) {
toast.error('无法发起争议', { description: '已超过最晚发起争议时间' })
return
}
setOpen(true)
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleCreateDispute = async () => {
if (!reason.trim()) {
toast.error('表单验证失败', { description: '请填写争议原因' })
return
}
if (reason.length > 100) {
toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' })
return
}
try {
setLoading(true)
const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() })
toast.success('争议已发起', { description: '请等待服务方处理' })
updateOrderStatus(order.id, {
status: 'disputing',
dispute_id: dispute.id
})
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '发起争议失败'
toast.error('发起争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>发起争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>发起争议</DialogTitle>
<DialogDescription>
如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason" className={isMobile ? "text-sm" : ""}>
争议原因
</Label>
<Textarea
id="reason"
placeholder="请详细描述您遇到的问题..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className={isMobile ? "text-sm" : ""}
/>
<p className="text-xs text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCreateDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认发起'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 取消争议弹窗
*/
export function CancelDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const handleCancelDispute = async () => {
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('取消争议失败', { description: '未找到争议记录' })
return
}
await DisputeService.closeDispute({ dispute_id: order.dispute_id })
toast.success('争议已取消', { description: '争议已成功取消' })
updateOrderStatus(order.id, { status: 'success' })
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '取消争议失败'
toast.error('取消争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setOpen(true)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>争议正在进行中,点击取消</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议取消</DialogTitle>
<DialogDescription>
您确定取消当前的争议吗?取消后争议将恢复正常。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCancelDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 取消中</> : '确认取消'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 查看争议历史弹窗
*/
export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: Order; viewer?: DisputeViewer }) {
const [open, setOpen] = useState(false)
const [disputeHistory, setDisputeHistory] = useState<Dispute | null>(null)
const [fetchingHistory, setFetchingHistory] = useState(false)
const disputeConfig = React.useMemo(() => {
switch (order.status) {
case 'refused':
return {
tooltip: '争议已拒绝,点击查看',
timelineText: '服务方驳回争议',
isRed: true,
showTimestamp: true,
showContent: false,
content: null
}
case 'refund':
return {
tooltip: '争议已退款',
timelineText: '服务方已退款',
isRed: false,
showTimestamp: true,
showContent: true,
content: '退款已完成'
}
case 'disputing':
return {
tooltip: '争议处理中,点击查看',
timelineText: '争议进行中,等待服务方处理',
isRed: false,
showTimestamp: false,
showContent: false,
content: null
}
}
}, [order.status])
if (!disputeConfig) return null
const resetForm = () => {
setDisputeHistory(null)
}
const fetchDisputeHistory = async () => {
try {
setFetchingHistory(true)
if (!order.dispute_id) {
toast.error('获取争议记录失败', { description: '未找到争议记录' })
return
}
const params = {
page: 1,
page_size: 1,
dispute_id: order.dispute_id
}
const res = viewer === 'payee'
? await DisputeService.listMerchantDisputes(params)
: await DisputeService.listDisputes(params)
if (res.disputes && res.disputes.length > 0) {
setDisputeHistory(res.disputes[0])
} else {
toast.error('未找到争议记录')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议记录失败'
toast.error('获取争议记录失败', { description: errorMessage })
} finally {
setFetchingHistory(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeHistory()
} else {
setOpen(false)
}
}
const parseDisputeReason = (fullReason: string) => {
const match = fullReason.match(/^(.*?)\s*\[(?:服务方|受托方)拒绝理由:\s*(.*?)\]$/)
if (match) {
return { userReason: match[1].trim(), merchantReason: match[2].trim() }
}
return { userReason: fullReason, merchantReason: null }
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-orange-600 hover:text-orange-700 hover:bg-orange-50"
onClick={() => handleOpenChange(true)}
>
<Layers className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{disputeConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议详情</DialogTitle>
<DialogDescription>查看争议处理记录</DialogDescription>
</DialogHeader>
{fetchingHistory ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : disputeHistory ? (
<div className="py-4 relative pl-4 border-l border-border/50 space-y-8 ml-2">
<div className="relative">
<div className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-background" />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">消费方发起争议</span>
<span className="text-xs text-muted-foreground">{formatDateTime(disputeHistory.created_at)}</span>
</div>
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).userReason}
</div>
</div>
</div>
<div className="relative">
<div className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${ disputeConfig.isRed ? 'bg-destructive' : 'bg-primary' } ring-4 ring-background`} />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${ disputeConfig.isRed ? 'text-destructive' : '' }`}>
{disputeConfig.timelineText}
</span>
{disputeConfig.showTimestamp && (
<span className="text-xs text-muted-foreground">
{formatDateTime(disputeHistory.updated_at)}
</span>
)}
</div>
{order.status === 'refused' && (
<div className="text-sm text-muted-foreground bg-destructive/5 border border-destructive/10 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
</div>
)}
{order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{disputeConfig.content}
</div>
)}
</div>
</div>
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
无法加载争议记录
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" className="h-8 text-xs">
关闭
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 退款审核活动弹窗
*/
export function RefundReviewDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const { refetch: refetchUser } = useUser()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [action, setAction] = useState<'refund' | 'closed' | null>(null)
const [reason, setReason] = useState("")
const [disputeInfo, setDisputeInfo] = useState<DisputeWithOrder | null>(null)
const [fetchingDispute, setFetchingDispute] = useState(false)
const resetForm = () => {
setAction(null)
setReason("")
setDisputeInfo(null)
}
const fetchDisputeInfo = async () => {
try {
setFetchingDispute(true)
if (!order.dispute_id) {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
return
}
const res = await DisputeService.listMerchantDisputes({
page: 1,
page_size: 1,
dispute_id: order.dispute_id
})
if (res.disputes && res.disputes.length > 0) {
setDisputeInfo(res.disputes[0])
} else {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议详情失败'
toast.error('获取争议详情失败', { description: errorMessage })
} finally {
setFetchingDispute(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeInfo()
} else if (!loading) {
setOpen(false)
}
}
const handleSubmit = async () => {
if (!action) {
toast.error('请选择处理方式')
return
}
if (action === 'closed' && !reason.trim()) {
toast.error('请填写拒绝原因')
return
}
if (reason.length > 100) {
toast.error('拒绝理由不能超过 100 个字符')
return
}
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('处理失败', { description: '未找到争议记录' })
return
}
await DisputeService.refundReview({
dispute_id: order.dispute_id,
status: action,
reason: action === 'closed' ? reason.trim() : undefined,
})
toast.success('处理成功', {
description: action === 'refund' ? '已同意积分退回,争议结束' : '已拒绝积分退回,此积分活动正常进行'
})
updateOrderStatus(order.id, {
status: action === 'refund' ? 'refund' : 'refused'
})
setOpen(false)
refetchUser()
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '处理争议失败'
toast.error('处理争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50"
onClick={() => {
setOpen(true)
fetchDisputeInfo()
}}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>处理争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>处理争议</DialogTitle>
<DialogDescription>
请审核此争议。您可以选择同意或拒绝退回积分。
</DialogDescription>
</DialogHeader>
{fetchingDispute ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : (
<div className="space-y-4 py-2">
{disputeInfo && (
<div className="rounded-md bg-muted/40 p-3 text-sm">
<div className="flex items-center gap-2 mb-2 text-muted-foreground text-xs">
<span>发起争议理由</span>
<span>•</span>
<span>{formatDateTime(disputeInfo.created_at)}</span>
</div>
<p className="text-sm font-medium leading-relaxed">
{disputeInfo.reason}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-primary ${ action === 'refund' && 'border-primary text-primary' }`}
onClick={() => setAction('refund')}
>
<div className="flex items-center gap-2">
<Banknote className="h-4 w-4" />
<span className="font-semibold">同意积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">积分全额退回,争议结束</span>
</Button>
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-destructive ${ action === 'closed' && 'border-destructive text-destructive' }`}
onClick={() => setAction('closed')}
>
<div className="flex items-center gap-2">
<TicketSlash className="h-4 w-4" />
<span className="font-semibold">拒绝积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">拒绝积分退回,此积分活动正常进行</span>
</Button>
</div>
{action === 'closed' && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 pt-2">
<Label htmlFor="reject-reason" className="text-xs font-medium">
拒绝原因 <span className="text-destructive">*</span>
</Label>
<Textarea
id="reject-reason"
placeholder="请说明拒绝退回的原因..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
)}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
disabled={loading || !action}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认处理'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -1,241 +0,0 @@
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { TablePagination, FilterSelect } from "@/components/common/general/table-filter"
import { Inbox, ListRestart, Gift, Coins, Check, Copy, ExternalLink } from "lucide-react"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { formatDateTime } from "@/lib/utils"
import type { RedEnvelope } from "@/lib/services"
export interface ListDataProps {
fetchData: (params: { page: number, page_size: number, type: string }) => Promise<{ list: RedEnvelope[], total: number }>
tabs: { value: string, label: string, color?: string }[]
defaultTab?: string
pageSize?: number
className?: string
emptyState?: (tab: string) => React.ReactNode
refreshTrigger?: number
}
/**
* 红包列表数据组件
*
* 提供分页、标签筛选、数据加载状态管理和网格布局,专门用于展示红包列表
*/
export function ListData({
fetchData,
tabs,
defaultTab,
pageSize = 20,
className,
emptyState,
refreshTrigger = 0
}: ListDataProps) {
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.value)
/* 数据状态 */
const [items, setItems] = useState<RedEnvelope[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [currentPageSize, setCurrentPageSize] = useState(pageSize)
const [copiedId, setCopiedId] = useState<string | null>(null)
/* 缓存页码 */
const loadData = async (type: string, p: number, s: number) => {
setLoading(true)
try {
const result = await fetchData({ page: p, page_size: s, type })
setItems(result.list)
setTotal(result.total)
} catch (error) {
toast.error('加载数据失败')
console.error(error)
} finally {
setLoading(false)
}
}
/* 初始加载和Tab切换 */
useEffect(() => {
setPage(1)
loadData(activeTab, 1, currentPageSize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, refreshTrigger])
const handlePageChange = (p: number) => {
setPage(p)
loadData(activeTab, p, currentPageSize)
}
const handlePageSizeChange = (s: number) => {
setCurrentPageSize(s)
setPage(1)
loadData(activeTab, 1, s)
}
const totalPages = Math.ceil(total / currentPageSize)
const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${ window.location.origin }/redenvelope/${ id }`
}
return `/redenvelope/${ id }`
}
const handleCopyLink = async (link: string, envelopeId: string) => {
try {
await navigator.clipboard.writeText(link)
setCopiedId(envelopeId)
toast.success("链接已复制")
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error("复制失败")
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-green-600 bg-green-50/50 border-green-200">进行中</Badge>
case 'finished':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-muted-foreground bg-muted/50 border-dashed">已领完</Badge>
case 'expired':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-red-500 bg-red-50/50 border-red-200">已过期</Badge>
default:
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none">{status}</Badge>
}
}
/* 默认空状态 */
const defaultEmptyState = (
<EmptyStateWithBorder
icon={Inbox}
description="暂无数据"
/>
)
return (
<div className={className}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<FilterSelect<string>
label="视图"
selectedValues={[activeTab]}
options={tabs.reduce((acc, tab) => ({
...acc,
[tab.value]: { label: tab.label, color: tab.color }
}), {})}
onToggleValue={(v) => setActiveTab(v)}
/>
</div>
<TablePagination
currentPage={totalPages > 0 ? page : 0}
totalPages={totalPages}
total={total}
pageSize={currentPageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRefresh={() => loadData(activeTab, page, currentPageSize)}
loading={loading}
/>
</div>
<div className="space-y-4">
{loading && items.length === 0 ? (
<LoadingStateWithBorder icon={ListRestart} />
) : items.length === 0 ? (
emptyState ? emptyState(activeTab) : defaultEmptyState
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((envelope) => {
const isSent = activeTab === 'sent'
const link = getEnvelopeLink(envelope.id)
const claimedCount = envelope.total_count - envelope.remaining_count
return (
<Card
key={envelope.id}
className="group border-dashed hover:bg-muted/30 transition-all bg-card/50 shadow-none"
>
<CardContent className="px-4 py-1">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 border ${ isSent
? "bg-red-50 text-red-600 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/30"
: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-900/30"
}`}>
{isSent ? <Gift size={16} /> : <Coins size={16} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-xs text-foreground truncate select-none">
{isSent
? (envelope.type === 'random' ? '拼手气红包' : '普通红包')
: envelope.creator_username
}
</span>
{getStatusBadge(envelope.status)}
</div>
<div className="text-[10px] text-muted-foreground truncate max-w-[140px] mt-0.5">
{envelope.greeting || "恭喜发财,大吉大利"}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-2.5 px-2.5 py-2 bg-muted/40 rounded-md text-xs">
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">总金额</div>
<div className="font-mono font-medium text-foreground">{envelope.total_amount}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">已领取</div>
<div className="font-medium text-foreground">{claimedCount}/{envelope.total_count}</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 -mb-2">
<div className="text-[10px] text-muted-foreground/60 font-mono">
{formatDateTime(envelope.created_at)}
</div>
<div className="flex gap-1 h-6">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => handleCopyLink(link, envelope.id)}
>
{copiedId === envelope.id ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => window.open(link, '_blank')}
>
<ExternalLink className="size-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</div>
)
}
@@ -56,6 +56,7 @@ interface ManagePageProps<T> {
emptyDescription?: string
loadingDescription?: string
getId: (item: T) => string | number
headerExtra?: React.ReactNode
}
export function ManagePage<T>({
@@ -72,7 +73,8 @@ export function ManagePage<T>({
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
getId
getId,
headerExtra
}: ManagePageProps<T>) {
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
@@ -199,8 +201,9 @@ export function ManagePage<T>({
return (
<div className="py-6">
<div className="flex border-b border-border pb-2 mb-6">
<div className="flex border-b border-border pb-2 mb-6 items-center justify-between">
<div className="text-2xl font-semibold">{title}</div>
{headerExtra}
</div>
<div className="space-y-6">
@@ -1,368 +0,0 @@
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { typeConfig, statusConfig } from "@/components/common/general/table-filter"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { ErrorInline } from "@/components/layout/error"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { Lightbulb, ListRestart, Layers, LucideIcon } from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import type { Order } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
import {
OrderDetailDialog,
CreateDisputeDialog,
ViewDisputeHistoryDialog,
RefundReviewDialog,
} from "./dispute-dialog"
import {usePublicConfig} from "@/hooks/use-public-config";
const FALLBACK_TYPE_CONFIG = {
label: '历史活动',
color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
}
const ROW_HEIGHT = 36
/**
* 虚拟化交易数据表格组件
* 使用 @tanstack/react-virtual 实现大数据量高性能渲染
*/
export const TransactionDataTable = React.memo(function TransactionDataTable({
transactions
}: {
transactions: Order[]
}) {
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: transactions.length,
getScrollElement: () => {
if (!scrollAreaRef.current) return null
const viewport = scrollAreaRef.current.querySelector('[data-slot="scroll-area-viewport"]')
return viewport as Element
},
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
const virtualItems = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea
ref={scrollAreaRef}
className="w-full h-[600px] whitespace-nowrap"
>
<table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-30 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="whitespace-nowrap w-[120px]">名称</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[76px]">积分</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[50px]">类型</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[84px]">状态</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[80px]">积分动向</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[80px]">应用名</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[120px]">编号</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[120px]">业务单号</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">创建时间</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">交易时间</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">订单过期时间</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[100px]">备注</TableHead>
<TableHead className="sticky right-0 whitespace-nowrap text-center bg-background shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] w-[150px] z-40">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody
key={transactions[0]?.id}
className="animate-in fade-in duration-200 [will-change:transform,opacity]"
>
{paddingTop > 0 && (
<tr style={{ height: `${ paddingTop }px` }}>
<td colSpan={13} />
</tr>
)}
{virtualItems.map((virtualRow) => {
const order = transactions[virtualRow.index]
return (
<TransactionTableRow
key={order.id}
order={order}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
/>
)
})}
{paddingBottom > 0 && (
<tr style={{ height: `${ paddingBottom }px` }}>
<td colSpan={13} />
</tr>
)}
</TableBody>
</table>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
})
/**
* 交易表格行组件
*/
const TransactionTableRow = React.memo(React.forwardRef<HTMLTableRowElement, {
order: Order
style?: React.CSSProperties
} & React.HTMLAttributes<HTMLTableRowElement>>(function TransactionTableRow({
order,
style,
...props
}, ref) {
const { user } = useUser()
const getAmountDisplay = (amount: string) => (
<span className="text-xs font-semibold">
{parseFloat(amount).toFixed(2)}
</span>
)
const { config: publicConfig } = usePublicConfig()
const isDisputeSupported = order.type === 'payment' || order.type === 'online' || order.type === 'receive'
const isCurrentUserPayer = user?.id === order.payer_user_id
const isCurrentUserPayee = user?.id === order.payee_user_id
const isDisputing = order.status === 'disputing' && isCurrentUserPayee
const hasPendingTransfer = order.payee_transfer_status === 'pending' && isCurrentUserPayee
const currentTypeConfig = typeConfig[order.type as keyof typeof typeConfig] ?? FALLBACK_TYPE_CONFIG
const pendingTransferHint = React.useMemo(() => {
const settleAt = order.payee_transfer_at ? formatDateTime(order.payee_transfer_at) : `${publicConfig?.settlement_delay_days_min ?? 7}-${publicConfig?.settlement_delay_days_max ?? 14} 天`
switch (order.status) {
case 'success':
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
case 'refused':
return `延迟结算机制:已拒绝退款,积分会在 ${settleAt} 后转入可用积分`
case 'disputing':
return `延迟结算机制:争议处理中,积分会在 ${settleAt} 后转入可用积分`
case 'refund':
return `延迟结算机制:已退款成功,积分会在 ${settleAt} 后转入可用积分`
default:
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
}
}, [order.status, order.payee_transfer_at, publicConfig?.settlement_delay_days_max, publicConfig?.settlement_delay_days_min])
return (
<TableRow
ref={ref}
style={style}
{...props}
className={`
border-dashed group hover:bg-muted/50 data-[state=selected]:bg-muted
${ isDisputing ? 'bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/30' : '' }
`}
>
<TableCell className="text-[11px] font-medium whitespace-nowrap py-1">
{order.order_name}
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
{getAmountDisplay(order.amount)}
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
<Badge
variant="secondary"
className={`text-[10px] px-1 ${ currentTypeConfig.color }`}
>
{currentTypeConfig.label}
</Badge>
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
<div className="flex justify-center">
<div className="relative inline-flex items-center">
<Badge
variant="secondary"
className={`text-[10px] px-1 ${ statusConfig[order.status].color }`}
>
{statusConfig[order.status].label}
</Badge>
{hasPendingTransfer && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute left-full ml-1 inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
aria-label="查看到账说明"
>
<Lightbulb className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="px-2.5 py-1.5">
{pendingTransferHint}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
{order.status === 'pending' || order.status === 'expired' || order.type === 'community' || order.type === 'red_envelope_send' || order.type === 'red_envelope_refund' ? (
<div className="text-muted-foreground">-</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-pointer gap-1 justify-center">
<Avatar className="h-4 w-4">
<AvatarImage src={order.payer_avatar_url || undefined} />
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
{order.payer_username.substring(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-xs font-bold">⭢</div>
<Avatar className="h-4 w-4">
<AvatarImage src={order.payee_avatar_url || undefined} />
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
{order.payee_username.substring(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="px-2.5 py-1.5">
<div className="space-y-2">
<div>
<p className="text-xs font-semibold">消费方</p>
<p className="text-xs">ID: {order.payer_user_id}</p>
<p className="text-xs">账户: {order.payer_username}</p>
</div>
<div>
<p className="text-xs font-semibold">服务方</p>
<p className="text-xs">ID: {order.payee_user_id}</p>
<p className="text-xs">账户: {order.payee_username}</p>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="text-[11px] font-medium text-center py-1">
{order.app_name || '-'}
</TableCell>
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
{order.order_no}
</TableCell>
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
{order.merchant_order_no || '-'}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{formatDateTime(order.created_at)}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{(order.status === 'success' || order.status === 'refund') ? formatDateTime(order.trade_time) : '-'}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{formatDateTime(order.expires_at)}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1 max-w-[250px] truncate" title={order.remark || ''}>
{order.remark || '-'}
</TableCell>
<TableCell className={`
sticky right-0 whitespace-nowrap text-center shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] py-1 z-20
bg-background
after:absolute after:inset-0 after:z-[-1] after:content-[''] after:pointer-events-none after:transition-colors
${ isDisputing
? 'after:bg-yellow-50 dark:after:bg-yellow-900/20 group-hover:after:bg-yellow-100/50 dark:group-hover:after:bg-yellow-900/30'
: 'group-hover:after:bg-muted/50' }
`}>
<OrderDetailDialog order={order} />
{/* 场景1:消费方对成功的订单发起争议 */}
{isDisputeSupported && isCurrentUserPayer && order.status === 'success' && (
<CreateDisputeDialog order={order} />
)}
{/* 场景2:消费方查看正在进行的争议 */}
{isDisputeSupported && isCurrentUserPayer && order.status === 'disputing' && (
<ViewDisputeHistoryDialog order={order} viewer="payer" />
)}
{/* 场景3:消费方查看已处理的争议(拒绝或退款) */}
{isDisputeSupported && isCurrentUserPayer && (order.status === 'refused' || order.status === 'refund') && (
<ViewDisputeHistoryDialog order={order} viewer="payer" />
)}
{/* 场景4:服务方处理正在进行的争议 */}
{isDisputeSupported && isCurrentUserPayee && order.status === 'disputing' && (
<RefundReviewDialog order={order} />
)}
{/* 场景5:服务方查看已处理的争议(拒绝或退款) */}
{isDisputeSupported && isCurrentUserPayee && (order.status === 'refused' || order.status === 'refund') && (
<ViewDisputeHistoryDialog order={order} viewer="payee" />
)}
</TableCell>
</TableRow>
)
}))
interface TransactionTableListProps {
loading: boolean
error: Error | null
transactions: Order[]
onRetry: () => void
emptyIcon?: LucideIcon
emptyDescription?: string
}
/**
* 交易列表容器组件
* 统一处理加载、错误、空状态
*/
export const TransactionTableList = React.memo(function TransactionTableList({
loading,
error,
transactions,
onRetry,
emptyIcon = Layers,
emptyDescription = "未发现积分活动"
}: TransactionTableListProps) {
if (loading && transactions.length === 0) {
return (
<LoadingStateWithBorder
icon={ListRestart}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onRetry}
className="justify-center"
/>
</div>
)
}
if (!transactions || transactions.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<TransactionDataTable transactions={transactions} />
)
})
export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialog"
@@ -1,721 +0,0 @@
import * as React from "react"
import { Filter, CalendarIcon, X, Search, ChevronLeft, ChevronRight, ChevronDown, Loader2 } from "lucide-react"
import { zhCN } from "date-fns/locale"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Separator } from "@/components/ui/separator"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import type { OrderType, OrderStatus, TransactionQueryParams } from "@/lib/services"
/* 类型标签配置 */
export const typeConfig: Record<OrderType, { label: string; color: string }> = {
receive: { label: '积分收益', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
payment: { label: '积分消耗', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' },
red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
red_envelope_refund: { label: '红包退款', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' }
}
/* 状态标签配置 */
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
success: { label: '成功', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
pending: { label: '处理中', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
failed: { label: '失败', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
expired: { label: '已过期', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
disputing: { label: '争议中', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
refund: { label: '已退回', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
refused: { label: '已拒绝', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }
}
export type DisplayOrderStatus = OrderStatus | 'transfer_pending'
export const displayStatusConfig: Record<DisplayOrderStatus, { label: string; color: string }> = {
pending: statusConfig.pending,
success: statusConfig.success,
transfer_pending: { label: '结算中', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
failed: statusConfig.failed,
expired: statusConfig.expired,
disputing: statusConfig.disputing,
refund: statusConfig.refund,
refused: statusConfig.refused,
}
export function mapDisplayStatusToQuery(
selectedStatus: DisplayOrderStatus | null
): Pick<TransactionQueryParams, 'statuses' | 'payee_transfer_status'> {
if (!selectedStatus) {
return {}
}
if (selectedStatus === 'transfer_pending') {
return {
payee_transfer_status: 'pending',
}
}
return {
statuses: [selectedStatus],
}
}
/* 时间范围选项 */
export const timeRangeOptions = [
{
label: "今天", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from: today, to: tomorrow }
}
},
{
label: "最近 7 天", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 6)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "最近 1 个月", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "最近 6 个月", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setMonth(from.getMonth() - 6)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "本月至今", getValue: () => {
const today = new Date()
const from = new Date(today.getFullYear(), today.getMonth(), 1)
from.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "本季至今", getValue: () => {
const today = new Date()
const quarter = Math.floor(today.getMonth() / 3)
const from = new Date(today.getFullYear(), quarter * 3, 1) // 本季第一天 00:00:00
from.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{ label: "所有时间", getValue: () => null },
]
export interface TableFilterProps {
/* 启用的筛选类型 */
enabledFilters?: {
type?: boolean
status?: boolean
timeRange?: boolean
search?: boolean
}
/* 当前选中的值 */
selectedTypes?: OrderType[]
selectedStatus?: DisplayOrderStatus | null
selectedTimeRange?: { from: Date; to: Date } | null
selectedQuickSelection?: string | null
/* 搜索值 */
searchValues?: SearchValues
/* 回调函数 */
onTypeChange?: (types: OrderType[]) => void
onStatusChange?: (status: DisplayOrderStatus | null) => void
onTimeRangeChange?: (range: { from: Date; to: Date } | null) => void
onQuickSelectionChange?: (selection: string | null) => void
onSearch?: (values: SearchValues) => void
// 其他选项
showClearButton?: boolean
onClearAll?: () => void
// 分页控制(可选)
enablePagination?: boolean
currentPage?: number
totalPages?: number
pageSize?: number
total?: number
onPageChange?: (page: number) => void
onPageSizeChange?: (size: number) => void
onRefresh?: () => void
loading?: boolean
}
/**
* 可复用的筛选组件
* 支持类型、状态、时间范围筛选
*/
export function TableFilter({
enabledFilters = { type: true, status: true, timeRange: true, search: true },
selectedTypes = [],
selectedStatus = null,
selectedTimeRange = null,
selectedQuickSelection = null,
searchValues = {},
onTypeChange,
onStatusChange,
onTimeRangeChange,
onQuickSelectionChange,
onSearch,
showClearButton = true,
onClearAll,
enablePagination = false,
currentPage = 1,
totalPages = 1,
pageSize = 20,
total = 0,
onPageChange,
onPageSizeChange,
onRefresh,
loading = false,
}: TableFilterProps) {
/* 通用切换筛选函数 */
const handleToggle = <T extends string>(
value: T,
selectedValues: T[],
onChange?: (values: T[]) => void
) => {
if (!onChange) return
const newValues = selectedValues.includes(value)
? selectedValues.filter(v => v !== value)
: [...selectedValues, value]
onChange(newValues)
}
/* 切换类型筛选 */
const toggleType = (type: OrderType) => handleToggle(type, selectedTypes, onTypeChange)
/* 切换状态筛选 */
const toggleStatus = (status: DisplayOrderStatus) => {
if (!onStatusChange) return
onStatusChange(selectedStatus === status ? null : status)
}
/* 处理时间范围变化 */
const handleTimeRangeChange = (range: { from: Date; to: Date } | null) => {
onTimeRangeChange?.(range)
}
/* 是否有激活的筛选 */
const hasActiveFilters =
(enabledFilters.type && selectedTypes.length > 0) ||
(enabledFilters.status && selectedStatus !== null) ||
(enabledFilters.timeRange && selectedTimeRange !== null) ||
(enabledFilters.search && Object.values(searchValues || {}).some(v => v))
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{enabledFilters.search && (
<SearchFilter
values={searchValues}
onSearch={onSearch}
/>
)}
{enabledFilters.type && (
<FilterSelect<OrderType>
label="类型"
selectedValues={selectedTypes}
options={typeConfig}
onToggleValue={toggleType}
/>
)}
{enabledFilters.status && (
<SingleFilterSelect<DisplayOrderStatus>
label="状态"
selectedValue={selectedStatus}
options={displayStatusConfig}
onSelectValue={toggleStatus}
/>
)}
{enabledFilters.timeRange && (
<TimeRangeFilter
selectedQuickSelection={selectedQuickSelection}
selectedTimeRange={selectedTimeRange}
onTimeRangeChange={handleTimeRangeChange}
onQuickSelectionChange={onQuickSelectionChange}
/>
)}
{showClearButton && hasActiveFilters && onClearAll && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
{enablePagination && onPageChange && onPageSizeChange && (
<Separator className="lg:hidden" />
)}
{enablePagination && onPageChange && onPageSizeChange && (
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRefresh={onRefresh}
loading={loading}
/>
)}
</div>
)
}
interface TablePaginationProps {
currentPage: number
totalPages: number
total: number
pageSize: number
onPageChange: (page: number) => void
onPageSizeChange: (size: number) => void
onRefresh?: () => void
loading?: boolean
}
export function TablePagination({
currentPage,
totalPages,
total,
pageSize,
onPageChange,
onPageSizeChange,
onRefresh,
loading = false
}: TablePaginationProps) {
return (
<div className="flex items-center gap-2 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap hidden sm:inline-block">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none bg-background">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed h-5.5 flex items-center">
{currentPage}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => onPageSizeChange(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onRefresh && (
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={onRefresh}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
)}
</div>
)
}
/**
* 可复用的筛选选择器组件
*/
export function FilterSelect<T extends string>({ label, selectedValues, options, onToggleValue }: {
label: string
selectedValues: T[]
options: Record<T, { label: string; color: string }>
onToggleValue: (value: T) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
selectedValues.length > 0 && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
{label}
{selectedValues.length > 0 && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
{selectedValues.length}
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
{(Object.keys(options) as T[]).map((value) => {
const isSelected = selectedValues.includes(value)
return (
<DropdownMenuItem
key={value}
onSelect={(e) => {
e.preventDefault()
onToggleValue(value)
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
</div>
<span className="text-xs">{options[value].label}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function SingleFilterSelect<T extends string>({ label, selectedValue, options, onSelectValue }: {
label: string
selectedValue: T | null
options: Record<T, { label: string; color: string }>
onSelectValue: (value: T) => void
}) {
const activeLabel = selectedValue ? options[selectedValue].label : null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
selectedValue && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
{activeLabel ? `${label}: ${activeLabel}` : label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[160px]" align="start">
{(Object.keys(options) as T[]).map((value) => {
const isSelected = selectedValue === value
return (
<DropdownMenuItem
key={value}
onSelect={(e) => {
e.preventDefault()
onSelectValue(value)
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-full border border-primary",
isSelected ? "bg-primary" : "opacity-50"
)} />
<span className="text-xs">{options[value].label}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface TimeRangeFilterProps {
selectedQuickSelection: string | null
selectedTimeRange: { from: Date; to: Date } | null
onTimeRangeChange: (range: { from: Date; to: Date } | null) => void
onQuickSelectionChange?: (selection: string | null) => void
}
function TimeRangeFilter({
selectedQuickSelection,
selectedTimeRange,
onTimeRangeChange,
onQuickSelectionChange
}: TimeRangeFilterProps) {
/* 处理日历选择 */
const handleCalendarSelect = (range: { from?: Date; to?: Date } | undefined) => {
if (range?.from) {
const from = new Date(range.from)
from.setHours(0, 0, 0, 0)
if (range.to) {
const to = new Date(range.to)
to.setHours(0, 0, 0, 0)
onTimeRangeChange({ from, to })
onQuickSelectionChange?.(null)
} else if (range.from.getTime() === selectedTimeRange?.from?.getTime()) {
const to = new Date(from)
to.setHours(0, 0, 0, 0)
onTimeRangeChange({ from, to })
onQuickSelectionChange?.(null)
}
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
(selectedQuickSelection || selectedTimeRange) && "bg-primary/5 border-primary/20"
)}
>
<CalendarIcon className="mr-1 size-3" />
{selectedQuickSelection || (selectedTimeRange ? "自定义时间" : "时间范围")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto p-0" align="start">
<div className="flex flex-col sm:flex-row">
<div className="flex flex-col p-2 gap-1 min-w-[120px] border-r">
{timeRangeOptions.map((selection) => (
<Button
key={selection.label}
variant="ghost"
size="sm"
onClick={() => {
const range = selection.getValue()
onTimeRangeChange(range)
onQuickSelectionChange?.(selection.label)
}}
className={cn(
"justify-start text-xs font-normal h-8",
selectedQuickSelection === selection.label && "bg-accent text-accent-foreground font-medium"
)}
>
{selection.label}
</Button>
))}
</div>
<div className="p-2">
<Calendar
mode="range"
selected={selectedTimeRange ? { from: selectedTimeRange.from, to: selectedTimeRange.to } : undefined}
onSelect={handleCalendarSelect}
numberOfMonths={2}
locale={zhCN}
/>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface SearchValues {
id?: string
order_name?: string
payer_username?: string
payee_username?: string
}
export function SearchFilter({
values,
onSearch
}: {
values?: SearchValues
onSearch?: (values: SearchValues) => void
}) {
const [localValues, setLocalValues] = React.useState<SearchValues>(values || {})
const [isOpen, setIsOpen] = React.useState(false)
React.useEffect(() => {
setLocalValues(values || {})
}, [values, isOpen])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onSearch?.(localValues)
setIsOpen(false)
}
}
const handleSearch = () => {
onSearch?.(localValues)
setIsOpen(false)
}
const handleClear = () => {
const empty = { id: '', order_name: '', payer_username: '', payee_username: '' }
setLocalValues(empty)
onSearch?.(empty)
setIsOpen(false)
}
const hasSearchValues = Object.values(values || {}).some(v => v)
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchValues && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3" />
搜索
{hasSearchValues && (
<>
<Separator orientation="vertical" className="mx-1 h-3" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[280px] p-4" align="start">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs">编号 ID</Label>
<Input
value={localValues.id || ''}
onChange={e => setLocalValues(prev => ({ ...prev, id: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="精确匹配"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">活动名称</Label>
<Input
value={localValues.order_name || ''}
onChange={e => setLocalValues(prev => ({ ...prev, order_name: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs">消费方</Label>
<Input
value={localValues.payer_username || ''}
onChange={e => setLocalValues(prev => ({ ...prev, payer_username: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">服务方</Label>
<Input
value={localValues.payee_username || ''}
onChange={e => setLocalValues(prev => ({ ...prev, payee_username: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs" onClick={handleClear}>
重置
</Button>
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleSearch}>
搜索
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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>
)
}
+172 -15
View File
@@ -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}
/>
</>
)
}
@@ -1,102 +0,0 @@
"use client"
import * as React from "react"
import { CircleHelp, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { useUser } from "@/contexts/user-context"
import { useLeaderboard } from "@/hooks/use-leaderboard"
import { LeaderboardPodium } from "@/components/common/leaderboard/leaderboard-podium"
import { LeaderboardTable } from "@/components/common/leaderboard/leaderboard-table"
import { cn } from "@/lib/utils"
/**
* 排行榜主组件
*/
export function LeaderboardMain() {
const { user } = useUser()
const {
items,
myRank,
loading,
loadingMore,
myRankLoading,
hasMore,
loadNextPage,
refresh,
} = useLeaderboard()
const isInitialLoading = loading && items.length === 0
const currentUserEntry = React.useMemo(() => {
if (!myRank?.user) return undefined
const matchedEntry = items.find((item) => item.user_id === myRank.user.user_id)
if (matchedEntry) return matchedEntry
if (!user) return undefined
return {
user_id: myRank.user.user_id,
username: user.nickname || user.username,
avatar_url: user.avatar_url,
available_balance: myRank.user.available_balance,
}
}, [items, myRank, user])
return (
<div className="py-6 space-y-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">全局排行榜</h1>
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-muted-foreground">
<CircleHelp className="size-4" />
规则
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>排行榜如何运作</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>排行榜按可用积分实时排序,数据会定期刷新。</p>
<p>参与社区活动、进行积分流转,都会影响可用积分与当前排名。</p>
</div>
</DialogContent>
</Dialog>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={refresh} aria-label="刷新排行榜">
<RefreshCw className={cn("size-4", (loading || loadingMore || myRankLoading) && "animate-spin")} />
</Button>
</div>
</div>
<LeaderboardPodium
items={items.slice(0, 3)}
loading={isInitialLoading}
/>
<section>
<h2 className="font-semibold mb-4">完整榜单</h2>
<LeaderboardTable
items={items}
loading={isInitialLoading}
currentUserId={myRank?.user.user_id}
currentUserEntry={currentUserEntry}
currentUserRank={myRank?.user.rank}
onLoadMore={loadNextPage}
hasMore={hasMore}
startRank={1}
/>
</section>
</div>
)
}
@@ -1,140 +0,0 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
interface LeaderboardPodiumProps {
items: LeaderboardEntry[]
loading?: boolean
}
type PodiumRank = 1 | 2 | 3
function formatBalance(value: string) {
return Number.parseFloat(value).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const rankConfig = {
1: {
numColor: "from-amber-500/60 to-amber-500/8",
scoreColor: "text-amber-500",
avatarSize: "size-16 sm:size-[72px]",
numSize: "text-[72px] sm:text-[88px]",
nameSize: "text-base font-bold",
scoreSize: "text-lg font-black",
avatarNegMt: "-mt-6 sm:-mt-7",
order: "md:order-2",
shift: "",
},
2: {
numColor: "from-zinc-400/45 to-zinc-400/5",
scoreColor: "text-zinc-400",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-1",
shift: "md:translate-y-9",
},
3: {
numColor: "from-orange-500/55 to-orange-500/8",
scoreColor: "text-orange-500",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-3",
shift: "md:translate-y-14",
},
} as const
function PodiumSkeleton() {
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{([2, 1, 3] as PodiumRank[]).map((rank) => {
const cfg = rankConfig[rank]
return (
<div key={rank} className={cn("flex flex-col items-center gap-0", cfg.order)}>
<Skeleton className={cn("rounded-lg", rank === 1 ? "h-[100px] w-[76px]" : "h-[72px] w-[52px]")} />
<Skeleton className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)} />
<Skeleton className="mt-3 h-4 w-[70%]" />
<Skeleton className="mt-1.5 h-4 w-[45%]" />
</div>
)
})}
</div>
</div>
)
}
function PodiumColumn({ entry, rank }: { entry: LeaderboardEntry; rank: PodiumRank }) {
const cfg = rankConfig[rank]
return (
<article className={cn("flex min-w-0 flex-col items-center", cfg.order, cfg.shift)}>
<div
className={cn(
"select-none bg-clip-text text-transparent font-black leading-none tracking-[-0.05em] -mt-8",
cfg.numSize,
`bg-linear-to-b ${cfg.numColor}`,
)}
>
{String(rank).padStart(2, "0")}
</div>
<Avatar className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)}>
<AvatarImage src={entry.avatar_url} alt={entry.username} className="object-cover" />
<AvatarFallback className={cn("rounded-full font-bold", rank === 1 ? "text-sm" : "text-xs")}>
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className={cn("mt-2.5 w-full truncate text-center tracking-tight text-foreground", cfg.nameSize)}>
{entry.username}
</div>
<div className={cn("mt-0.5 mb-3 tabular-nums tracking-tight", cfg.scoreSize, cfg.scoreColor)}>
{formatBalance(entry.available_balance)}
</div>
</article>
)
}
export const LeaderboardPodium = React.memo(function LeaderboardPodium({
items,
loading,
}: LeaderboardPodiumProps) {
if (loading) return <PodiumSkeleton />
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
const ordered = [
items[1] ? { entry: items[1], rank: 2 as PodiumRank } : null,
items[0] ? { entry: items[0], rank: 1 as PodiumRank } : null,
items[2] ? { entry: items[2], rank: 3 as PodiumRank } : null,
].filter(Boolean) as Array<{ entry: LeaderboardEntry; rank: PodiumRank }>
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{ordered.map(({ entry, rank }) => (
<PodiumColumn key={entry.user_id} entry={entry} rank={rank} />
))}
</div>
</div>
)
})
@@ -1,182 +0,0 @@
"use client"
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area"
import { RankRowItem } from "./rank-row-item"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardTableColumns = "grid-cols-[72px_minmax(0,1fr)_132px] sm:grid-cols-[88px_minmax(0,1fr)_152px]"
const leaderboardRowHeight = 60
interface LeaderboardTableProps {
items: LeaderboardEntry[]
loading?: boolean
currentUserId?: number
currentUserEntry?: LeaderboardEntry
currentUserRank?: number
onLoadMore?: () => void
hasMore?: boolean
startRank?: number
}
export const LeaderboardTable = React.memo(function LeaderboardTable({
items,
loading,
currentUserId,
currentUserEntry,
currentUserRank,
onLoadMore,
hasMore,
startRank = 1,
}: LeaderboardTableProps) {
const parentRef = React.useRef<HTMLDivElement>(null)
const maxBalance = React.useMemo(() => {
if (items.length === 0) return 1
return Math.max(...items.map(item => parseFloat(item.available_balance))) || 1
}, [items])
const computePercentage = React.useCallback(
(balance: number) => {
const denominator = Math.log(maxBalance + 1)
if (denominator <= 0) return 0
const safeBalance = Math.max(balance, 0)
return (Math.log(safeBalance + 1) / denominator) * 100
},
[maxBalance]
)
const rankedItems = React.useMemo(
() =>
items
.map((entry, index) => ({
entry,
rank: startRank + index,
percentage: computePercentage(parseFloat(entry.available_balance)),
}))
.filter(({ entry }) => entry.user_id !== currentUserEntry?.user_id),
[items, startRank, currentUserEntry?.user_id, computePercentage]
)
const virtualizer = useVirtualizer({
count: rankedItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => leaderboardRowHeight,
overscan: 6,
useFlushSync: false,
})
const virtualItems = virtualizer.getVirtualItems()
React.useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= rankedItems.length - 1 && hasMore && !loading && onLoadMore) {
onLoadMore()
}
}, [virtualItems, rankedItems.length, hasMore, loading, onLoadMore])
const currentUserPercentage = currentUserEntry
? computePercentage(parseFloat(currentUserEntry.available_balance))
: 0
if (loading && items.length === 0) {
return (
<div>
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
<div className="mt-1 overflow-hidden rounded-xl bg-linear-to-b from-muted/30 to-transparent">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={`grid h-[60px] ${leaderboardTableColumns} items-center gap-3 px-3`}>
<Skeleton className="h-4 w-8" />
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="ml-auto h-5 w-16" />
</div>
))}
</div>
</div>
)
}
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
return (
<div>
{/* 列头 */}
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
{/* 当前用户置顶行 */}
{currentUserEntry && currentUserRank && (
<div className="mt-1 overflow-hidden rounded-xl bg-primary/[0.04]">
<RankRowItem
entry={currentUserEntry}
rank={currentUserRank}
percentage={currentUserPercentage}
isCurrentUser
pinned
metaLabel="我的位置"
/>
</div>
)}
{/* 榜单主体 — 从 muted 过渡到页面背景色 */}
<div className="relative mt-2">
<div className="overflow-hidden rounded-xl bg-linear-to-b from-muted/30 via-muted/10 to-transparent">
<ScrollArea className="h-[440px] sm:h-[480px]" viewportRef={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const { entry, rank, percentage } = rankedItems[virtualRow.index]
return (
<div
key={entry.user_id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<RankRowItem
entry={entry}
rank={rank}
percentage={percentage}
isCurrentUser={entry.user_id === currentUserId}
/>
</div>
)
})}
</div>
</ScrollArea>
</div>
</div>
{loading && items.length > 0 && (
<div className="py-3 text-center text-sm text-muted-foreground">
正在加载更多...
</div>
)}
</div>
)
})
@@ -1,80 +0,0 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardRowColumns = "grid-cols-[72px_minmax(0,1fr)_112px] sm:grid-cols-[88px_minmax(0,1fr)_128px]"
interface RankRowItemProps {
entry: LeaderboardEntry
rank: number
percentage?: number
isCurrentUser?: boolean
metaLabel?: string
pinned?: boolean
}
export const RankRowItem = React.memo(function RankRowItem({
entry,
rank,
percentage = 0,
isCurrentUser,
metaLabel,
pinned = false,
}: RankRowItemProps) {
const rankClass =
rank === 1 ? "text-amber-500 font-black" :
rank === 2 ? "text-zinc-400 font-bold" :
rank === 3 ? "text-orange-500 font-bold" :
"text-muted-foreground"
return (
<div
className={cn(
"relative grid h-[60px] items-center px-3 transition-colors hover:bg-muted/30",
leaderboardRowColumns,
isCurrentUser && !pinned && "bg-primary/[0.03]",
pinned && "bg-transparent"
)}
>
{/* 进度条背景 */}
{percentage > 0 && (
<div
className="pointer-events-none absolute inset-y-0 left-0 transition-all duration-500"
style={{
width: `${Math.max(percentage, 4)}%`,
background: "linear-gradient(to right, oklch(58.5% 51% 277.117 / 0.15) 65%, transparent)",
}}
/>
)}
<div className={cn("relative truncate pr-2 text-sm tabular-nums", rankClass)}>
#{rank}
</div>
<div className="relative flex min-w-0 items-center gap-3">
<Avatar className="size-9 shrink-0">
<AvatarImage src={entry.avatar_url} alt={entry.username} />
<AvatarFallback className="text-xs font-semibold">
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{entry.username}</div>
{metaLabel && (
<div className={cn("text-[11px]", pinned ? "text-primary/70" : "text-muted-foreground")}>
{metaLabel}
</div>
)}
</div>
</div>
<div className="relative text-right text-sm font-semibold tabular-nums text-foreground sm:text-[15px]">
{parseFloat(entry.available_balance).toFixed(2)}
</div>
</div>
)
})
@@ -1,68 +0,0 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { UserRankResponse } from "@/lib/services/leaderboard"
import { UserRound } from "lucide-react"
interface UserRankCardProps {
data: UserRankResponse | null
loading?: boolean
className?: string
}
export const UserRankCard = React.memo(function UserRankCard({
data,
loading,
className,
}: UserRankCardProps) {
if (loading) {
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-4 px-1 py-1">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-24" />
</div>
))}
</div>
</section>
)
}
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-5 px-1 py-1">
{data ? (
<>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前名次</div>
<div className="text-3xl font-semibold tabular-nums">#{data.user.rank.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">可用积分</div>
<div className="text-2xl font-semibold tabular-nums">{parseFloat(data.user.available_balance).toFixed(2)}</div>
</div>
</>
) : (
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前状态</div>
<div className="text-base font-medium">暂未进入榜单</div>
<div className="text-sm text-muted-foreground">继续积累可用积分后会出现在排行榜中。</div>
</div>
)}
</div>
</section>
)
})
@@ -1,183 +0,0 @@
"use client"
import * as React from "react"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { Undo2, FileText, Link2 } from "lucide-react"
import { toast } from "sonner"
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
import { TransactionTableList } from "@/components/common/general/table-data"
import { type MerchantAPIKey, type OrderType } from "@/lib/services"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { formatLocalDate } from "@/lib/utils"
/** 集市功能列表 */
const MERCHANT_ACTIONS = [
{ title: "处理争议", description: "获取此应用的所有争议", icon: Undo2, color: "text-orange-600", bgColor: "bg-orange-50 dark:bg-orange-950/20", action: "refund" as const },
{ title: "所有活动", description: "显示此应用的所有活动", icon: FileText, color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", action: "view-all" as const },
{ title: "在线流转", description: "创建在线积分流转活动", icon: Link2, color: "text-purple-600", bgColor: "bg-purple-50 dark:bg-purple-950/20", action: "online-payment" as const },
]
/** 获取默认日期范围(最近30天) */
const getDefaultDateRange = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
return { from, to: today }
}
interface MerchantDataProps {
apiKey: MerchantAPIKey
}
/**
* 集市数据组件
* 显示应用的活动数据和统计信息
*/
export function MerchantData({ apiKey }: MerchantDataProps) {
const { from: start, to: end } = getDefaultDateRange()
return (
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: formatLocalDate(start),
endTime: formatLocalDate(end),
client_id: apiKey.client_id
}}
>
<MerchantDataContent apiKey={apiKey} />
</TransactionProvider>
)
}
/**
* 集市数据内容组件
*/
function MerchantDataContent({ apiKey }: MerchantDataProps) {
const router = useRouter()
const { transactions, total, currentPage, totalPages, pageSize, loading, error, fetchTransactions, goToPage, setPageSize: setPageSizeHandler } = useTransaction()
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>([])
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
const buildQueryParams = React.useCallback((page: number) => {
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
return {
page,
page_size: pageSize,
types: selectedTypes.length > 0 ? selectedTypes : undefined,
...stateQuery,
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
endTime: dateRange ? (() => {
const endDate = new Date(dateRange.to)
endDate.setHours(0, 0, 0, 0)
endDate.setDate(endDate.getDate() + 1)
return formatLocalDate(endDate)
})() : undefined,
client_id: apiKey.client_id,
id: selectedSearch.id || undefined,
order_name: selectedSearch.order_name || undefined,
payer_username: selectedSearch.payer_username || undefined,
payee_username: selectedSearch.payee_username || undefined,
}
}, [apiKey.client_id, dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
const clearAllFilters = () => {
setSelectedTypes([])
setSelectedStatus(null)
setDateRange(null)
setSelectedQuickSelection(null)
setSelectedSearch({})
}
useEffect(() => {
fetchTransactions(buildQueryParams(1))
}, [buildQueryParams, fetchTransactions])
const actionHandlers = {
refund: () => {
setSelectedStatus('disputing')
toast.success('待处理的争议', { description: '显示此应用的所有待处理的争议' })
},
'view-all': () => {
clearAllFilters()
toast.success('全部活动', { description: '显示此应用的所有积分活动' })
},
'online-payment': () => router.push(`/merchant/online-paying?apiKeyId=${ apiKey.id }`)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold mb-4">应用服务</h2>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{MERCHANT_ACTIONS.map((action) => (
<button
key={action.action}
className="rounded-lg p-4 border border-dashed hover:border-primary/50 transition-all text-left group cursor-pointer"
onClick={() => actionHandlers[action.action]()}
>
<div className="flex items-center gap-4">
<div className={`rounded-lg p-2 ${ action.bgColor }`}>
<action.icon className={`size-4 ${ action.color }`} />
</div>
<div className="flex-1">
<h3 className="font-medium text-sm group-hover:text-foreground">{action.title}</h3>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
</button>
))}
</div>
</div>
<div>
<h2 className="font-semibold mb-4">活动记录</h2>
<div className="space-y-2">
<TableFilter
enabledFilters={{
type: true,
status: true,
timeRange: true,
search: true
}}
selectedTypes={selectedTypes}
selectedStatus={selectedStatus}
selectedTimeRange={dateRange}
selectedQuickSelection={selectedQuickSelection}
onTypeChange={setSelectedTypes}
onStatusChange={setSelectedStatus}
onTimeRangeChange={setDateRange}
onQuickSelectionChange={setSelectedQuickSelection}
onSearch={setSelectedSearch}
searchValues={selectedSearch}
onClearAll={clearAllFilters}
enablePagination={true}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
total={total}
onPageChange={goToPage}
onPageSizeChange={setPageSizeHandler}
loading={loading}
/>
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions(buildQueryParams(1))}
emptyDescription="未发现积分活动记录"
/>
</div>
</div>
</div>
)
}
@@ -1,366 +0,0 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Info } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import services, { type MerchantAPIKey, type CreateAPIKeyRequest, type UpdateAPIKeyRequest } from "@/lib/services"
interface MerchantDialogProps {
/** 模式:创建或更新 */
mode: 'create' | 'update'
/** API Key*/
apiKey?: MerchantAPIKey
/** 创建成功回调 */
onSuccess: (newKey: MerchantAPIKey) => void
/** 更新成功回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 触发按钮 */
trigger?: React.ReactNode
/** 自定义创建函数 */
createAPIKey?: (data: CreateAPIKeyRequest) => Promise<MerchantAPIKey>
/** 更新函数 */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用对话框组件
* 负责创建和更新集市中心应用的表单和验证逻辑
*/
export function MerchantDialog({
mode,
apiKey,
onSuccess,
onUpdate,
trigger,
createAPIKey,
updateAPIKey
}: MerchantDialogProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [mounted, setMounted] = useState(false)
const [showNotifyTooltip, setShowNotifyTooltip] = useState(false)
const [tooltipSide, setTooltipSide] = useState<'top' | 'bottom'>('bottom')
const [formData, setFormData] = useState<CreateAPIKeyRequest | UpdateAPIKeyRequest>({
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
public_key: '',
test_mode: false,
})
useEffect(() => {
setMounted(true)
}, [])
/**
* 获取初始表单数据
*/
const getInitialFormData = React.useCallback((): CreateAPIKeyRequest | UpdateAPIKeyRequest => {
if (mode === 'update' && apiKey) {
return {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
public_key: apiKey.public_key,
test_mode: apiKey.test_mode,
}
}
return {
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
test_mode: false,
}
}, [mode, apiKey])
/**
* 初始化表单数据
*/
useEffect(() => {
setFormData(getInitialFormData())
}, [getInitialFormData])
const isValidUrl = (url: string): boolean => {
if (!url || url.trim() === '') return false
try {
const urlObj = new URL(url.trim())
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
const resetForm = () => {
setFormData(getInitialFormData())
}
const validateForm = (): { valid: boolean; error?: string } => {
/* 验证必填项 */
if (!formData.app_name || !formData.app_homepage_url || !formData.notify_url) {
return { valid: false, error: '请填写所有必填项' }
}
/* 验证应用名称长度 */
if (formData.app_name.length > 20) {
return { valid: false, error: '应用名称不能超过 20 个字符' }
}
/* 验证应用主页 URL */
if (!isValidUrl(formData.app_homepage_url)) {
return { valid: false, error: '应用主页 URL 格式不正确' }
}
/* 验证回调 URI */
if (formData.redirect_uri && !isValidUrl(formData.redirect_uri)) {
return { valid: false, error: '回调 URI 格式不正确' }
}
/* 验证通知 URL */
if (!isValidUrl(formData.notify_url)) {
return { valid: false, error: '通知 URL 格式不正确' }
}
/* 验证应用描述长度 */
if (formData.app_description && formData.app_description.length > 100) {
return { valid: false, error: '应用描述不能超过 100 个字符' }
}
return { valid: true }
}
const handleSubmit = async () => {
const validation = validateForm()
if (!validation.valid) {
toast.error('表单验证失败', {
description: validation.error
})
return
}
try {
setProcessing(true)
if (mode === 'create') {
const newKey = await (createAPIKey ? createAPIKey(formData as CreateAPIKeyRequest) : services.merchant.createAPIKey(formData as CreateAPIKeyRequest))
toast.success('创建成功', {
description: '新应用已创建,请妥善保管您的 Client Secret'
})
onSuccess(newKey)
} else if (mode === 'update' && apiKey) {
if (updateAPIKey) {
await updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
} else {
await services.merchant.updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
}
toast.success('更新成功', {
description: '应用信息已更新'
})
/* 更新本地状态 */
const updatedKey = { ...apiKey, ...formData }
onUpdate?.(updatedKey)
}
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || `无法${mode === 'create' ? '创建' : '更新'}应用`
toast.error(`${mode === 'create' ? '创建' : '更新'}失败`, {
description: errorMessage
})
throw error
} finally {
setProcessing(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !processing) {
resetForm()
}
setOpen(newOpen)
}
if (!mounted) {
return null
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button className="h-8 text-xs">
{mode === 'create' ? '创建应用' : '更新应用'}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'create' ? '创建应用' : '更新应用信息'}</DialogTitle>
<DialogDescription>
{mode === 'create' ? '您可以创建一个集市中心应用来接入积分服务,请仔细填写以下信息' : '修改集市中心应用的基本信息和配置'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="app_name">应用名称 <span className="text-red-500">*</span></Label>
<Input
id="app_name"
placeholder="您的应用名称。最多 20 个字符,用于标识您的应用"
maxLength={20}
value={formData.app_name}
onChange={(e) => setFormData({ ...formData, app_name: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_description">应用描述</Label>
<Input
id="app_description"
placeholder="您的应用描述。最多 100 个字符,用于描述您的应用,可选"
maxLength={100}
value={formData.app_description}
onChange={(e) => setFormData({ ...formData, app_description: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_homepage_url">应用主页 URL <span className="text-red-500">*</span></Label>
<Input
id="app_homepage_url"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于展示您的应用主页"
maxLength={100}
value={formData.app_homepage_url}
onChange={(e) => setFormData({ ...formData, app_homepage_url: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="notify_url">通知 URL <span className="text-red-500">*</span></Label>
<TooltipProvider>
<Tooltip open={showNotifyTooltip} onOpenChange={setShowNotifyTooltip}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onMouseEnter={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(true)
}}
onClick={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(!showNotifyTooltip)
}}
>
<Info className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide} className="w-64">
<div className="space-y-2">
<p className="text-xs">如果您只需要使用在线积分流转服务,建议直接填写 http://localhost:3000/ 即可</p>
<Button
size="sm"
variant="secondary"
className="w-full h-6 text-xs"
onClick={() => {
setFormData({ ...formData, notify_url: 'http://localhost:3000/' })
setShowNotifyTooltip(false)
}}
>
快速填入
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
id="notify_url"
type="url"
placeholder="URL 必须为包含 http:// 或 https:// ,用于接收积分服务成功的异步通知"
maxLength={100}
value={formData.notify_url}
onChange={(e) => {
const value = e.target.value
setFormData({ ...formData, notify_url: value })
if (value.trim()) {
setShowNotifyTooltip(false)
}
}}
onClick={() => {
if (!formData.notify_url || !formData.notify_url.trim()) {
setTooltipSide('top')
setShowNotifyTooltip(true)
}
}}
onBlur={() => setShowNotifyTooltip(false)}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="redirect_uri">回调 URI</Label>
<Input
id="redirect_uri"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于接收积分服务完成后的回调,可选"
maxLength={100}
value={formData.redirect_uri}
onChange={(e) => setFormData({ ...formData, redirect_uri: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="public_key">Ed25519 公钥 (Base64)</Label>
<Input
id="public_key"
placeholder="可选,用于请求签名验证,必须为 32 字节的 Ed25519 公钥(Base64 编码)"
maxLength={100}
value={formData.public_key || ''}
onChange={(e) => setFormData({ ...formData, public_key: e.target.value })}
disabled={processing}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={processing} className="h-8 text-xs">取消</Button>
</DialogClose>
<Button
onClick={(e) => {
e.preventDefault()
handleSubmit()
}}
disabled={processing}
className="h-8 text-xs"
>
{processing ? <><Spinner /> {mode === 'create' ? '创建中' : '更新中'}</> : (mode === 'create' ? '创建' : '更新')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog >
)
}
@@ -1,221 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Coins } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { MerchantService, type MerchantAPIKey } from "@/lib/services"
interface DistributeDialogProps {
/** 自定义触发器 */
trigger?: React.ReactNode
/** 商户凭证 */
apiKey?: Pick<MerchantAPIKey, "client_id" | "client_secret">
}
/**
* 商户分发对话框
* 商户向用户分发积分
*/
export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [userId, setUserId] = useState("")
const [username, setUsername] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const resetForm = () => {
setUserId("")
setUsername("")
setAmount("")
setRemark("")
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleSubmit = async () => {
if (!userId.trim()) {
toast.error('表单验证失败', { description: '请填写用户 ID' })
return
}
if (!username.trim()) {
toast.error('表单验证失败', { description: '请填写用户名' })
return
}
if (!amount.trim()) {
toast.error('表单验证失败', { description: '请填写分发金额' })
return
}
const amountNum = parseFloat(amount)
if (isNaN(amountNum) || amountNum <= 0) {
toast.error('表单验证失败', { description: '分发金额必须大于 0' })
return
}
if (amountNum > 999999.99) {
toast.error('表单验证失败', { description: '分发金额不能超过 999999.99' })
return
}
if (remark.length > 100) {
toast.error('表单验证失败', { description: '备注不能超过 100 个字符' })
return
}
try {
setLoading(true)
if (!apiKey?.client_id || !apiKey?.client_secret) {
toast.error('缺少应用凭证', { description: '请先创建应用并确认凭证可用' })
return
}
const result = await MerchantService.distribute({
user_id: Number(userId.trim()),
username: username.trim(),
amount: amountNum,
remark: remark.trim() || undefined,
}, {
client_id: apiKey.client_id,
client_secret: apiKey.client_secret,
})
toast.success('分发成功', {
description: `订单号: ${ result.trade_no }`
})
setOpen(false)
resetForm()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '分发失败'
toast.error('分发失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="text-xs h-8 border-dashed w-full shadow-none">
<Coins className="size-3 mr-1" />
积分分发
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>积分分发</DialogTitle>
<DialogDescription>
向指定用户分发积分。您的积分将被扣除,对方收到的积分会扣除分发费率。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user-id" className="text-xs">
用户 ID <span className="text-destructive">*</span>
</Label>
<Input
id="user-id"
placeholder="被分发者的用户 ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username" className="text-xs">
用户名 <span className="text-destructive">*</span>
</Label>
<Input
id="username"
placeholder="被分发者的用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount" className="text-xs">
分发积分 <span className="text-destructive">*</span>
</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
max="999999.99"
placeholder="积分数量,用户将收到扣除分发费率后的积分"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="remark" className="text-xs">
备注
</Label>
<Textarea
id="remark"
placeholder="积分分发备注,最多100字 (可选)"
value={remark}
onChange={(e) => setRemark(e.target.value)}
disabled={loading}
maxLength={100}
className="h-16 text-xs resize-none"
/>
</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}
className="h-8 text-xs"
>
{loading ? <><Spinner /> 分发中</> : '分发'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,263 +0,0 @@
import * as React from "react"
import { useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Copy, Eye, EyeOff, Trash2, ExternalLink, Edit } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { TestModeToggle } from "@/components/common/merchant/merchant-test"
import { DistributeDialog } from "@/components/common/merchant/merchant-distribute"
import { formatDateTime } from "@/lib/utils"
import { type UpdateAPIKeyRequest, type MerchantAPIKey } from "@/lib/services"
interface MerchantInfoProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 删除回调 */
onDelete: (id: string) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用信息组件
* 显示集市中心应用的凭证信息(Client ID 和 Secret)
*/
export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: MerchantInfoProps) {
const [showClientId, setShowClientId] = useState(false)
const [showClientSecret, setShowClientSecret] = useState(false)
/* 复制到剪贴板 */
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success(`${ label } 已复制`)
} catch {
try {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
toast.success(`${ label } 已复制`)
} else {
toast.error('复制失败')
}
} catch {
toast.error('复制失败')
}
}
}
const maskText = (text: string, showLength: number = 8) => {
if (text.length <= showLength * 2) return text
return `${ text.substring(0, showLength) }${ '•'.repeat(20) }${ text.substring(text.length - showLength) }`
}
return (
<div className="space-y-6 sticky top-0">
<div>
<h2 className="font-semibold mb-4">应用信息</h2>
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用名称</label>
<div className="flex items-center gap-2 max-w-[70%]">
<p className="text-xs font-medium truncate">{apiKey.app_name}</p>
{apiKey.test_mode && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/10 text-orange-600 border border-orange-500/20">
测试
</span>
)}
</div>
</div>
{apiKey.app_description && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用描述</label>
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{apiKey.app_description}</p>
</div>
)}
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground text-right max-w-[70%]">{formatDateTime(apiKey.created_at)}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用地址</label>
<Link
href={apiKey.app_homepage_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.app_homepage_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
{apiKey.redirect_uri && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">回调 URI</label>
<Link
href={apiKey.redirect_uri}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.redirect_uri}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
)}
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">通知 URL</label>
<Link
href={apiKey.notify_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.notify_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">API 配置</h2>
<div className="border border-dashed rounded-lg px-3 py-2 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client ID</label>
<span className="text-[10px] text-muted-foreground">客户端标识</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientId ? apiKey.client_id : maskText(apiKey.client_id, 8)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientId(!showClientId)}
>
{showClientId ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => copyToClipboard(apiKey.client_id, 'Client ID')}
>
<Copy className="size-3 text-muted-foreground" />
</Button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client Secret</label>
<span className="text-[10px] text-muted-foreground">客户端密钥</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientSecret ? apiKey.client_secret : '•'.repeat(40)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientSecret(!showClientSecret)}
>
{showClientSecret ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
onClick={() => copyToClipboard(apiKey.client_secret, 'Client Secret')}
className="size-6 p-1"
>
<Copy className="size-3" />
</Button>
</div>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">应用管理</h2>
<div className="grid grid-cols-2 gap-2">
<MerchantDialog
mode="update"
apiKey={apiKey}
onSuccess={() => { }}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
trigger={
<Button variant="outline" className="text-xs text-primary h-8 border-dashed border-primary/50 hover:bg-primary/5 w-full">
<Edit className="size-3 mr-1" />
编辑应用
</Button>
}
/>
<DistributeDialog apiKey={apiKey} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 w-full">
<Trash2 className="size-3 mr-1" />
删除应用
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除应用</AlertDialogTitle>
<AlertDialogDescription>
确定要删除应用 &ldquo;{apiKey.app_name}&rdquo; 吗?
此操作将永久删除该应用的所有凭证和配置,且无法恢复。
使用此应用凭证的所有集成将立即失效。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(apiKey.id)}
className="bg-destructive hover:bg-destructive/90"
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestModeToggle
apiKey={apiKey}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
</div>
)
}
@@ -1,141 +0,0 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { toast } from "sonner"
import { ErrorDisplay } from "@/components/layout/error"
import { EmptyState } from "@/components/layout/empty"
import { LoadingPage } from "@/components/layout/loading"
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
import { MerchantInfo } from "@/components/common/merchant/merchant-info"
import { MerchantData } from "@/components/common/merchant/merchant-data"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { type MerchantAPIKey } from "@/lib/services"
import { useMerchant } from "@/contexts/merchant-context"
/**
* 集市中心主页面组件
* 负责组装集市中心的各个子组件
*/
export function MerchantMain() {
const { apiKeys, loading, error, loadAPIKeys, createAPIKey, updateAPIKey, deleteAPIKey } = useMerchant()
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null)
const selectedKey = apiKeys.find(key => key.id === selectedKeyId) || null
/* 加载 API Keys */
useEffect(() => {
loadAPIKeys()
}, [loadAPIKeys])
/* 选择默认 API Key */
useEffect(() => {
if (apiKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(apiKeys[0].id)
}
}, [apiKeys, selectedKeyId])
/* 创建成功回调 */
const handleCreateSuccess = (newKey: MerchantAPIKey) => {
setSelectedKeyId(newKey.id)
}
/* 删除成功回调 */
const handleDelete = async (id: string) => {
try {
await deleteAPIKey(id)
toast.success('删除成功')
if (selectedKeyId === id) {
const remainingKeys = apiKeys.filter(key => key && key.id !== id)
setSelectedKeyId(remainingKeys.length > 0 ? remainingKeys[0].id : null)
}
} catch (error) {
toast.error('删除失败', {
description: (error as Error).message || '无法删除应用'
})
}
}
/* 正在加载中 */
if (loading) {
return <LoadingPage text="集市中心" badgeText="集市" />
}
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4">
<h1 className="text-2xl font-semibold">集市中心</h1>
<div className="flex items-center gap-3">
{apiKeys.length > 0 && (
<MerchantSelector
apiKeys={apiKeys}
selectedKeyId={selectedKeyId}
onSelect={setSelectedKeyId}
/>
)}
<MerchantDialog
mode="create"
onSuccess={handleCreateSuccess}
createAPIKey={createAPIKey}
/>
</div>
</div>
{error ? (
<motion.div
key="error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<ErrorDisplay
title="加载失败"
message={error}
onRetry={loadAPIKeys}
retryText="重试"
/>
</motion.div>
) : apiKeys.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<EmptyState
title="应用列表为空"
description="请创建您的第一个集市应用,开始您的积分服务"
/>
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="space-y-6"
>
{selectedKey && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<MerchantData apiKey={selectedKey} />
</div>
<div className="lg:col-span-1">
<MerchantInfo
apiKey={selectedKey}
onDelete={handleDelete}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
)}
</motion.div>
)}
</div>
)
}
@@ -1,836 +0,0 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, Store, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { useMerchant } from "@/contexts/merchant-context"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { useUser } from "@/contexts/user-context"
import { MerchantService, ConfigService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey, type UserPayConfig } from "@/lib/services"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
import { LoadingPage } from "@/components/layout/loading"
import { EmptyState } from "@/components/layout/empty"
import { TransactionTableList } from "@/components/common/general/table-data"
/** 设备预览配置 */
const DEVICE_CONFIG = {
mobile: {
width: 375,
height: 812,
borderRadius: 50,
borderWidth: 14,
scale: 0.6
},
tablet: {
width: 768,
height: 1024,
borderRadius: 32,
borderWidth: 18,
scale: 0.45
},
desktop: {
width: 1200,
height: 750,
borderRadius: 16,
borderWidth: 8,
scale: 0.35
}
} as const
export function MerchantOnline() {
const router = useRouter()
const searchParams = useSearchParams()
const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant()
useEffect(() => {
loadAPIKeys()
}, [loadAPIKeys])
const apiKeyId = searchParams.get("apiKeyId")
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
if (loadingKeys) {
return <LoadingPage text="在线流转" badgeText="积分" />
}
if (apiKeys.length === 0) {
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
<h1 className="text-2xl font-semibold">在线流转</h1>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
<Store className="size-3 mr-1" /> 前往集市
</Button>
</div>
<div className="flex items-center justify-center min-h-[60vh]">
<EmptyState
title="暂未创建应用"
description="您还没有创建任何应用。请前往集市中心创建应用,即可开始使用在线积分流转服务。"
icon={Key}
/>
</div>
</div>
)
}
return (
<TransactionProvider
defaultParams={{
page_size: 20,
types: ['online'],
client_id: selectedKey?.client_id
}}
>
<MerchantOnlineContent apiKeys={apiKeys} />
</TransactionProvider>
)
}
interface MerchantOnlineContentProps {
apiKeys: MerchantAPIKey[]
}
function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
const router = useRouter()
const searchParams = useSearchParams()
const {
transactions,
loading: loadingTransactions,
error: transactionsError,
fetchTransactions,
refresh
} = useTransaction()
/* 派生 selectedKey */
const apiKeyId = searchParams.get("apiKeyId")
/* 处理集市应用切换 */
const handleMerchantSelect = useCallback((id: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set("apiKeyId", id.toString())
router.push(`${ window.location.pathname }?${ params.toString() }`)
}, [searchParams, router])
/* 自动选择第一个集市应用 */
useEffect(() => {
if (apiKeys.length > 0 && !apiKeyId) {
handleMerchantSelect(apiKeys[0].id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKeys.length, apiKeyId])
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
/* 集市状态 */
const [paymentLinks, setPaymentLinks] = useState<PaymentLink[]>([])
const [selectedLink, setSelectedLink] = useState<PaymentLink | null>(null)
const [previewLink, setPreviewLink] = useState<PaymentLink | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [loading, setLoading] = useState(false)
/* 创建/编辑表单状态 */
const [productName, setProductName] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const [totalLimit, setTotalLimit] = useState("")
const [userLimit, setUserLimit] = useState("")
/* 设备预览状态 */
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'tablet' | 'desktop'>('mobile')
const [showToken, setShowToken] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light')
/* 获取应用列表 */
const fetchLinks = useCallback(async () => {
if (!selectedKey) {
setPaymentLinks([])
return
}
try {
setLoading(true)
const links = await MerchantService.listPaymentLinks(selectedKey.id)
setPaymentLinks(links)
/* 自动设置第一个应用为预览 */
if (links.length > 0) {
setPreviewLink(prev => prev || links[0])
}
} catch (error) {
console.error(error)
toast.error("获取应用列表失败")
} finally {
setLoading(false)
}
}, [selectedKey])
/* 当 selectedKey 变化时,重新获取应用列表和数据 */
useEffect(() => {
if (selectedKey?.client_id) {
fetchLinks()
fetchTransactions({
page: 1,
page_size: 20,
types: ['online'],
client_id: selectedKey.client_id
})
setIsCreating(false)
setSelectedLink(null)
} else {
setPaymentLinks([])
setIsCreating(false)
setSelectedLink(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedKey?.client_id])
/* 处理创建 */
const handleCreate = async () => {
if (!selectedKey) return
if (!productName || !amount) {
toast.error("请填写在线积分流转服务的名称和数量")
return
}
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
toast.error("积分数量必须设置为大于0的数值")
return
}
// 验证限制字段
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
toast.error("总数量限制必须是大于0的整数")
return
}
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
toast.error("单用户限制必须是大于0的整数")
return
}
try {
setLoading(true)
const newLink = await MerchantService.createPaymentLink(selectedKey.id, {
product_name: productName,
amount: parseFloat(amount),
remark,
...(totalLimit && { total_limit: parseInt(totalLimit) }),
...(userLimit && { user_limit: parseInt(userLimit) })
})
toast.success("在线积分流转服务创建成功")
/* 重置表单 */
setProductName("")
setAmount("")
setRemark("")
setTotalLimit("")
setUserLimit("")
/* 更新UI */
fetchLinks()
setIsCreating(false)
setSelectedLink(newLink)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务创建失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理删除 */
const handleDelete = async (link: PaymentLink) => {
if (!selectedKey) return
if (!confirm("确定要删除这个在线积分流转服务吗?该操作不可恢复。")) return
try {
setLoading(true)
await MerchantService.deletePaymentLink(selectedKey.id, link.id)
toast.success("在线积分流转服务已删除")
if (selectedLink?.id === link.id) {
setSelectedLink(null)
}
fetchLinks()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务删除失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理编辑 */
const handleEdit = (link: PaymentLink) => {
setProductName(link.product_name)
setAmount(link.amount)
setRemark(link.remark || "")
setTotalLimit(link.total_limit?.toString() || "")
setUserLimit(link.user_limit?.toString() || "")
setIsEditing(true)
}
/* 处理更新 */
const handleUpdate = async () => {
if (!selectedKey || !selectedLink) return
if (!productName || !amount) {
toast.error("请填写在线积分流转服务的名称和数量")
return
}
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
toast.error("积分数量必须设置为大于0的数值")
return
}
// 验证限制字段
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
toast.error("总数量限制必须是大于0的整数")
return
}
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
toast.error("单用户限制必须是大于0的整数")
return
}
try {
setLoading(true)
await MerchantService.updatePaymentLink(selectedKey.id, selectedLink.id, {
product_name: productName,
amount: parseFloat(amount),
remark,
...(totalLimit && { total_limit: parseInt(totalLimit) }),
...(userLimit && { user_limit: parseInt(userLimit) })
})
toast.success("在线积分流转服务已更新")
/* 更新UI */
fetchLinks()
setIsEditing(false)
setSelectedLink(null)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务更新失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理复制链接 */
const handleCopyLink = (token: string) => {
const url = `${ window.location.origin }/paying/online?token=${ token }`
navigator.clipboard.writeText(url)
toast.success("在线积分流转服务链接已复制")
}
/* 获取用户支付配置 */
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
const { user } = useUser()
useEffect(() => {
const loadConfigs = async () => {
try {
const configs = await ConfigService.getUserPayConfigs()
setUserPayConfigs(configs)
} catch (error) {
console.error("Failed to load user pay configs:", error)
}
}
loadConfigs()
}, [])
/* 预览订单信息 */
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
const previewOrderInfo: GetMerchantOrderResponse = {
merchant: {
app_name: selectedKey?.app_name || "服务名称",
redirect_uri: "",
},
order: {
id: '0',
order_no: previewLink ? `LINK-${ previewLink.id }` : "PREVIEW",
order_name: isCreating ? (productName || "服务名称") : (previewLink?.product_name || "服务名称"),
payer_username: "user",
payee_username: selectedKey?.app_name || "应用",
amount: isCreating ? (amount || "0.00") : (previewLink?.amount || "0.00"),
status: "pending",
type: "payment",
payment_type: "api",
remark: isCreating ? remark : (previewLink?.remark || ""),
client_id: "",
return_url: "",
notify_url: "",
trade_time: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
user_pay_config: currentUserConfig ? {
...currentUserConfig,
fee_rate: String(currentUserConfig.fee_rate)
} : {
id: '0',
level: user?.pay_level ?? 0,
min_score: 0,
max_score: null,
daily_limit: null,
fee_rate: "0.00",
created_at: "",
updated_at: ""
}
}
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
<h1 className="text-2xl font-semibold">在线流转</h1>
<div className="flex items-center gap-3">
{apiKeys.length > 0 && (
<MerchantSelector
apiKeys={apiKeys}
selectedKeyId={selectedKey?.id || null}
onSelect={handleMerchantSelect}
/>
)}
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
<Store className="size-3 mr-1" /> 前往集市
</Button>
</div>
</div>
{!selectedKey ? (
<div className="flex items-center justify-center min-h-[60vh]">
<EmptyState
title={apiKeys.length === 0 ? "暂未创建应用" : "未选择应用"}
description={apiKeys.length === 0
? "您还没有创建任何应用。请前往集市中心创建一个应用,即可开始管理在线积分流转服务。"
: "请先在右上角选择一个应用以管理此应用的所有在线积分流转服务。"}
icon={Key}
/>
</div>
) : (
<div className="flex flex-col gap-6 h-full min-h-0">
<div className="space-y-4 shrink-0">
<h2 className="font-semibold -mt-2">所有服务</h2>
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex gap-4 pb-3">
<button
onClick={() => {
setIsCreating(true)
setSelectedLink(null)
setProductName("")
setAmount("")
setRemark("")
setTotalLimit("")
setUserLimit("")
}}
className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<div className="rounded-lg p-2 bg-purple-50 dark:bg-purple-950/20">
<Plus className="size-4 text-purple-600" />
</div>
<div className="text-center">
<h3 className="font-medium text-sm group-hover:text-foreground">创建服务</h3>
<p className="text-xs text-muted-foreground">添加新的在线积分流转服务</p>
</div>
</button>
{loading && paymentLinks.length === 0 && (
<div className="flex items-center justify-center h-[100px] w-[180px] shrink-0">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{paymentLinks.map((link) => (
<button
key={link.id}
onClick={() => {
if (previewLink?.id === link.id) {
setSelectedLink(link)
setIsCreating(false)
} else {
setPreviewLink(link)
setIsCreating(false)
toast.info("再次点击打开详情", {
duration: 1500,
position: "bottom-center"
})
}
}}
className={`rounded-lg p-4 border border-dashed shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col justify-between ${ previewLink?.id === link.id
? "border-primary bg-primary/5"
: "hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between gap-2 w-full">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{link.product_name}</h3>
<p className="text-xs text-muted-foreground truncate">{link.remark || "无备注"}</p>
</div>
<div className="rounded-lg p-1.5 bg-green-50 dark:bg-green-950/20 shrink-0">
<CreditCard className="size-3 text-green-600" />
</div>
</div>
<div className="flex items-baseline gap-1 mt-2">
<span className="text-xs text-muted-foreground">LDC</span>
<span className="text-lg font-mono font-semibold">{parseFloat(link.amount).toFixed(2)}</span>
</div>
</button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center justify-between shrink-0">
<h2 className="font-semibold">活动数据</h2>
</div>
<div className="h-[600px] overflow-hidden">
<TransactionTableList
loading={loadingTransactions}
error={transactionsError}
transactions={transactions}
onRetry={refresh}
emptyDescription="暂无在线流转服务数据"
/>
</div>
</div>
<div className={`space-y-4 ${ isFullscreen ? 'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm p-6 flex flex-col' : '' }`}>
<div className="flex items-center justify-between shrink-0">
<h2 className="font-semibold">{isFullscreen ? '全屏预览' : '实时预览'}</h2>
</div>
<div className={`border-none overflow-hidden relative h-[600px] ${ isFullscreen ? 'border-0 rounded-none h-full flex-1' : '' }`}>
<div className="absolute z-15 left-1/2 -translate-x-1/2 top-4">
<div className="h-8 bg-background border border-border rounded-full p-1.5 flex items-center gap-0.5 shadow-lg">
<Button variant={previewDevice === 'mobile' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('mobile')}>
<Smartphone className="size-3.5" />
</Button>
<Button variant={previewDevice === 'tablet' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('tablet')}>
<Tablet className="size-3.5" />
</Button>
<Button variant={previewDevice === 'desktop' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('desktop')}>
<Monitor className="size-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setIsFullscreen(!isFullscreen)}>
{isFullscreen ? <Minimize className="size-3.5" /> : <Expand className="size-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => { const current = previewDevice; setPreviewDevice('mobile'); setTimeout(() => setPreviewDevice(current), 50) }}>
<RotateCw className="size-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setPreviewTheme(previewTheme === 'dark' ? 'light' : 'dark')}>
{previewTheme === 'dark' ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
</Button>
</div>
</div>
<div className={`flex justify-center items-center p-6 relative overflow-hidden h-full ${ isFullscreen ? 'min-h-screen' : '' }`}>
<div className="absolute inset-0 pattern-grid-lg opacity-5" />
<motion.div
layout
initial={false}
animate={{
width: DEVICE_CONFIG[previewDevice].width,
height: DEVICE_CONFIG[previewDevice].height,
borderRadius: DEVICE_CONFIG[previewDevice].borderRadius,
borderWidth: DEVICE_CONFIG[previewDevice].borderWidth,
scale: isFullscreen
? (previewDevice === 'mobile' ? 0.9 : previewDevice === 'tablet' ? 0.75 : 0.65)
: DEVICE_CONFIG[previewDevice].scale,
}}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
className="shrink-0 overflow-hidden shadow-2xl bg-black relative border-[#1f1f1f] origin-center rounded-[50px]"
>
<AnimatePresence>
{previewDevice === 'mobile' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-10" key="mobile-frame">
<div className="absolute top-[11px] left-1/2 -translate-x-1/2 w-[120px] h-[35px] bg-black rounded-full flex items-center justify-center gap-3 shadow-sm border border-white/5">
<div className="w-2 h-2 rounded-full bg-[#1a1a1a]" />
<div className="w-1.5 h-1.5 rounded-full bg-[#0f0f0f]" />
</div>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
</motion.div>
)}
{previewDevice === 'tablet' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="tablet-frame">
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full bg-gray-600 border border-gray-500" />
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
</motion.div>
)}
{previewDevice === 'desktop' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="desktop-frame">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-48 h-8 bg-[#1f1f1f] rounded-b-xl flex items-center justify-center border-b border-x border-black/50">
<div className="w-1.5 h-1.5 rounded-full bg-black/50" />
</div>
</motion.div>
)}
</AnimatePresence>
<div className={`h-full w-full overflow-y-auto custom-scrollbar flex items-center justify-center p-4 transition-colors duration-500 ${ previewTheme === 'dark' ? 'dark bg-zinc-900' : 'bg-white' }`}>
<div className={`flex ${ previewDevice === 'mobile' ? 'flex-col' : 'flex-row' } w-full max-w-4xl backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl relative shrink-0 transition-colors duration-500 ${ previewTheme === 'dark' ? 'bg-card/70 border-border/50' : 'bg-white border-gray-200' }`}>
<PayingInfo orderInfo={previewOrderInfo} loading={loading} forceMobile={previewDevice === 'mobile'} />
<PayingNow orderInfo={previewOrderInfo} paying={false} payKey="" currentStep="method" selectedMethod="alipay" isOpen={false} loading={false} onPayKeyChange={() => { }} onCurrentStepChange={() => { }} onSelectedMethodChange={() => { }} onIsOpenChange={() => { }} onPayOrder={() => { }} forceMobile={previewDevice === 'mobile'} />
<div className="absolute inset-0 z-50 bg-transparent cursor-default" />
</div>
</div>
<div className="absolute inset-0 z-40 bg-transparent cursor-default" />
</motion.div>
</div>
</div>
</div>
</div>
<Sheet open={isCreating || !!selectedLink} onOpenChange={(open) => { if (!open) { setIsCreating(false); setSelectedLink(null); } }}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto custom-scrollbar px-4">
{isCreating ? (
<div className="space-y-6">
<SheetHeader className="px-0">
<SheetTitle>创建新服务</SheetTitle>
<SheetDescription className="text-xs">创建一个在线积分流转服务。</SheetDescription>
</SheetHeader>
<div className="border border-dashed rounded-lg p-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
<Input
placeholder="例如:高级会员订阅服务"
value={productName}
onChange={e => setProductName(e.target.value)}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="0.00"
className="font-mono"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={totalLimit}
onChange={e => setTotalLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={userLimit}
onChange={e => setUserLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
<Textarea
placeholder="服务备注信息..."
className="resize-none h-20"
value={remark}
onChange={e => setRemark(e.target.value)}
maxLength={200}
/>
</div>
</div>
<SheetFooter className="flex flex-row justify-end items-center gap-2">
<Button variant="ghost" onClick={() => setIsCreating(false)} className="h-8 text-xs">取消</Button>
<Button onClick={handleCreate} disabled={loading} className="h-8 text-xs">
{loading && <Spinner className="mr-1 size-3" />} 创建
</Button>
</SheetFooter>
</div>
) : selectedLink ? (
<div className="space-y-6">
<SheetHeader className="px-0">
<SheetTitle>{selectedLink.product_name}</SheetTitle>
<SheetDescription className="text-xs">
{isEditing ? "编辑在线积分流转服务信息" : "在线积分流转服务内容详情"}
</SheetDescription>
</SheetHeader>
{isEditing ? (
/* 编辑模式 */
<div className="space-y-6">
<div className="border border-dashed rounded-lg p-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
<Input
placeholder="例如:高级会员订阅服务"
value={productName}
onChange={e => setProductName(e.target.value)}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="0.00"
className="font-mono"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={totalLimit}
onChange={e => setTotalLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={userLimit}
onChange={e => setUserLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
<Textarea
placeholder="服务备注信息..."
className="resize-none h-20"
value={remark}
onChange={e => setRemark(e.target.value)}
maxLength={200}
/>
</div>
</div>
<SheetFooter className="flex flex-row justify-end items-center gap-2">
<Button variant="ghost" onClick={() => setIsEditing(false)} className="h-8 text-xs">取消</Button>
<Button onClick={handleUpdate} disabled={loading} className="h-8 text-xs">
{loading && <Spinner className="mr-1 size-3" />} 保存
</Button>
</SheetFooter>
</div>
) : (
/* 查看模式 */
<>
<div>
<h2 className="text-sm font-semibold mb-4">服务信息</h2>
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">服务名称</label>
<p className="text-xs font-medium truncate text-right max-w-[70%]">{selectedLink.product_name}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">积分数量</label>
<p className="text-xs font-bold text-primary">LDC {parseFloat(selectedLink.amount).toFixed(2)}</p>
</div>
{(selectedLink.total_limit || selectedLink.user_limit) && (
<>
{selectedLink.total_limit && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">总次数限制</label>
<p className="text-xs font-medium">{selectedLink.total_limit}</p>
</div>
)}
{selectedLink.user_limit && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">单用户限制</label>
<p className="text-xs font-medium">{selectedLink.user_limit}</p>
</div>
)}
</>
)}
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">服务备注</label>
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{selectedLink.remark || "无备注"}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{new Date(selectedLink.created_at).toLocaleString()}</p>
</div>
<div className="px-3 py-2 border-b border-dashed last:border-b-0">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground shrink-0">服务令牌</label>
<div className="flex items-center p-1 h-7 border border-dashed rounded-sm bg-background max-w-[200px]">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] whitespace-nowrap">
{showToken ? selectedLink.token : '•'.repeat(36)}
</code>
<Button
variant="ghost"
className="p-0.5 w-5 h-5 shrink-0"
onClick={() => setShowToken(!showToken)}
>
{showToken ? <EyeOff className="size-2.5 text-muted-foreground" /> : <Eye className="size-2.5 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
onClick={() => handleCopyLink(selectedLink.token)}
className="p-0.5 w-5 h-5 shrink-0"
>
<Copy className="size-2.5" />
</Button>
</div>
</div>
</div>
</div>
</div>
<div>
<h2 className="text-sm font-semibold mb-4">流转服务管理</h2>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => handleCopyLink(selectedLink.token)}>
<Copy className="size-3 mr-1" />
复制链接
</Button>
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => window.open(`/paying/online?token=${ selectedLink.token }`, '_blank')}>
<ExternalLink className="size-3 mr-1" />
查看服务
</Button>
<Button variant="outline" className="text-xs h-8 border-dashed border-blue-500 hover:bg-blue-50 shadow-none" onClick={() => handleEdit(selectedLink)}>
<svg className="size-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
编辑服务
</Button>
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 shadow-none" onClick={() => handleDelete(selectedLink)}>
<Trash2 className="size-3 mr-1" />
删除服务
</Button>
</div>
</div>
</>
)}
</div>
) : null}
</SheetContent>
</Sheet>
</div>
)
}
</div >
)
}
@@ -1,55 +0,0 @@
import * as React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { MerchantAPIKey } from "@/lib/services"
interface MerchantSelectorProps {
/** API Keys 列表 */
apiKeys: MerchantAPIKey[]
/** 选中的 API Key */
selectedKeyId: string | null
/** 选择回调 */
onSelect: (id: string) => void
/** 是否正在加载 */
loading?: boolean
}
/**
* 应用选择器组件
* 用于在多个应用之间切换
*/
export function MerchantSelector({
apiKeys,
selectedKeyId,
onSelect,
loading = false,
}: MerchantSelectorProps) {
return (
<Select
value={selectedKeyId || ''}
onValueChange={(value) => onSelect(value)}
disabled={loading || apiKeys.length === 0}
>
<SelectTrigger className="w-fit h-8 text-xs" size="sm">
<SelectValue placeholder="请选择应用" />
</SelectTrigger>
<SelectContent>
{apiKeys.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
title={apiKey.app_description ? `${ apiKey.app_name } - ${ apiKey.app_description }` : apiKey.app_name}
>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{apiKey.app_name}</span>
{apiKey.app_description && (
<span className="text-xs text-muted-foreground truncate">
- {apiKey.app_description}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -1,176 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Power, PowerOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import type { MerchantAPIKey, UpdateAPIKeyRequest } from "@/lib/services"
interface TestModeToggleProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 测试模式切换组件
* 提供测试模式开关按钮,带有详细的说明提示
*/
export function TestModeToggle({ apiKey, onUpdate, updateAPIKey }: TestModeToggleProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [pendingValue, setPendingValue] = useState<boolean>(false)
const handleToggle = async () => {
try {
setProcessing(true)
const updateData = {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
test_mode: pendingValue
}
if (updateAPIKey) {
await updateAPIKey(apiKey.id, updateData)
} else {
const services = await import("@/lib/services")
await services.default.merchant.updateAPIKey(apiKey.id, updateData)
}
toast.success(pendingValue ? '测试模式已开启' : '测试模式已关闭', {
description: pendingValue
? '现在订单不会真实扣费,仅用于测试调试'
: '现在订单将正常扣费,可以接收真实付款'
})
const updatedKey = { ...apiKey, test_mode: pendingValue }
onUpdate?.(updatedKey)
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || '更新测试模式失败'
toast.error('更新失败', { description: errorMessage })
} finally {
setProcessing(false)
}
}
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
setPendingValue(!apiKey.test_mode)
setOpen(true)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button
variant="outline"
onClick={handleClick}
className={`h-8 text-xs border-dashed w-full ${ apiKey.test_mode
? "text-orange-600 border-orange-500/50 hover:bg-orange-500/10"
: "text-green-600 border-green-500/50 hover:bg-green-500/10"
}`}
>
{apiKey.test_mode ? (
<Power className="size-3 mr-1" />
) : (
<PowerOff className="size-3 mr-1" />
)}
{apiKey.test_mode ? "关闭测试" : "开启测试"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{pendingValue ? (
<>
<Power className="size-5 text-orange-600" />
开启测试模式
</>
) : (
<>
<PowerOff className="size-5 text-green-600" />
关闭测试模式
</>
)}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
{pendingValue ? (
<>
<p className="font-medium">测试模式开启后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单不会真实扣费</strong>:用户余额不会扣减</li>
<li><strong>商户不会收款</strong>:你的余额不会增加</li>
<li><strong>仅限自己测试</strong>:只有你可以支付测试订单</li>
<li><strong>订单标记明显</strong>:测试订单会显示警告标识</li>
</ul>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
<p className="text-xs text-orange-800 dark:text-orange-300">
<strong>使用场景:</strong>在正式上线前测试支付流程、回调通知等功能
</p>
</div>
</>
) : (
<>
<p className="font-medium">测试模式关闭后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单真实扣费</strong>:用户余额会正常扣减</li>
<li><strong>商户正常收款</strong>:你的余额会正常增加</li>
<li><strong>其他用户可支付</strong>:任何人都可以支付订单</li>
<li><strong>正式生产环境</strong>:可以接收真实的业务订单</li>
</ul>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p className="text-xs text-green-800 dark:text-green-300">
<strong>注意:</strong>关闭后应用将进入生产模式,请确保已完成测试
</p>
</div>
</>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={processing}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleToggle()
}}
disabled={processing}
className={pendingValue ? "bg-orange-600 hover:bg-orange-700" : "bg-green-600 hover:bg-green-700"}
>
{processing ? (
<>
<Spinner className="size-4" />
{pendingValue ? '开启中' : '关闭中'}
</>
) : (
pendingValue ? '确认开启' : '确认关闭'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,179 +0,0 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { ShoppingBag } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 加载中骨架组件
* 显示活动信息加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="w-full">
<div className="mb-6 text-center md:text-left">
<Skeleton className="h-4 w-32 mb-2 bg-white/10 mx-auto md:mx-0" />
<Skeleton className="h-8 w-40 bg-white/10 mx-auto md:mx-0" />
</div>
<div className="space-y-4">
<div className="rounded-xl bg-white/5 p-4 space-y-4">
<div className="flex justify-between items-start pb-4">
<div className="flex flex-col items-start space-y-1.5">
<Skeleton className="h-4 w-24 bg-white/10" />
<Skeleton className="h-3 w-16 bg-white/10" />
</div>
<div className="flex flex-col items-end space-y-1.5">
<Skeleton className="h-4 w-20 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
</div>
</div>
<div className="flex justify-between items-center pt-2">
<Skeleton className="h-6 w-20 bg-white/10" />
<Skeleton className="h-6 w-24 bg-white/10" />
</div>
</div>
<div className="space-y-1.5 px-1">
<Skeleton className="h-3 w-full bg-white/10" />
<Skeleton className="h-3 w-3/4 bg-white/10" />
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
function OrderContent({ orderInfo, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, forceMobile?: boolean }) {
const amount = parseFloat(orderInfo.order.amount).toFixed(2)
const feeRateValue = orderInfo.user_pay_config?.fee_rate
const feeRate = feeRateValue ? (parseFloat(feeRateValue) * 100).toFixed(2) : '0.00'
return (
<div className="w-full">
<div className={`mb-4 text-center ${ forceMobile ? '' : 'md:mb-6 md:text-left' }`}>
<div className={`inline-flex items-center justify-center gap-1.5 mb-1 text-white/50 text-[10px] font-medium uppercase tracking-wider ${ forceMobile ? '' : 'md:justify-start md:gap-2 md:mb-1.5 md:text-xs' }`}>
<span>服务方</span>
<span className="w-1 h-1 rounded-full bg-white/30" />
<span className="text-white hover:text-blue-400 transition-colors cursor-pointer font-bold">{orderInfo.merchant.app_name}</span>
</div>
<h1 className={`text-2xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1 ${ forceMobile ? '' : 'md:text-4xl md:justify-start' }`}>
<span className={`text-lg text-white/50 font-normal ${ forceMobile ? '' : 'md:text-2xl' }`}>LDC</span>
{amount}
</h1>
</div>
<div className={`space-y-3 ${ forceMobile ? '' : 'md:space-y-4' }`}>
<div className={`rounded-2xl bg-white/5 p-3 backdrop-blur-sm transition-colors ${ forceMobile ? '' : 'md:p-4' }`}>
<div className={`flex justify-between items-start pb-3 mb-3 ${ forceMobile ? '' : 'md:pb-4 md:mb-4' }`}>
<div className={`flex flex-col items-start pr-3 ${ forceMobile ? '' : 'md:pr-4' }`}>
<p className={`text-xs font-medium text-white mb-0.5 line-clamp-2 ${ forceMobile ? '' : 'md:text-sm' }`}>{orderInfo.order.order_name}</p>
<p className="text-white/40 text-[10px]">数量: 1</p>
</div>
<div className="flex flex-col items-end text-right whitespace-nowrap">
<p className={`text-xs font-medium text-white mb-0.5 ${ forceMobile ? '' : 'md:text-sm' }`}>LDC {amount}</p>
<p className="text-white/40 text-[10px]">LDC {amount} / 个</p>
</div>
</div>
<button className="w-full text-xs text-left mb-3 text-white/40 hover:text-white transition-colors duration-200 flex items-center gap-2 group">
<span>添加优惠码</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/30">暂未开放</span>
</button>
<div className="flex justify-between items-center pt-2">
<p className="text-base font-bold text-white">积分合计</p>
<p className="text-lg font-bold text-white tracking-tight">LDC {amount}</p>
</div>
</div>
<div className="px-2">
<p className="text-[10px] text-white/30 space-y-0.5 leading-relaxed">
* 包含 {feeRate}% 的服务手续费 (由服务方承担)。
<br />
服务手续费率根据服务方等级和平台活动动态调整,不会消耗您(消费者)的积分。
</p>
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
export function PayingInfo({ orderInfo, loading = false, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, loading: boolean, forceMobile?: boolean }) {
return (
<div className={`flex flex-col w-full p-4 justify-center bg-gradient-to-br from-neutral-900/95 to-black dark:from-neutral-900 dark:to-black relative text-white rounded-t-3xl ${ forceMobile ? '' : 'md:w-[45%] md:p-8 md:rounded-l-3xl md:rounded-tr-none' }`}>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-30">
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] bg-blue-900/20 rounded-full blur-3xl" />
<div className="absolute top-[40%] -right-[10%] w-[60%] h-[60%] bg-purple-900/20 rounded-full blur-3xl" />
</div>
<div className={`w-full max-w-xs mx-auto relative z-10 flex flex-col justify-center ${ forceMobile ? '' : 'md:h-full md:min-h-[360px]' }`}>
<div className={`text-center mb-2 ${ forceMobile ? '' : 'md:hidden' }`}>
{loading ? (
<div className="flex flex-col items-center gap-2">
<Skeleton className="h-3 w-32 bg-white/10" />
<div className="flex items-baseline justify-center gap-1">
<Skeleton className="h-8 w-24 bg-white/10" />
</div>
</div>
) : (
<>
<div className="text-white/50 text-[10px] uppercase tracking-wider mb-1">服务方 {orderInfo.merchant.app_name}</div>
<div className="text-3xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1">
<span className="text-lg text-white/50 font-normal">LDC</span>
{(parseFloat(orderInfo.order.amount)).toFixed(2)}
</div>
</>
)}
</div>
<div className={`mb-10 flex justify-center hidden ${ forceMobile ? '' : 'md:block md:justify-start' }`}>
<div className="flex items-center gap-2">
<div className="w-8 h-8 flex items-center justify-center shadow-sm backdrop-blur-sm">
{loading ? (
<Skeleton className="w-5 h-5 rounded-full bg-white/20" />
) : (
<ShoppingBag className="size-4 text-white/80" />
)}
</div>
{loading ? (
<Skeleton className="h-5 w-24 bg-white/10" />
) : (
<span className="text-xs font-bold text-white/60">积分流转信息</span>
)}
</div>
</div>
<div className={`hidden ${ forceMobile ? '' : 'md:block' }`}>
{loading ? <LoadingSkeleton /> : <OrderContent orderInfo={orderInfo} forceMobile={forceMobile} />}
</div>
{!loading && (
<div className={`text-center ${ forceMobile ? '' : 'md:hidden' }`}>
<div className="inline-flex items-center gap-1 text-[10px] text-white/40 bg-white/5 px-2 py-1 rounded-full">
<span>{orderInfo.order.order_name}</span>
</div>
</div>
)}
<div className={`hidden mt-auto pt-8 ${ forceMobile ? '' : 'md:block' }`}>
<div className="text-[10px] text-white/20 font-mono">
ORDER: {orderInfo?.order?.order_no || 'LOADING...'}
</div>
</div>
</div>
</div>
)
}
@@ -1,240 +0,0 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import services from "@/lib/services"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 积分认证主页面组件
* 通过 order_no 查询订单信息并完成积分认证
*/
export function PayingMain() {
/** 获取URL参数中的认证编号 */
const searchParams = useSearchParams()
const router = useRouter()
const encryptedOrderNo = searchParams.get('order_no')
/** 组件状态管理 */
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/** 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'订单不存在': "此积分流转服务不存在或已过期",
'未登录': "请先登录后再进行认证",
'余额不足': "您的积分余额不足",
'安全密码': "安全密码错误",
'不能认证自己的订单': "不能认证自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
'已完成': "认证完成"
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/** 组件初始化时查询订单信息 */
useEffect(() => {
if (encryptedOrderNo) {
setError(false)
handleQueryOrder(encryptedOrderNo)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [encryptedOrderNo])
/** 查询订单信息 */
const handleQueryOrder = async (queryOrderNo?: string) => {
const targetOrderNo = queryOrderNo || encryptedOrderNo
if (!targetOrderNo) {
toast.error("缺少认证编号", { id: 'missing-order-no' })
return
}
setLoading(true)
try {
const data = await services.merchant.getMerchantOrder({ order_no: targetOrderNo })
setOrderInfo(data)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询认证信息")
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/** 执行积分认证操作 */
const handlePayOrder = async () => {
if (!orderInfo) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length !== 6) {
toast.error("安全密码长度为6位,请重新输入")
return
}
setPaying(true)
try {
const freshOrderInfo = await services.merchant.getMerchantOrder({
order_no: encryptedOrderNo!
})
if (freshOrderInfo.order.status !== 'pending') {
toast.error("此积分流转服务状态已变更,无法认证")
setOrderInfo(freshOrderInfo)
return
}
await services.merchant.payMerchantOrder({
order_no: encryptedOrderNo!,
pay_key: payKey
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/** 支付成功后立即更新订单状态为成功 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/** 5秒后跳转到redirect_uri或刷新页面 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
router.push('/home')
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
if (
error &&
typeof error === 'object' &&
'message' in error &&
String(error.message).includes('已完成')
) {
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
window.location.reload()
}, 500)
}
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的认证编号已经完成认证或不存在。请检查您的积分流转服务链接是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}
@@ -1,445 +0,0 @@
import * as React from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Send } from "@/components/animate-ui/icons/send"
import { Check } from "@/components/animate-ui/icons/check"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/animate-ui/primitives/radix/collapsible"
import { AlertCircle, CreditCard, ArrowLeft, ShieldCheck, Lock } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
type PaymentStep = typeof PAYMENT_STEPS[keyof typeof PAYMENT_STEPS]
const PAYMENT_METHODS = {
LINUX_DO_PAY: 'linux-do-pay'
} as const
const PAYMENT_STEPS = {
METHOD: 'method' as const,
Credit: 'pay' as const
} as const
const ORDER_STATUSES = {
SUCCESS: 'success',
PENDING: 'pending'
} as const
/**
* 加载中骨架组件
* 显示支付方式加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="space-y-6 w-full max-w-xs mx-auto">
<div className="space-y-3">
<Skeleton className="h-6 w-32 bg-muted/50" />
<Skeleton className="h-4 w-48 bg-muted/50" />
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-24 bg-muted/50" />
</div>
</div>
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-20 bg-muted/50" />
</div>
</div>
</div>
<Skeleton className="h-10 w-full rounded-full bg-muted/50" />
</div>
)
}
/**
* 成功状态组件
* 显示认证成功状态
*/
function SuccessState() {
const [countdown, setCountdown] = React.useState(5)
React.useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [])
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
type: "spring",
stiffness: 100
}}
className="text-center py-10"
>
<div className="relative inline-block mb-6">
<div className="absolute inset-0 bg-green-500/20 blur-xl rounded-full" />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: 0.2,
duration: 0.5,
type: "spring",
stiffness: 200
}}
className="relative w-10 h-10 bg-green-500 rounded-full flex items-center justify-center "
>
<Check
className="size-6 text-white"
animate={true}
loop={false}
/>
</motion.div>
</div>
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.4 }}
className="text-xl font-bold text-foreground mb-2"
>
认证成功
</motion.h3>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.4 }}
className="text-muted-foreground text-sm"
>
认证已安全完成,{countdown > 0 ? `${ countdown }秒后自动跳转...` : '正在跳转...'}
</motion.p>
</motion.div>
)
}
/**
* 认证方式选择步骤组件
* 显示认证方式选择步骤
*/
function MethodSelectionStep({
orderInfo,
selectedMethod,
isOpen,
onSelectedMethodChange,
onIsOpenChange,
onCurrentStepChange,
forceMobile = false
}: {
orderInfo: GetMerchantOrderResponse
selectedMethod: string
isOpen: boolean
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onCurrentStepChange: (step: PaymentStep) => void
forceMobile?: boolean
}) {
const { user } = useUser()
return (
<motion.div
key="method-step"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className={`space-y-2 text-center ${ forceMobile ? '' : 'md:text-left' }`}>
<h2 className="text-xl font-bold text-foreground tracking-tight">选择认证平台</h2>
<p className="text-xs text-muted-foreground">请选择您想要使用的服务认证平台</p>
</div>
<div className="space-y-3">
<Collapsible open={isOpen} onOpenChange={onIsOpenChange}>
<CollapsibleTrigger asChild>
<div
className={`group cursor-pointer rounded-xl p-2.5 transition-all duration-300 border backdrop-blur-sm ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary/50 bg-primary/5'
: 'border-border/50 bg-card hover:bg-muted/50 hover:border-border'
}`}
onClick={() => {
if (selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY) {
onSelectedMethodChange('')
onIsOpenChange(false)
} else {
onSelectedMethodChange(PAYMENT_METHODS.LINUX_DO_PAY)
onIsOpenChange(true)
}
}}
>
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'
}`}>
<CreditCard className="size-4" />
</div>
<div className="flex-1 text-left">
<div className="font-bold text-xs text-foreground">LINUX DO Credit</div>
<div className="text-[9px] text-muted-foreground mt-0.5">即时安全认证</div>
</div>
<div className={`w-5 h-5 rounded-full border flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary bg-primary text-primary-foreground'
: 'border-border'
}`}>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && <Check className="size-3" />}
</div>
</div>
</div>
</CollapsibleTrigger>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && (
<CollapsibleContent>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="pt-3 px-1"
>
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mb-4 bg-muted/50 p-2.5 rounded-lg border">
<div className="p-1 rounded">
<Lock className="size-3 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-[10px] opacity-70">消费方账户</span>
<span className="font-medium text-foreground">{orderInfo.order.payer_username || user?.username}</span>
</div>
</div>
<Button
onClick={() => onCurrentStepChange(PAYMENT_STEPS.Credit)}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
确认并继续
</Button>
</motion.div>
</CollapsibleContent>
)}
</Collapsible>
</div>
</motion.div>
)
}
/**
* 认证步骤组件
* 显示认证步骤
*/
function PaymentStep({
orderInfo,
payKey,
paying,
onPayKeyChange,
onCurrentStepChange,
onPayOrder
}: {
orderInfo: GetMerchantOrderResponse
payKey: string
paying: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onPayOrder: () => void
}) {
return (
<motion.div
key="pay-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className="space-y-1.5">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 text-muted-foreground hover:text-foreground mb-1"
onClick={() => onCurrentStepChange(PAYMENT_STEPS.METHOD)}
>
<ArrowLeft className="size-3.5 mr-1" /> 返回
</Button>
<h2 className="text-xl font-bold text-foreground tracking-tight">身份验证</h2>
<p className="text-xs text-muted-foreground">请输入您的6位安全密码</p>
</div>
<div className="flex justify-center py-4">
<InputOTP
maxLength={6}
value={payKey}
onChange={onPayKeyChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && payKey.length === 6 && !paying) {
onPayOrder()
}
}}
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className="size-11 bg-background border-border text-foreground text-lg font-bold transition-all focus:border-primary focus:ring-4 focus:ring-primary/10 ring-offset-0"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button
onClick={onPayOrder}
disabled={paying || payKey.length !== 6}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
{paying ? (
<span className="flex items-center gap-2">
<Send className="size-4" animate={true} loop={true} loopDelay={2000} /> 处理中...
</span>
) : (
`消耗 LDC ${ (parseFloat(orderInfo.order.amount)).toFixed(2) }`
)}
</Button>
</motion.div>
)
}
/**
* 错误状态组件
* 显示错误状态
*/
function ErrorState({ status }: { status: string }) {
return (
<div className="text-center py-10">
<div className="w-14 h-14 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/20">
<AlertCircle className="size-6 text-red-500" />
</div>
<h3 className="text-lg font-bold text-foreground mb-1">认证出现问题</h3>
<p className="text-muted-foreground text-sm">状态:<span className="font-medium text-foreground">{status}</span></p>
</div>
)
}
/**
* 底部组件
* 显示底部
*/
function Footer({ loading }: { loading?: boolean }) {
return loading ? (
<div className="flex flex-col items-center justify-center space-y-2 mt-8 opacity-50">
<Skeleton className="h-3 w-32" />
</div>
) : (
<div className="flex items-center justify-center gap-2 text-[10px] uppercase font-bold text-muted-foreground/40 mt-10">
<ShieldCheck className="size-3 text-green-500" />
<span>由 LINUX DO Credit 提供安全认证</span>
</div>
)
}
interface PayingNowProps {
orderInfo: GetMerchantOrderResponse | null
paying: boolean
payKey: string
currentStep: PaymentStep
selectedMethod: string
isOpen: boolean
loading?: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onPayOrder: () => void
forceMobile?: boolean
}
/**
* 支付现在组件
* 显示支付现在组件
*/
export function PayingNow({
orderInfo,
paying,
payKey,
currentStep,
selectedMethod,
isOpen,
loading = false,
onPayKeyChange,
onCurrentStepChange,
onSelectedMethodChange,
onIsOpenChange,
onPayOrder,
forceMobile = false
}: PayingNowProps) {
const renderContent = () => {
if (loading) return <LoadingSkeleton />
if (!orderInfo) return null
switch (orderInfo.order.status) {
case ORDER_STATUSES.SUCCESS:
return <SuccessState />
case ORDER_STATUSES.PENDING:
return (
<AnimatePresence mode="wait">
{currentStep === PAYMENT_STEPS.METHOD ? (
<MethodSelectionStep
orderInfo={orderInfo}
selectedMethod={selectedMethod}
isOpen={isOpen}
onSelectedMethodChange={onSelectedMethodChange}
onIsOpenChange={onIsOpenChange}
onCurrentStepChange={onCurrentStepChange}
forceMobile={forceMobile}
/>
) : (
<PaymentStep
orderInfo={orderInfo}
payKey={payKey}
paying={paying}
onPayKeyChange={onPayKeyChange}
onCurrentStepChange={onCurrentStepChange}
onPayOrder={onPayOrder}
/>
)}
</AnimatePresence>
)
default:
return <ErrorState status={orderInfo.order.status} />
}
}
return (
<div className={`flex-1 flex flex-col items-center justify-center p-6 relative overflow-hidden bg-white dark:bg-neutral-900 transition-colors duration-500 ${ forceMobile ? '' : 'md:p-8' }`}>
<div className="w-full relative z-10 flex flex-col h-full justify-center">
<div className="flex-1 flex flex-col justify-center">
{renderContent()}
</div>
<Footer loading={loading} />
</div>
</div>
)
}
@@ -1,282 +0,0 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import { ConfigService } from "@/lib/services"
import type { PaymentLink, GetMerchantOrderResponse, UserPayConfig } from "@/lib/services"
/**
* 支付链接支付页面组件
* 通过 token 查询支付链接信息并完成支付
*/
export function PayingOnline() {
/* 获取URL参数中的Token */
const searchParams = useSearchParams()
const token = searchParams.get('token')
const { user } = useUser()
/* 组件状态管理 */
const [paymentLink, setPaymentLink] = useState<PaymentLink | null>(null)
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/* 获取用户支付配置 */
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
useEffect(() => {
const loadConfigs = async () => {
try {
const configs = await ConfigService.getUserPayConfigs()
setUserPayConfigs(configs)
} catch (error) {
console.error("Failed to load user pay configs:", error)
}
}
loadConfigs()
}, [])
/* 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'不存在': "此积分流转服务不存在或已失效",
'未登录': "请先登录后再进行认证",
'余额不足': "账户积分不足",
'支付密码': "安全密码错误",
'不能支付': "不能支付自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/* 组件初始化时查询支付链接信息 */
useEffect(() => {
if (token) {
setError(false)
handleQueryPaymentLink(token)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, userPayConfigs.length, user?.pay_level])
/* 查询支付链接信息 */
const handleQueryPaymentLink = async (queryToken?: string) => {
const targetToken = queryToken || token
if (!targetToken) {
toast.error("缺少支付链接", { id: 'missing-token' })
return
}
setLoading(true)
setLoading(true)
try {
const data = await services.merchant.getPaymentLinkByToken(targetToken)
setPaymentLink(data)
/* 将支付链接信息转换为订单信息格式,以复用PayingInfo和PayingNow组件 */
const mockOrderInfo: GetMerchantOrderResponse = {
merchant: {
app_name: data.app_name || "商户",
redirect_uri: data.redirect_uri || "",
},
order: {
id: '0',
order_no: `LINK-${ data.id }`,
order_name: data.product_name,
payer_username: user?.username || "",
payee_username: "",
amount: data.amount,
status: "pending",
type: "payment",
payment_type: "link",
remark: data.remark || "",
client_id: "",
return_url: "",
notify_url: "",
trade_time: null,
created_at: data.created_at,
updated_at: data.updated_at,
},
user_pay_config: (() => {
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
return currentUserConfig ? {
...currentUserConfig,
fee_rate: String(currentUserConfig.fee_rate)
} : {
id: '0',
level: user?.pay_level ?? 0,
min_score: 0,
max_score: null,
daily_limit: null,
fee_rate: "0.00",
created_at: "",
updated_at: ""
}
})(),
}
setOrderInfo(mockOrderInfo)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询积分流转服务")
setPaymentLink(null)
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/* 执行支付操作 */
const handlePayOrder = async () => {
if (!paymentLink || !token) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length < 6 || payKey.length > 10) {
toast.error("安全密码长度必须为6位")
return
}
setPaying(true)
try {
await services.merchant.payByLink({
token: token,
pay_key: payKey,
remark: paymentLink.remark || undefined
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/* 支付成功后更新订单状态 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/* 5秒后跳转到首页或回调地址 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
window.location.href = '/home'
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的积分流转服务不存在或已失效。请检查链接地址是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}
@@ -1,286 +0,0 @@
"use client"
import * as React from "react"
import { useState, useCallback } from "react"
import Cropper from "react-easy-crop"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Crop, RotateCw, X } from "lucide-react"
import type { Area, Point } from "react-easy-crop"
interface ImageCropperProps {
/** 是否打开对话框 */
isOpen: boolean
/** 关闭对话框回调 */
onOpenChange: (open: boolean) => void
/** 裁剪完成回调 */
onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void
/** 封面类型 */
coverType: 'cover' | 'heterotypic'
}
/**
* 创建裁剪后的图片
*/
async function getCroppedImg(
imageSrc: string,
pixelCrop: Area,
rotation = 0
): Promise<string> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建 canvas context')
}
const maxSize = Math.max(image.width, image.height)
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
canvas.width = safeArea
canvas.height = safeArea
ctx.translate(safeArea / 2, safeArea / 2)
ctx.rotate((rotation * Math.PI) / 180)
ctx.translate(-safeArea / 2, -safeArea / 2)
ctx.drawImage(
image,
safeArea / 2 - image.width * 0.5,
safeArea / 2 - image.height * 0.5
)
const data = ctx.getImageData(0, 0, safeArea, safeArea)
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
)
return canvas.toDataURL('image/webp')
}
/**
* 创建图片对象
*/
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', (error) => reject(error))
image.setAttribute('crossOrigin', 'anonymous')
image.src = url
})
}
/**
* 图片裁剪组件
* 使用 react-easy-crop 实现专业的图片裁剪功能
*/
export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) {
const [image, setImage] = useState<string | null>(null)
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const resetCropState = () => {
setImage(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setRotation(0)
setCroppedAreaPixels(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// 裁剪比例配置 - 2:3
const aspect = 2 / 3
const cropShape = 'rect'
const coverLabel = coverType === 'cover' ? '背景封面' : '装饰图片'
// 处理文件选择
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件')
return
}
// 验证文件大小 (最大 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('图片大小不能超过 2MB')
return
}
const reader = new FileReader()
reader.onload = (e) => {
resetCropState()
setImage(e.target?.result as string)
}
reader.readAsDataURL(file)
}
// 裁剪完成回调
const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels)
}, [])
// 执行裁剪
const handleCrop = async () => {
if (!image || !croppedAreaPixels) return
try {
const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation)
onCropComplete(croppedImage, coverType)
handleClose()
} catch (error) {
console.error('裁剪失败:', error)
toast.error('裁剪失败,请重试')
}
}
// 关闭对话框
const handleClose = () => {
resetCropState()
onOpenChange(false)
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className={`max-h-[90vh] overflow-y-auto w-[95vw] sm:w-full ${ image ? 'sm:max-w-2xl' : 'sm:max-w-md' }`}>
<DialogHeader>
<DialogTitle className="text-sm">
{image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`}
</DialogTitle>
<DialogDescription className="text-xs">
{image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'}
</DialogDescription>
</DialogHeader>
{!image ? (
/* 上传区域 */
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
id="image-upload"
/>
<Label
htmlFor="image-upload"
className="cursor-pointer block rounded-lg border border-dashed border-border hover:border-foreground/25 hover:bg-muted/50 transition-colors p-10 sm:p-14 text-center"
>
<Crop className="size-6 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-xs font-medium text-foreground">点击选择图片</p>
<p className="text-[10px] text-muted-foreground mt-1">
或拖拽图片到此处
</p>
</Label>
</div>
) : (
/* 裁剪区域 */
<div className="space-y-3">
<div className="relative w-full h-[300px] sm:h-[420px] bg-muted/50 rounded-lg overflow-hidden touch-none">
<Cropper
image={image}
crop={crop}
zoom={zoom}
rotation={rotation}
aspect={aspect}
cropShape={cropShape}
showGrid={true}
onCropChange={setCrop}
onCropComplete={onCropCompleteHandler}
onZoomChange={setZoom}
onRotationChange={setRotation}
/>
</div>
<div className="flex items-center gap-4 px-1">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Label className="text-[10px] text-muted-foreground shrink-0">缩放</Label>
<Slider
value={[zoom]}
onValueChange={(value: number[]) => setZoom(value[0])}
min={1}
max={3}
step={0.1}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">{Math.round(zoom * 100)}%</span>
</div>
<div className="w-px h-4 bg-border shrink-0" />
<div className="flex items-center gap-2 flex-1 min-w-0">
<Label className="text-[10px] text-muted-foreground shrink-0">旋转</Label>
<Slider
value={[rotation]}
onValueChange={(value: number[]) => setRotation(value[0])}
min={0}
max={360}
step={1}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground tabular-nums w-6 text-right shrink-0">{rotation}°</span>
</div>
<div className="w-px h-4 bg-border shrink-0" />
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRotation((prev) => (prev + 90) % 360)}
className="size-7 text-muted-foreground"
title="旋转 90°"
>
<RotateCw className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={resetCropState}
className="size-7 text-muted-foreground"
title="重新选择"
>
<X className="size-3.5" />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={handleClose} className="text-xs h-8">
取消
</Button>
<Button
onClick={handleCrop}
disabled={!image}
className="bg-red-500 hover:bg-red-600 text-xs h-8"
>
确认裁剪
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,134 +0,0 @@
"use client"
import * as React from "react"
import Image from "next/image"
import { motion } from "motion/react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export interface RedEnvelopeCardProps {
status: "preview" | "ready" | "opening"
coverImage?: string | null
heterotypicImage?: string | null
greeting?: string
sender?: {
username?: string
avatar_url?: string
}
onOpen?: () => void
amount?: string
className?: string
}
export function RedEnvelopeCard({
status,
coverImage,
greeting,
sender,
onOpen,
className
}: RedEnvelopeCardProps) {
// 预览模式下不播放入场动画
const isPreview = status === "preview"
return (
<motion.div
layoutId={!isPreview ? "red-envelope-card" : undefined}
className={`relative w-full h-full rounded-xl shadow-2xl overflow-hidden z-10 bg-[#F25542] ${ className || "" }`}
initial={!isPreview ? { opacity: 1 } : undefined}
animate={!isPreview ? { opacity: 1 } : undefined}
exit={!isPreview ? { y: -40, opacity: 0, scale: 0.95, transition: { duration: 0.5, ease: "easeInOut" } } : undefined}
>
{/* 上半部分 */}
<div
className="absolute top-0 h-[78%] overflow-hidden z-10 w-full"
style={{
borderRadius: '0 0 100% 100% / 0 0 20% 20%',
backgroundColor: coverImage ? '#f35543' : '#E75240',
boxShadow: '0 2px 2px rgba(0,0,0,0.15)'
}}
>
{/* 自定义背景封面 */}
{coverImage ? (
<div className="absolute inset-0">
<Image
src={coverImage}
alt="红包封面"
fill
className="object-cover"
unoptimized
loading="eager"
/>
{/* 半透明遮罩以确保内容可读 */}
<div className="absolute inset-0 bg-black/30 dark:bg-black/50" />
</div>
) : null}
{/* 用户信息 */}
<div className="absolute top-28 left-0 right-0 flex flex-col items-center justify-center text-[#FAE5AE] z-20 px-6 space-y-4">
<motion.div
initial={!isPreview ? { scale: 0 } : undefined}
animate={!isPreview ? { scale: 1 } : undefined}
transition={{ delay: 0.15, type: "spring", stiffness: 300, damping: 20 }}
className="flex items-center gap-2 opacity-90"
>
<Avatar className="h-8 w-8 rounded-md border border-white/20 shadow-sm">
<AvatarImage src={sender?.avatar_url} alt={sender?.username} />
<AvatarFallback className="bg-[#FAE5AE] text-[#E75240] font-bold text-xs rounded-md">
{sender?.username?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-base font-medium tracking-wide">{sender?.username || '你'} 的红包</span>
</motion.div>
<motion.p
initial={!isPreview ? { opacity: 0, y: 10 } : undefined}
animate={!isPreview ? { opacity: 1, y: 0 } : undefined}
transition={{ delay: 0.3 }}
className="text-xl font-medium tracking-wider text-center opacity-90"
>
{greeting || "新年快乐,恭喜发财"}
</motion.p>
</div>
</div>
{/* 开启按钮 */}
<div className="absolute top-[78%] left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
{status === "opening" && (
<motion.div
className="absolute inset-0 -m-8"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: [0.9, 2.0], opacity: [0, 0.6, 0] }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<div
className="w-full h-full rounded-full"
style={{ background: "radial-gradient(circle, rgba(255,232,168,0.8) 0%, rgba(245,205,122,0.4) 35%, transparent 65%)" }}
/>
</motion.div>
)}
<motion.button
className="relative w-20 h-20 rounded-full bg-[#EFC77A] flex items-center justify-center shadow-lg cursor-pointer border-none focus:outline-none overflow-hidden group"
whileHover={status === "ready" || isPreview ? { scale: 1.05 } : {}}
whileTap={status === "ready" || isPreview ? { scale: 0.95 } : {}}
animate={status === "opening" ? {
scale: [1, 0.92, 0],
opacity: [1, 0]
} : {}}
transition={status === "opening" ? {
duration: 0.55,
ease: "easeInOut"
} : {
duration: 0.2
}}
onClick={status === "ready" ? onOpen : undefined}
disabled={status !== "ready" && !isPreview}
>
<span className="relative text-[#4D4753] font-bold text-3xl z-10" style={{ fontFamily: 'serif' }}>
開
</span>
</motion.button>
</div>
</motion.div>
)
}
@@ -1,373 +0,0 @@
"use client"
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import Image from "next/image"
import { motion, AnimatePresence } from "motion/react"
import { toast } from "sonner"
import { Gift } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import services from "@/lib/services"
import { formatDateTime } from "@/lib/utils"
import type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services"
import { getFileUrl } from "@/lib/services/upload/upload.service"
import { RedEnvelopeCard } from "./red-envelope-card"
interface RedEnvelopeClaimProps {
id: string
}
type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error"
export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) {
const [state, setState] = useState<ClaimState>("loading")
const [detail, setDetail] = useState<RedEnvelopeDetailResponse | null>(null)
const [claimedAmount, setClaimedAmount] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const truncateText = (value: string | undefined, maxChars: number) => {
if (!value) return ""
const chars = Array.from(value)
if (chars.length <= maxChars) return value
return `${ chars.slice(0, maxChars).join("") }…`
}
const bestClaimId = React.useMemo(() => {
const claims = detail?.claims
if (!claims || claims.length === 0) return null
let topId: string | null = null
let topAmount = -Infinity
for (const claim of claims) {
const amount = parseFloat(claim.amount)
if (Number.isNaN(amount)) continue
if (amount > topAmount) {
topAmount = amount
topId = claim.id
}
}
return topId
}, [detail?.claims])
const formatClaimedAt = (claimedAt?: string) => {
if (!claimedAt) return "-"
const date = new Date(claimedAt)
if (Number.isNaN(date.getTime())) return "-"
const getShanghaiDateKey = (d: Date) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(d)
const todayKey = getShanghaiDateKey(new Date())
const claimKey = getShanghaiDateKey(date)
if (claimKey === todayKey) {
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(date)
}
return formatDateTime(date)
}
const loadDetail = useCallback(async () => {
try {
const data = await services.redEnvelope.getDetail(id)
console.log('Red envelope data:', data.red_envelope)
setDetail(data)
if (data.user_claimed) {
setClaimedAmount(data.user_claimed.amount)
setState("claimed")
} else if (data.red_envelope.status !== "active") {
setState("opened")
} else {
setState("ready")
}
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败")
setState("error")
}
}, [id])
// 安全验证图片URL (防止XSS)
const sanitizeImageUrl = (url: string | null | undefined): string | undefined => {
if (!url) return undefined
// 允许相对路径且以 /f/ 开头
if (!url.startsWith('/f/')) {
console.warn('Invalid image URL detected:', url)
return undefined
}
// 防止路径遍历
if (url.includes('..') || url.includes('//')) {
console.warn('Path traversal detected in URL:', url)
return undefined
}
return url
}
// 从后端获取封面图片URL并进行安全验证
const coverImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id))
const heterotypicImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.heterotypic_upload_id))
useEffect(() => {
loadDetail()
}, [loadDetail])
const handleOpen = async () => {
setState("opening")
try {
const result = await services.redEnvelope.claim({ id })
setClaimedAmount(result.amount)
// Reload the full details to get updated claims list
const updatedDetail = await services.redEnvelope.getDetail(id)
setDetail(updatedDetail)
setTimeout(() => setState("claimed"), 1500)
} catch (err) {
toast.error(err instanceof Error ? err.message : "领取失败")
setState("ready")
}
}
if (state === "loading") {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }}>
<Gift className="h-12 w-12 text-red-500" />
</motion.div>
</div>
</div>
)
}
if (state === "error") {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<Gift className="h-8 w-8 text-red-500" />
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
红包加载失败
</h1>
<p className="text-muted-foreground text-sm">
{error}
</p>
</div>
</motion.div>
</div>
</div>
)
}
const envelope = detail?.red_envelope
const claimedCount = detail?.claims?.length ?? 0
const totalCount = envelope?.total_count ?? claimedCount
const totalAmount = parseFloat(envelope?.total_amount || "0").toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
return (
<div className="relative h-screen w-full flex flex-col items-center justify-center bg-background p-2 sm:p-4 overflow-hidden">
<div className="relative z-10 w-full h-full flex items-center justify-center">
{/* 装饰图片容器 */}
<div className="relative w-full max-w-[320px] aspect-[3/5] h-auto flex items-center justify-center">
{/* 异形装饰 */}
{heterotypicImage && (
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center scale-[1.2]"
aria-hidden="true"
>
<Image
src={heterotypicImage}
alt="红包装饰"
fill
className="object-cover object-center"
unoptimized
loading="eager"
/>
</motion.div>
)}
{/* 统一卡片容器 */}
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut", delay: 0.1 }}
className="relative w-full h-full mx-auto rounded-xl shadow-2xl overflow-hidden z-10"
>
<AnimatePresence mode="wait">
{(state === "ready" || state === "opening") && (
<RedEnvelopeCard
key="cover"
status={state}
coverImage={coverImage}
greeting={envelope?.greeting}
sender={{
username: envelope?.creator_username,
avatar_url: envelope?.creator_avatar_url
}}
onOpen={handleOpen}
/>
)}
{(state === "claimed" || state === "opened") && (
<motion.div
key="result"
initial={{ y: 30, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
className="absolute inset-0 bg-background flex flex-col"
>
{/* 顶部区域 */}
<div className="relative shrink-0">
<div
className="h-26 w-full bg-[#F25542] relative z-0"
style={{
borderRadius: '0 0 100% 100% / 0 0 80% 80%'
}}
>
{coverImage && (
<div
className="absolute inset-0 overflow-hidden"
style={{ borderRadius: '0 0 100% 100% / 0 0 80% 80%' }}
>
<Image
src={coverImage}
alt="cover"
fill
className="object-cover opacity-50"
style={{ transform: "scale(1.1)", transformOrigin: "center" }}
unoptimized
/>
</div>
)}
</div>
</div>
{/* 个人信息 */}
<div className="flex flex-col items-center justify-center mt-4 shrink-0">
<div className="flex items-center gap-2 mb-2">
<Avatar className="h-6 w-6 rounded-md border border-white/20">
<AvatarImage className="rounded-md" src={envelope?.creator_avatar_url} alt={envelope?.creator_username} />
<AvatarFallback className="bg-[#E75240] text-white text-[10px] rounded-md">
{envelope?.creator_username?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1 min-w-0 flex-1">
<span className="text-sm font-medium text-foreground/80 truncate">
{truncateText(envelope?.creator_username, 12)}
</span>
<span className="text-sm font-medium text-foreground/80 whitespace-nowrap">发出的红包</span>
</div>
{envelope?.type === 'random' && (
<span className="bg-[#E1B876] text-white text-[10px] px-1 rounded">拼</span>
)}
</div>
<p className="text-muted-foreground text-xs">{envelope?.greeting || "新年快乐,恭喜发财"}</p>
</div>
{/* 金额区域 */}
<div className="bg-background pt-3 pb-4 px-4 text-center shrink-0">
{claimedAmount ? (
<div>
<div className="text-center">
<div className="relative inline-block text-[#CFB073] leading-none">
<span className="text-5xl font-bold">{parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
<span className="absolute left-full ml-1 bottom-1 text-sm font-medium">LDC</span>
</div>
</div>
</div>
) : (
<div className="py-2">
<p className="text-2xl font-medium text-foreground/80">手慢了,红包派完了</p>
</div>
)}
</div>
{/* 列表区域 */}
<div className="flex-1 bg-background flex flex-col min-h-0">
<div className="px-4 py-1 bg-background text-xs text-muted-foreground flex items-center justify-between">
<div className="whitespace-nowrap">
共 {totalAmount} LDC
</div>
<div className="flex items-baseline gap-1">
<span className="font-semibold tabular-nums">{claimedCount}</span>
<span>/</span>
<span className="font-semibold tabular-nums">{totalCount}</span>
<span>个</span>
</div>
</div>
<div className="border-t border-border/30" />
<div className="flex-1 bg-background overflow-y-auto overscroll-contain">
<div className="divide-y divide-border/30">
{detail?.claims.map((claim: RedEnvelopeClaim) => (
<div
key={claim.id}
className="flex items-start justify-between px-4 py-2 gap-3"
>
<div className="flex gap-3 min-w-0 flex-1">
<Avatar className="h-9 w-9 rounded-md border border-border/10 mt-0.5">
<AvatarImage className="rounded-md" src={claim.avatar_url} alt={claim.username} />
<AvatarFallback className="text-xs bg-muted rounded-md">
{claim.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-0.5 min-w-0">
<span className="text-[13px] font-medium text-foreground/90 truncate max-w-[160px] sm:max-w-[200px]">
{truncateText(claim.username, 12)}
</span>
<span className="text-[11px] text-muted-foreground">
{formatClaimedAt(claim.claimed_at)}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0 min-w-[96px] text-right">
<span className="text-[13px] font-semibold text-foreground tabular-nums whitespace-nowrap">
{parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC
</span>
{/* 最佳手气 */}
{envelope?.type === 'random' && bestClaimId === claim.id && (
<div className="flex items-center gap-1 text-[#E1B876] text-[9px]">
<span>手气最佳</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
)
}
@@ -1,59 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { AlertTriangle } from "lucide-react"
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
const RISK_BLOCKED_EVENT = "credit-risk-blocked"
function isRiskInfo(value: unknown): value is RiskInfo {
if (!value || typeof value !== "object") return false
const riskInfo = value as Partial<RiskInfo>
return typeof riskInfo.risk_level === "string" && Array.isArray(riskInfo.risk_labels) && Array.isArray(riskInfo.risks)
}
export function RiskBlockDialog() {
const [riskInfo, setRiskInfo] = useState<RiskInfo | null>(null)
useEffect(() => {
const handleRiskBlocked = (event: Event) => {
const detail = (event as CustomEvent<unknown>).detail
if (!isRiskInfo(detail)) return
setRiskInfo(detail)
}
window.addEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
return () => window.removeEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
}, [])
return (
<Dialog open={!!riskInfo}>
<DialogContent
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
className="sm:max-w-md"
>
<DialogHeader className="items-center text-center sm:text-center">
<div className="mb-1 flex size-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<DialogTitle className="text-center text-lg">账号存在风险</DialogTitle>
<DialogDescription className="text-center text-sm leading-6">
因触发风控,账号暂时无法使用需登录的功能
</DialogDescription>
</DialogHeader>
<RiskInfoBox riskInfo={riskInfo} />
</DialogContent>
</Dialog>
)
}
@@ -1,56 +0,0 @@
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
export interface RiskItem {
label: string
value?: string
desc?: string
}
export interface RiskInfo {
risk_level: string
risk_labels: string[]
risks: RiskItem[]
}
export function RiskInfoBox({
riskInfo,
label = "风险等级",
labelClassName,
}: {
riskInfo: RiskInfo | null
label?: string
labelClassName?: string
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("text-sm font-medium text-muted-foreground", labelClassName)}>{label}</span>
<Badge variant="destructive">{riskInfo?.risk_level || "未知"}</Badge>
</div>
<div className="mt-4 space-y-2">
{riskInfo?.risks.length ? (
riskInfo.risks.map(risk => (
<div key={risk.value || risk.label} className="rounded-md border bg-muted/35 px-3 py-2.5">
<span className="text-sm font-medium leading-5 text-foreground">{risk.label}</span>
{risk.desc ? (
<p className="mt-1 text-xs leading-5 text-muted-foreground">{risk.desc}</p>
) : null}
</div>
))
) : riskInfo?.risk_labels.length ? (
<div className="flex flex-wrap gap-2">
{riskInfo.risk_labels.map(label => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">暂无风险标签详情</span>
)}
</div>
</div>
)
}
@@ -1,39 +0,0 @@
import { toast } from "sonner"
import { X } from "lucide-react"
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
const RISK_TOAST_ID = "credit-risk-warning"
let isRiskWarningDismissed = false
function dismissRiskWarningToast(id: string | number) {
isRiskWarningDismissed = true
toast.dismiss(id)
}
export function showRiskWarningToast(riskInfo: RiskInfo) {
if (isRiskWarningDismissed) return
toast.custom(
(id) => (
<div className="relative w-[min(calc(100vw-2rem),28rem)]">
<button
type="button"
aria-label="关闭风险提示"
className="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => dismissRiskWarningToast(id)}
>
<X className="size-4" />
</button>
<RiskInfoBox riskInfo={riskInfo} label="账号存在风险提示" labelClassName="font-semibold text-foreground" />
</div>
),
{
id: RISK_TOAST_ID,
duration: Infinity,
dismissible: true,
onDismiss: () => {
isRiskWarningDismissed = true
},
},
)
}
+30 -371
View File
@@ -4,247 +4,12 @@ import * as React from "react"
import Link from "next/link"
import { motion, useAnimation } from "motion/react"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Info } from "lucide-react"
import { cn } from "@/lib/utils"
import { PayLevel, User } from "@/lib/services/auth"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import type { UserPayConfig } from "@/lib/services/admin"
/**
* 等级配置
*/
interface LevelConfig {
level: PayLevel
name: string
nameEn: string
minScore: number
maxScore: number | null
gradient: string
textColor: string
metalEffect?: boolean
dailyLimit: number | null
feeRate: number | string
scoreRate: number | string
}
/** 等级视觉样式配置(不包含业务数据) */
const LEVEL_STYLES = [
{
level: PayLevel.Ordinary,
name: "普通会员",
nameEn: "ORDINARY MEMBER",
gradient: "from-[#1e3a8a] via-[#2563eb] to-[#1d4ed8] shadow-lg shadow-blue-900/10",
textColor: "text-white/95",
},
{
level: PayLevel.Gold,
name: "黄金会员",
nameEn: "GOLD MEMBER",
gradient: "from-[#FDFCF0] via-[#FCE38A] to-[#B45309] shadow-lg shadow-amber-500/10",
textColor: "text-[#854D0E]",
metalEffect: true,
},
{
level: PayLevel.WhiteGold,
name: "白金会员",
nameEn: "PLATINUM MEMBER",
gradient: "from-[#F8FAFC] via-[#E2E8F0] to-[#94A3B8] shadow-lg shadow-slate-500/10",
textColor: "text-slate-800",
metalEffect: true,
},
{
level: PayLevel.BlackGold,
name: "黑金会员",
nameEn: "BLACK MEMBER",
gradient: "from-[#020617] via-[#1e293b] to-[#020617] shadow-lg shadow-black/20",
textColor: "text-[#FFE5B4]",
metalEffect: true,
},
] as const
/** 合并 API 数据和视觉样式 */
function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] {
return LEVEL_STYLES.map(style => {
const apiConfig = apiConfigs.find(c => c.level === style.level)
if (apiConfig) {
return {
...style,
minScore: apiConfig.min_score,
maxScore: apiConfig.max_score,
dailyLimit: apiConfig.daily_limit,
feeRate: apiConfig.fee_rate,
scoreRate: apiConfig.score_rate,
}
}
return {
...style,
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
})
}
function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig {
if (configs.length === 0) {
return {
level: PayLevel.Ordinary,
name: "加载中...",
nameEn: "LOADING...",
gradient: "from-blue-600 via-blue-500 to-blue-700",
textColor: "text-white",
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
}
for (let i = configs.length - 1; i >= 0; i--) {
if (score >= configs[i].minScore) {
return configs[i]
}
}
return configs[0]
}
function MembershipCard({
config,
user,
score,
currentLevel,
isActive,
levelConfigs,
}: {
config: LevelConfig
user: User
score: number
currentLevel: LevelConfig
isActive: boolean
levelConfigs: LevelConfig[]
}) {
const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level)
const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Ordinary
return (
<div
className={cn(
"transition-all duration-500",
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
)}
>
<div
className={cn(
"relative w-full aspect-[2/1] rounded-xl overflow-hidden bg-gradient-to-br shadow-lg",
config.gradient,
config.metalEffect && "shadow-2xl"
)}
>
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
</div>
<div className="absolute inset-0 opacity-[0.15] bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{config.metalEffect && (
<>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 via-white/2 to-transparent" />
<div className="absolute inset-0 rounded-xl ring-1 ring-inset ring-white/25" />
<div className="absolute top-0 left-0 w-full h-1/4 bg-gradient-to-b from-white/15 via-transparent to-transparent rounded-t-xl" />
</>
)}
<div className={cn("relative h-full flex flex-col justify-between p-3 sm:p-4 md:p-5", config.textColor)}>
<div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium opacity-70 tracking-wider">
{config.nameEn}
</div>
<div className="text-sm sm:text-base md:text-lg font-bold tracking-tight">
{config.name}
</div>
</div>
<div className="space-y-0.5 sm:space-y-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70 tracking-wide">平台分数</div>
<div className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight tabular-nums">
{score.toLocaleString()}
</div>
</div>
<div className="flex items-end justify-between gap-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">
LINUX DO <span className="italic font-serif">Credit</span>
</div>
<div className="text-right">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">分数范围</div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium tabular-nums">
{config.minScore < 0 ? "0.00" : config.minScore.toLocaleString()} - {config.maxScore?.toLocaleString() || "∞"}
</div>
</div>
</div>
</div>
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 md:top-5 md:right-5">
{isAccessible ? (
<Avatar className="size-8 sm:size-9 md:size-10 border-2 border-white/20">
<AvatarImage src={user?.avatar_url} alt={user?.nickname} />
<AvatarFallback
className={cn(
"text-xs sm:text-sm font-semibold",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
{user?.nickname?.slice(0, 2)}
</AvatarFallback>
</Avatar>
) : (
<div
className={cn(
"px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-[8px] sm:text-[9px] md:text-[10px] font-semibold backdrop-blur-sm",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
未获得
</div>
)}
</div>
</div>
</div>
)
}
function getPayLevelLabel(level: PayLevel): string {
switch (level) {
case PayLevel.BlackGold:
return "黑金会员"
case PayLevel.WhiteGold:
return "白金会员"
case PayLevel.Gold:
return "黄金会员"
case PayLevel.Ordinary:
return "普通会员"
default:
return "未知等级"
}
}
import { Shield } from "lucide-react"
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
const [levelConfigs, setLevelConfigs] = React.useState<LevelConfig[]>([])
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
@@ -262,42 +27,6 @@ export function ProfileMain() {
}, 650)
}
const score = user?.pay_score ?? 0
const currentLevel = getLevelConfig(score, levelConfigs)
React.useEffect(() => {
async function fetchPayConfigs() {
const configs = await services.config.getUserPayConfigs()
setLevelConfigs(mergeLevelConfigs(configs))
}
fetchPayConfigs()
}, [])
React.useEffect(() => {
if (!api) return
setCurrent(api.selectedScrollSnap())
api.on("select", () => setCurrent(api.selectedScrollSnap()))
}, [api])
React.useEffect(() => {
if (!api || levelConfigs.length === 0 || !currentLevel) return
const scrollToCurrentLevel = () => {
const currentLevelIndex = levelConfigs.findIndex(
(config) => config.level === currentLevel.level
)
if (currentLevelIndex >= 0) {
api.scrollTo(currentLevelIndex, true)
}
}
scrollToCurrentLevel()
api.on("reInit", scrollToCurrentLevel)
return () => {
api.off("reInit", scrollToCurrentLevel)
}
}, [api, currentLevel, levelConfigs])
if (loading) {
return (
<div className="py-6 space-y-4">
@@ -317,7 +46,7 @@ export function ProfileMain() {
}
return (
<div className="py-6 space-y-6">
<div className="py-6 space-y-6 max-w-4xl mx-auto">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
@@ -334,130 +63,60 @@ export function ProfileMain() {
</Breadcrumb>
</div>
<h2 className="font-medium text-sm text-muted-foreground">会员等级</h2>
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4">
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
</div>
<Carousel
setApi={setApi}
opts={{
align: "center",
loop: false,
containScroll: false,
}}
className="w-full relative"
>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<CarouselContent className="-ml-4">
{levelConfigs.map((config, index) => (
<CarouselItem
key={config.level}
className="pl-4 basis-[85%] sm:basis-[70%] md:basis-[65%] lg:basis-[50%] xl:basis-[40%] 2xl:basis-[35%] cursor-pointer"
onClick={() => api?.scrollTo(index)}
>
<MembershipCard
config={config}
user={user}
score={score}
currentLevel={currentLevel}
isActive={index === current}
levelConfigs={levelConfigs}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0" />
<CarouselNext className="right-0" />
</Carousel>
<div className="space-y-4">
<h2 className="font-medium text-sm text-muted-foreground">基本信息</h2>
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
<motion.div
animate={controls}
onClick={handleAvatarClick}
className="cursor-pointer origin-center"
className="cursor-pointer origin-center shrink-0"
whileHover={{ scale: 1.05 }}
>
<Avatar className="size-14 sm:size-16">
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
<AvatarFallback className="text-lg sm:text-xl">
{(user.nickname || user.username).slice(0, 2)}
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="flex-1 w-full">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<div className="flex-1 w-full space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">账户</div>
<div className="text-sm font-medium">@{user.username}</div>
<div className="text-sm font-semibold">@{user.username}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">昵称</div>
<div className="text-sm font-medium">{user.nickname || '未设置'}</div>
<div className="text-sm font-semibold">{user.nickname || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">用户ID</div>
<div className="text-sm font-medium">{user.id}</div>
<div className="text-xs text-muted-foreground">用户ID (UID)</div>
<div className="text-sm font-mono font-semibold">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">信任等级</div>
<div className="text-sm font-medium">{getTrustLevelLabel(user.trust_level)}</div>
<div className="text-sm font-semibold">{getTrustLevelLabel(user.trust_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台等级</div>
<div className="text-sm font-medium">{getPayLevelLabel(user.pay_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数</div>
<div className="text-sm font-medium">{user.pay_score.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">每日流转限额</div>
<div className="text-sm font-medium">
{currentLevel.dailyLimit ? `${ currentLevel.dailyLimit.toLocaleString() } 积分` : '无限额'}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">集市手续费率</div>
<div className="text-sm font-medium">
{typeof currentLevel.feeRate === 'number'
? `${ (currentLevel.feeRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.feeRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数转化率</div>
<div className="text-sm font-medium">
{typeof currentLevel.scoreRate === 'number'
? `${ (currentLevel.scoreRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
积分基准值
<Popover>
<PopoverTrigger>
<Info className="h-3 w-3 cursor-help" />
</PopoverTrigger>
<PopoverContent side="top" className="w-auto max-w-[280px] p-3">
<p className="text-xs">上一次从社区同步的积分基准值</p>
</PopoverContent>
</Popover>
</div>
<div className="text-sm font-medium tabular-nums">
{parseFloat(user.community_balance).toLocaleString()}
<div className="text-xs text-muted-foreground">管理员身份</div>
<div className="text-sm font-semibold flex items-center gap-1">
{user.is_admin ? (
<span className="text-rose-600 flex items-center gap-1">
<Shield className="size-3.5" />
是
</span>
) : (
<span>否</span>
)}
</div>
</div>
</div>
@@ -2,83 +2,11 @@
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion, AnimatePresence } from "motion/react"
import { ShieldCheck } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Spinner } from "@/components/ui/spinner"
import { UserService } from "@/lib/services/user"
export function SecurityMain() {
const router = useRouter()
const [setupStep, setSetupStep] = React.useState<'password' | 'confirm'>('password')
const [payKey, setPayKey] = React.useState("")
const [confirmPayKey, setConfirmPayKey] = React.useState("")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setConfirmPayKey(numericValue)
}
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmitting(true)
try {
await UserService.updatePayKey(payKey)
toast.success("修改成功", {
description: "您的安全密码已成功更新",
})
// 立即退出
router.push('/settings')
} catch (error) {
toast.error("修改失败", {
description: error instanceof Error ? error.message : "更新密码时发生错误,请稍后重试",
})
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmitting(false)
}
}
}
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
@@ -100,111 +28,11 @@ export function SecurityMain() {
<div className="space-y-6">
<div className="font-medium text-sm text-muted-foreground flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
密码安全
安全设置
</div>
<div className="mt-8 flex justify-center">
<motion.div
className="w-full max-w-sm space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<AnimatePresence mode="wait">
<motion.div
key={setupStep}
initial={{ opacity: 0, x: setupStep === 'confirm' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-xl font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置新密码' : '确认新密码'}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请输入新的6位数字安全密码'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 text-lg border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 sm:mx-8">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-10 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className={`flex-1 h-10 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95 ${setupStep === 'password' ? 'w-full' : ''}`}
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmitting || !isConfirmValid
}
>
{isSubmitting && <Spinner className="mr-2" />}
{setupStep === 'password' ? '继续' : '保存修改'}
</Button>
</div>
</form>
</motion.div>
</AnimatePresence>
</motion.div>
<div className="text-sm text-muted-foreground">
当前暂无安全配置选项
</div>
</div>
</div>
@@ -1,85 +0,0 @@
import * as React from "react"
import { TrendingDown, TrendingUp, Users } from "lucide-react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { useUser } from "@/contexts/user-context"
/**
* 安全转换数字,避免 NaN
*/
function safeNumber(value: number): number {
return typeof value === 'number' && !isNaN(value) ? value : 0
}
/**
* 所有活动组件
* 显示用户的所有活动统计数据
*/
export function AllActivity() {
const { user, loading } = useUser()
/* 统计数据 */
const stats = React.useMemo(() => ({
totalReceive: parseFloat(user?.total_receive || '0'),
totalPayment: parseFloat(user?.total_payment || '0'),
totalCommunity: parseFloat(user?.total_community || '0')
}), [user])
/* 功能卡片配置 */
const statCards = React.useMemo(() => [
{
title: "总收益",
value: stats.totalReceive,
icon: TrendingUp,
color: "text-green-600",
bgColor: "bg-green-100 dark:bg-green-900/20"
},
{
title: "总消耗",
value: stats.totalPayment,
icon: TrendingDown,
color: "text-red-600",
bgColor: "bg-red-100 dark:bg-red-900/20"
},
{
title: "总划转",
value: stats.totalCommunity,
icon: Users,
color: "text-purple-600",
bgColor: "bg-purple-100 dark:bg-purple-900/20"
}
], [stats])
return (
<div className="space-y-4">
<h2 className="font-semibold">数据概览</h2>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{statCards.map((card) => {
const IconComponent = card.icon
return (
<div key={card.title} className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">
{card.title}
</div>
<div className={`p-1.5 rounded-full ${ card.bgColor }`}>
<IconComponent className={`h-3 w-3 ${ card.color }`} />
</div>
</div>
<div className="text-xl md:text-2xl font-bold mt-2">
{loading ? '-' : (
<CountingNumber
number={safeNumber(card.value)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -1,25 +0,0 @@
import * as React from "react"
/**
* 社区积分划转组件
*
* 显示社区积分划转规则
*/
export function Community() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-4">
<div className="max-w-2xl">
<h2 className="font-semibold mb-3">LINUX DO 社区积分划转规则</h2>
<div className="text-muted-foreground space-y-2">
<ul className="list-disc list-inside space-y-1 text-sm">
<li>社区积分源于用户在社区内的活动,如解决问题,获赞等。</li>
<li>社区积分无法在每日划转前进行交易、赠予给其他社区用户。</li>
<li>每日划转时间为每日凌晨00:00:00,目前划转无需任何服务手续费。</li>
</ul>
</div>
</div>
</div>
</div>
)
}
@@ -1,27 +0,0 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
/**
* 积分流转服务组件
*
* 显示积分流转服务功能和管理入口
*/
export function Online() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-8">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">在线积分流转</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
创建和管理您的在线积分流转服务,无需编写代码即可快速开启个人服务。
</p>
<Link href="/merchant/online-paying">
<Button className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm">
前往创建
</Button>
</Link>
</div>
</div>
</div>
)
}
@@ -1,24 +0,0 @@
import * as React from "react"
/**
* 积分消耗组件
*
* 显示积分消耗功能和规则
*/
export function Payment() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-4">
<div className="max-w-2xl">
<h2 className="font-semibold mb-3">积分消耗</h2>
<div className="text-muted-foreground space-y-2">
<ul className="list-disc list-inside space-y-1 text-sm">
<li>安全可靠的认证流程,确保您的账户安全。</li>
<li>实时积分消耗状态跟踪,随时了解消耗进展。</li>
</ul>
</div>
</div>
</div>
</div>
)
}
@@ -1,94 +0,0 @@
"use client"
import * as React from "react"
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
/**
* 功能卡片组件
*/
function FeatureCard({ title, description, linkText, href }: { title: string, description: string, linkText: string, href?: string }) {
return (
<Card className="bg-background border border-border shadow-none transition-shadow">
<CardHeader>
<CardTitle className="text-lg font-semibold -mb-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-xs text-muted-foreground leading-relaxed">
{description}
</CardDescription>
{href ? (
<Link href={href}>
<Button variant="link" className="px-0 h-auto text-xs text-blue-600 font-normal hover:text-blue-700">
{linkText}
</Button>
</Link>
) : (
<Button variant="link" className="px-0 h-auto text-xs text-muted-foreground font-normal cursor-not-allowed" disabled>
{linkText}
</Button>
)}
</CardContent>
</Card>
)
}
/**
* 积分收益组件
*
* 显示积分收益功能和规则
*/
export function Receive() {
const [isVisible, setIsVisible] = useState(true)
const [isHiding, setIsHiding] = useState(false)
const handleHide = () => {
setIsHiding(true)
setTimeout(() => setIsVisible(false), 300)
}
return (
<div className="space-y-4">
{isVisible && (
<div
className={`bg-muted/50 rounded-lg px-6 py-8 overflow-hidden transition-all duration-300 ease-out ${isHiding ? 'opacity-0 max-h-0 !py-0 !mb-0' : 'opacity-100 max-h-[500px]'}`}
>
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">获得积分收益</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。
</p>
<Button
className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm"
onClick={handleHide}
>
开始使用
</Button>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<FeatureCard
title="接入官方 API 接口"
description="使用官方 API 接口(兼容易支付),快速接入积分服务,支持异步通知和同步跳转。"
linkText="查看文档"
href="/docs/api"
/>
<FeatureCard
title="创建在线积分服务"
description="通过我们提供的在线积分服务页面,快速创建,无需编写代码即可开始进行积分流转。"
linkText="前往集市"
href="/merchant"
/>
<FeatureCard
title="面对面服务"
description="通过 LINUX DO Credit 提供的面对面服务功能,扩展您的积分收益渠道,处理面对面服务场景。"
linkText="功能开发中,敬请期待"
/>
</div>
</div>
)
}
@@ -1,767 +0,0 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import Image from "next/image"
import { toast } from "sonner"
import { Gift, Copy, Check, ExternalLink, Pencil, X, ImagePlus, History } from "lucide-react"
import type { UploadImageResponse } from "@/lib/services"
import { getFileUrl } from "@/lib/services/upload/upload.service"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PasswordDialog } from "@/components/common/general/password-dialog"
import { ListData } from "@/components/common/general/list-data"
import { ImageCropper } from "@/components/common/redenvelope/image-cropper"
import { motion } from "motion/react"
import { RedEnvelopeCard } from "@/components/common/redenvelope/red-envelope-card"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import type { RedEnvelopeType, CreateRedEnvelopeRequest, PublicConfigResponse, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services"
interface RedEnvelopeCover {
coverImageUrl: string | null
heterotypicImageUrl: string | null
coverUploadId: string | null
heterotypicUploadId: string | null
}
/**
* 红包组件
*
* 提供红包发送功能,包括 Banner 和创建对话框
*/
export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) {
const { user } = useUser()
const [isFormOpen, setIsFormOpen] = useState(false)
const [isPasswordOpen, setIsPasswordOpen] = useState(false)
const [isResultOpen, setIsResultOpen] = useState(false)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [isCropperOpen, setIsCropperOpen] = useState(false)
const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover')
const [showCustomization, setShowCustomization] = useState(false)
const [historyCoverCovers, setHistoryCoverCovers] = useState<UploadImageResponse[]>([])
const [historyHeterotypicCovers, setHistoryHeterotypicCovers] = useState<UploadImageResponse[]>([])
/* 表单状态 */
const [type, setType] = useState<RedEnvelopeType>("random")
const [totalAmount, setTotalAmount] = useState("")
const [totalCount, setTotalCount] = useState("")
const [greeting, setGreeting] = useState("")
const [loading, setLoading] = useState(false)
/* 封面状态 */
const [cover, setCover] = useState<RedEnvelopeCover>({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
/* 结果状态 */
const [resultLink, setResultLink] = useState("")
/* 配置状态 */
const [config, setConfig] = useState<PublicConfigResponse | null>(null)
/* 加载配置 */
const loadConfig = async () => {
try {
const result = await services.config.getPublicConfig()
setConfig(result)
} catch {
toast.error('加载配置失败')
}
}
/* 初始加载 */
useEffect(() => {
loadConfig()
}, [])
/* 验证积分格式 */
const validateAmount = (value: string): boolean => {
const regex = /^\d+(\.\d{1,2})?$/
return regex.test(value) && parseFloat(value) > 0
}
/* 处理表单提交 */
const handleFormSubmit = () => {
if (!totalAmount.trim()) {
toast.error("请设置积分总数")
return
}
if (!validateAmount(totalAmount)) {
toast.error("积分格式不正确,必须大于1且最多2位小数")
return
}
if (!totalCount.trim()) {
toast.error("请设置可领取人数")
return
}
const count = parseInt(totalCount)
if (isNaN(count) || count <= 0) {
toast.error("可领取人数必须大于0")
return
}
const amount = parseFloat(totalAmount)
if (amount < 1) {
toast.error("红包总金额不能低于1 LDC")
return
}
if (config && parseFloat(config.red_envelope_max_amount) > 0) {
if (amount > parseFloat(config.red_envelope_max_amount)) {
toast.error(`红包的积分总数不能超过 ${ config.red_envelope_max_amount } LDC`)
return
}
}
if (count > 10000) {
toast.error("可领取人数不能超过 10000 个")
return
}
if (amount / count < 0.01) {
toast.error("单个红包不可低于 0.01 LDC")
return
}
if (greeting.length > 100) {
toast.error("祝福语最多100字符")
return
}
setIsPasswordOpen(true)
}
/* 提交创建 */
const handleConfirmCreate = async (password: string) => {
setLoading(true)
try {
const data: CreateRedEnvelopeRequest = {
type,
total_amount: parseFloat(totalAmount),
total_count: parseInt(totalCount),
greeting: greeting || "恭喜发财,大吉大利",
pay_key: password,
cover_upload_id: cover.coverUploadId || undefined,
heterotypic_upload_id: cover.heterotypicUploadId || undefined,
}
const result = await services.redEnvelope.create(data)
const link = getEnvelopeLink(result.id)
setResultLink(link)
setIsPasswordOpen(false)
setIsFormOpen(false)
setIsResultOpen(true)
onSuccess?.()
/* 重置表单 */
setType("random")
setTotalAmount("")
setTotalCount("")
setGreeting("")
setCover({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
setShowCustomization(false)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '创建失败'
toast.error('创建红包失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
/* 复制链接 */
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 feeInfo = React.useMemo(() => {
if (!totalAmount || !config) return null
const amount = parseFloat(totalAmount)
if (isNaN(amount)) return null
const feeRate = parseFloat(config.red_envelope_fee_rate)
const fee = (amount * feeRate).toFixed(2)
const total = (amount + parseFloat(fee)).toFixed(2)
return { fee, total, rate: feeRate }
}, [totalAmount, config])
/* 生成红包链接 */
const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${ window.location.origin }/redenvelope/${ id }`
}
return `/redenvelope/${ id }`
}
/* 处理封面裁剪完成 */
const handleCropComplete = async (croppedImage: string, coverType: 'cover' | 'heterotypic') => {
const uploadingToast = toast.loading(`正在上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }...`)
try {
const result = await services.upload.uploadBase64Image(
croppedImage,
coverType,
`${ coverType }-${ Date.now() }.png`
)
setCover(prev => ({
...prev,
[coverType === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(result.id),
[coverType === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: result.id
}))
toast.dismiss(uploadingToast)
toast.success(`${ coverType === 'cover' ? '背景封面' : '装饰图片' }已设置`)
} catch (error) {
toast.dismiss(uploadingToast)
const errorMessage = error instanceof Error ? error.message : '上传失败'
toast.error(`上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }失败`, {
description: errorMessage
})
}
}
/* 加载历史封面 */
const loadHistoryCovers = async () => {
try {
const [covers, heterotypics] = await Promise.all([
services.redEnvelope.listCovers('cover'),
services.redEnvelope.listCovers('heterotypic'),
])
setHistoryCoverCovers(covers || [])
setHistoryHeterotypicCovers(heterotypics || [])
} catch {
// 静默失败,不影响主功能
}
}
/* 选择历史封面 */
const handleSelectHistoryCover = (item: UploadImageResponse, type: 'cover' | 'heterotypic') => {
// 检查是否已经选中当前项
const currentId = type === 'cover' ? cover.coverUploadId : cover.heterotypicUploadId
if (currentId === item.id) {
// 取消选择
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
}))
toast.success(`已取消选择${ type === 'cover' ? '背景封面' : '装饰图片' }`)
return
}
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(item.id),
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: item.id
}))
toast.success(`已选择历史${ type === 'cover' ? '背景封面' : '装饰图片' }`)
}
/* 打开裁剪对话框 */
const handleOpenCropper = (type: 'cover' | 'heterotypic') => {
setCropperType(type)
setIsCropperOpen(true)
}
/* 移除封面 */
const handleRemoveCover = (type: 'cover' | 'heterotypic') => {
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
}))
toast.success(`${ type === 'cover' ? '背景封面' : '装饰图片' }已移除`)
}
/* 处理图片加载错误 */
const handleImageError = (id: string, type: 'cover' | 'heterotypic') => {
if (type === 'cover') {
setHistoryCoverCovers(prev => prev.filter(item => item.id !== id))
} else {
setHistoryHeterotypicCovers(prev => prev.filter(item => item.id !== id))
}
}
return (
<div className="space-y-6">
<div className="bg-gradient-to-r from-red-500/10 to-orange-500/10 rounded-lg px-6 py-8">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">
发红包
</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
创建积分红包,支持固定金额和拼手气两种模式。
</p>
<Button
onClick={() => setIsFormOpen(true)}
className="bg-red-500 hover:bg-red-600 font-medium px-6 rounded-md shadow-sm"
>
发红包
</Button>
</div>
</div>
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between pr-8">
<div className="flex items-center gap-2">
<Gift className="size-5 text-red-500" />
发红包
</div>
<Button
variant={showCustomization ? "default" : "ghost"}
size="sm"
onClick={() => setShowCustomization(!showCustomization)}
className={`flex items-center gap-1.5 text-xs transition-colors ${ showCustomization ? "bg-red-500 hover:bg-red-600 text-white" : "text-muted-foreground hover:text-foreground" }`}
>
<Pencil className="size-3.5" />
<span>自定义封面</span>
</Button>
</DialogTitle>
<DialogDescription>
创建积分红包,支持固定金额和拼手气两种模式{showCustomization && ",可添加个性化封面"}。
</DialogDescription>
</DialogHeader>
<div className="grid md:grid-cols-5 gap-8 pt-2">
<div className="space-y-4 md:col-span-3">
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label>红包类型 <span className="text-red-500">*</span></Label>
<Select value={type} onValueChange={(v) => setType(v as RedEnvelopeType)}>
<SelectTrigger className="text-xs w-full shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="random" className="text-xs">拼手气红包</SelectItem>
<SelectItem value="fixed" className="text-xs">普通红包</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="totalAmount">积分总数 <span className="text-red-500">*</span></Label>
<Input
id="totalAmount"
type="number"
step="0.01"
min="0.01"
max={config && parseFloat(config.red_envelope_max_amount) > 0 ? config.red_envelope_max_amount : undefined}
placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount }` : "0.00"}
value={totalAmount}
onChange={(e) => setTotalAmount(e.target.value)}
className="font-mono shadow-none"
disabled={loading}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="totalCount">可领取人数 <span className="text-red-500">*</span></Label>
<Input
id="totalCount"
type="number"
placeholder="最多 10000"
value={totalCount}
onChange={(e) => setTotalCount(e.target.value)}
className="font-mono shadow-none"
disabled={loading}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="greeting">祝福语</Label>
<Textarea
id="greeting"
placeholder="恭喜发财,大吉大利"
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
maxLength={100}
rows={2}
className="resize-none shadow-none"
disabled={loading}
/>
</div>
{showCustomization && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="rounded-lg bg-muted/40 p-4"
onAnimationComplete={() => loadHistoryCovers()}
>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-medium">红包封面</Label>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setShowCustomization(false)
setCover({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
}}
className="size-6 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">背景封面</Label>
{cover.coverImageUrl ? (
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
<Image
src={cover.coverImageUrl}
alt="背景封面"
fill
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleOpenCropper('cover')}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleRemoveCover('cover')}
>
<X className="size-3" />
</Button>
</div>
</div>
) : (
<button
onClick={() => handleOpenCropper('cover')}
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<ImagePlus className="size-5 text-muted-foreground/50" />
<span className="text-[11px] text-muted-foreground">上传背景</span>
</button>
)}
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">装饰图片</Label>
{cover.heterotypicImageUrl ? (
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
<Image
src={cover.heterotypicImageUrl}
alt="装饰图片"
fill
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleOpenCropper('heterotypic')}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleRemoveCover('heterotypic')}
>
<X className="size-3" />
</Button>
</div>
</div>
) : (
<button
onClick={() => handleOpenCropper('heterotypic')}
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<ImagePlus className="size-5 text-muted-foreground/50" />
<span className="text-[11px] text-muted-foreground">上传装饰</span>
</button>
)}
</div>
</div>
<p className="text-[10px] text-muted-foreground/60 mt-2.5 text-center">推荐 2:3 比例,上传后可裁剪</p>
{(historyCoverCovers.length > 0 || historyHeterotypicCovers.length > 0) && (
<div className="mt-3 pt-3 border-t border-border/50">
<div className="flex items-center gap-1.5 mb-2.5">
<History className="size-3 text-muted-foreground/60" />
<span className="text-[11px] text-muted-foreground/60">历史封面</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
{historyCoverCovers.length > 0 && (
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
{historyCoverCovers.map((item: UploadImageResponse) => (
<button
key={item.id}
onClick={() => handleSelectHistoryCover(item, 'cover')}
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.coverUploadId === item.id
? 'ring-2 ring-primary scale-105'
: 'ring-border/50 hover:ring-foreground/25'
}`}
>
<Image
src={getFileUrl(item.id) || ''}
alt="背景"
fill
className="object-cover"
unoptimized
onError={() => handleImageError(item.id, 'cover')}
/>
</button>
))}
</div>
)}
</div>
<div>
{historyHeterotypicCovers.length > 0 && (
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
{historyHeterotypicCovers.map((item: UploadImageResponse) => (
<button
key={item.id}
onClick={() => handleSelectHistoryCover(item, 'heterotypic')}
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.heterotypicUploadId === item.id
? 'ring-2 ring-primary scale-105'
: 'ring-border/50 hover:ring-foreground/25'
}`}
>
<Image
src={getFileUrl(item.id) || ''}
alt="装饰"
fill
className="object-cover"
unoptimized
onError={() => handleImageError(item.id, 'heterotypic')}
/>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</motion.div>
)}
{feeInfo && parseFloat(feeInfo.fee) > 0 && (
<div className="text-xs space-y-1.5 p-3 rounded-lg bg-muted/50">
<div className="flex justify-between">
<span className="text-muted-foreground">积分</span>
<span className="font-mono text-muted-foreground">{totalAmount} LDC</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
费率 ({(feeInfo.rate * 100).toFixed(2)}%)
</span>
<span className="font-mono text-muted-foreground">+{feeInfo.fee} LDC</span>
</div>
<div className="border-t border-border/50 my-0.5" />
<div className="flex justify-between items-center">
<span className="text-foreground font-medium">总计</span>
<span className="font-mono font-semibold text-red-500">{feeInfo.total} LDC</span>
</div>
</div>
)}
</div>
<div className="md:col-span-2 flex items-center justify-center md:justify-end md:pr-8 my-6 md:my-0">
<div className="relative w-52 h-80">
{cover.heterotypicImageUrl && (
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center"
>
<Image
src={cover.heterotypicImageUrl}
alt="装饰"
fill
className="object-cover"
style={{
transform: 'scale(1.2)',
transformOrigin: 'center center'
}}
unoptimized
/>
</motion.div>
)}
{/* 缩放容器:将 320px 宽的卡片等比缩放以适应 208px (w-52) 的容器 */}
<div className="absolute inset-0 overflow-hidden bg-[#F25542] rounded-lg">
<div className="absolute top-0 left-0 origin-top-left" style={{ width: 320, height: 533, transform: 'scale(0.65)' }}>
<RedEnvelopeCard
status="preview"
coverImage={cover.coverImageUrl}
greeting={greeting || "新年快乐,恭喜发财"}
sender={{
username: user?.username,
avatar_url: user?.avatar_url
}}
className="rounded-xl w-full h-full"
/>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="ghost" onClick={() => setIsFormOpen(false)} disabled={loading} className="h-8 text-xs">
取消
</Button>
<Button
onClick={handleFormSubmit}
disabled={!totalAmount || !totalCount || loading || !!(config && config.red_envelope_max_recipients > 0 && parseInt(totalCount) > config.red_envelope_max_recipients)}
className="bg-red-500 hover:bg-red-600 h-8 text-xs"
>
下一步
</Button>
</div>
</DialogContent>
</Dialog>
<PasswordDialog
isOpen={isPasswordOpen}
onOpenChange={(open) => {
setIsPasswordOpen(open)
if (!open) setIsFormOpen(true)
}}
onConfirm={handleConfirmCreate}
loading={loading}
title="密码验证"
description={
feeInfo && parseFloat(feeInfo.fee) > 0
? `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个,服务费率 ${ feeInfo.fee } LDC,总计需要 ${ feeInfo.total } LDC`
: `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个`
}
/>
<Dialog open={isResultOpen} onOpenChange={setIsResultOpen}>
<DialogContent className="w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
创建成功
</DialogTitle>
<DialogDescription>
复制此链接分享给好友领取红包
</DialogDescription>
</DialogHeader>
<div className="py-2">
<div className="flex gap-2">
<Input
value={resultLink}
readOnly
className="text-xs font-mono shadow-none border-none bg-muted"
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleCopyLink(resultLink, "result")}
>
{copiedId === "result" ? <Check className="size-3" /> : <Copy className="size-3" />}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(resultLink, "_blank")}
>
<ExternalLink className="size-3" />
</Button>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => setIsResultOpen(false)} className="h-8 text-xs">
完成
</Button>
</div>
</DialogContent>
</Dialog>
<ImageCropper
isOpen={isCropperOpen}
onOpenChange={setIsCropperOpen}
onCropComplete={handleCropComplete}
coverType={cropperType}
/>
</div>
)
}
/**
* 红包列表组件
*
* 使用通用 ListData 组件展示红包记录
*/
export function RedEnvelopeList({ refreshTrigger }: { refreshTrigger?: number }) {
const fetchEnvelopes = async (params: { page: number, page_size: number, type: string }) => {
const result: RedEnvelopeListResponse = await services.redEnvelope.getList({
page: params.page,
page_size: params.page_size,
type: params.type as 'sent' | 'received',
})
return { list: result.red_envelopes, total: result.total }
}
return (
<ListData
fetchData={fetchEnvelopes}
tabs={[
{ value: 'sent', label: '我发出的', color: 'bg-primary/10 text-primary' },
{ value: 'received', label: '我收到的', color: 'bg-primary/10 text-primary' }
]}
defaultTab="received"
refreshTrigger={refreshTrigger}
/>
)
}
@@ -1,138 +0,0 @@
"use client"
import * as React from "react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AllActivity } from "@/components/common/trade/all-activity"
import { Community } from "@/components/common/trade/community"
import { Payment } from "@/components/common/trade/payment"
import { Receive } from "@/components/common/trade/receive"
import { TradeTable } from "@/components/common/trade/trade-table"
import { Online } from "@/components/common/trade/online"
import { RedEnvelope, RedEnvelopeList } from "@/components/common/trade/red-envelope"
import { TransactionProvider } from "@/contexts/transaction-context"
import services from "@/lib/services"
/** 标签触发器样式 */
const TAB_TRIGGER_STYLES =
"data-[state=active]:bg-transparent " +
"data-[state=active]:shadow-none " +
"data-[state=active]:border-0 " +
"data-[state=active]:border-b-2 " +
"data-[state=active]:border-indigo-500 " +
"bg-transparent " +
"rounded-none " +
"border-0 " +
"border-b-2 " +
"border-transparent " +
"px-0 " +
"text-sm " +
"font-bold " +
"text-muted-foreground " +
"data-[state=active]:text-indigo-500 " +
"-mb-[2px] " +
"relative " +
"hover:text-foreground " +
"transition-colors " +
"flex-none"
/** 标签值类型 */
type TabValue = 'receive' | 'payment' | 'community' | 'online' | 'all' | 'redenvelope'
/** 页面组件映射表 */
const PAGE_COMPONENTS: Record<TabValue, React.ComponentType> = {
receive: Receive,
payment: Payment,
community: Community,
online: Online,
redenvelope: RedEnvelope,
all: AllActivity,
}
/**
* 活动交易主页面组件
*
* 负责组装活动交易中心的各个子组件
*/
export function TradeMain() {
const [activeTab, setActiveTab] = React.useState<TabValue>('receive')
const [mounted, setMounted] = React.useState(false)
const [refreshKey, setRefreshKey] = React.useState(0)
React.useEffect(() => {
setMounted(true)
}, [])
const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(false)
React.useEffect(() => {
/* 获取红包状态 */
services.config.getPublicConfig()
.then(res => setRedEnvelopeEnabled(res.red_envelope_enabled))
.catch(() => setRedEnvelopeEnabled(false))
}, [])
/* 获取活动类型 */
const getOrderType = (tab: TabValue) => {
if (tab === 'all' || tab === 'redenvelope') return undefined
return tab
}
/* 渲染活动页面内容 */
const renderPageContent = () => {
if (activeTab === 'redenvelope') {
return <RedEnvelope onSuccess={() => setRefreshKey(prev => prev + 1)} />
}
const Component = PAGE_COMPONENTS[activeTab]
return Component ? <Component /> : null
}
if (!mounted) {
return (
<div className="py-6">
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
<div className="animate-pulse space-y-4">
<div className="h-10 bg-muted rounded" />
<div className="h-64 bg-muted rounded" />
</div>
</div>
)
}
return (
<TransactionProvider defaultParams={{ page_size: 20 }}>
<div className="py-6">
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="w-full"
>
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
<TabsTrigger value="receive" className={TAB_TRIGGER_STYLES}>积分收益</TabsTrigger>
<TabsTrigger value="payment" className={TAB_TRIGGER_STYLES}>积分消耗</TabsTrigger>
<TabsTrigger value="community" className={TAB_TRIGGER_STYLES}>社区划转</TabsTrigger>
<TabsTrigger value="online" className={TAB_TRIGGER_STYLES}>在线流转</TabsTrigger>
{redEnvelopeEnabled && (
<TabsTrigger value="redenvelope" className={TAB_TRIGGER_STYLES}>积分红包</TabsTrigger>
)}
<TabsTrigger value="all" className={TAB_TRIGGER_STYLES}>所有活动</TabsTrigger>
</TabsList>
<div className="pt-2 space-y-8">
{renderPageContent()}
<div className="space-y-4">
<h2 className="font-semibold">活动记录</h2>
{activeTab === 'redenvelope' ? (
<RedEnvelopeList refreshTrigger={refreshKey} />
) : (
<TradeTable key={activeTab} type={getOrderType(activeTab)} />
)}
</div>
</div>
</Tabs>
</div>
</TransactionProvider>
)
}
@@ -1,148 +0,0 @@
import * as React from "react"
import { TransactionTableList } from "@/components/common/general/table-data"
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { DEFAULT_ORDER_TYPES, type OrderType, type TransactionQueryParams } from "@/lib/services"
import { formatLocalDate } from "@/lib/utils"
/**
* 获取默认日期范围(最近30天)
*/
function getDefaultDateRange() {
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 }
}
/**
* 活动表格组件
*
* 支持类型、状态、时间范围筛选的活动记录显示(支持分页)
*/
export function TradeTable({ type }: { type?: OrderType }) {
/* 获取时间范围 */
const { from, to: tomorrow } = getDefaultDateRange()
const startTime = formatLocalDate(from)
const endTime = formatLocalDate(tomorrow)
return (
<TransactionProvider defaultParams={{ page_size: 20, startTime, endTime }}>
<TransactionList initialType={type} />
</TransactionProvider>
)
}
/**
* 活动列表组件
*
* 显示交用户活动记录
*/
function TransactionList({ initialType }: { initialType?: OrderType }) {
const {
transactions,
total,
currentPage,
totalPages,
pageSize,
loading,
error,
fetchTransactions,
goToPage,
setPageSize: setPageSizeHandler,
clearCacheAndRefresh,
} = useTransaction()
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>(initialType ? [initialType] : [])
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
const buildQueryParams = React.useCallback((page: number): TransactionQueryParams => {
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
return {
page,
page_size: pageSize,
types: selectedTypes.length > 0 ? selectedTypes : DEFAULT_ORDER_TYPES,
...stateQuery,
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
endTime: dateRange ? (() => {
const endDate = new Date(dateRange.to)
endDate.setDate(endDate.getDate() + 1)
return formatLocalDate(endDate)
})() : undefined,
id: selectedSearch.id || undefined,
order_name: selectedSearch.order_name || undefined,
payer_username: selectedSearch.payer_username || undefined,
payee_username: selectedSearch.payee_username || undefined,
}
}, [dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
/* 清空所有筛选 */
const clearAllFilters = () => {
setSelectedTypes(initialType ? [initialType] : [])
setSelectedStatus(null)
setDateRange(null)
setSelectedQuickSelection(null)
setSelectedSearch({})
}
/* 当筛选条件改变时,重新加载数据 */
React.useEffect(() => {
fetchTransactions(buildQueryParams(1))
}, [buildQueryParams, fetchTransactions])
/* 当initialType改变时,更新筛选状态 */
React.useEffect(() => {
if (initialType) {
setSelectedTypes([initialType])
} else {
setSelectedTypes([])
}
}, [initialType])
return (
<div className="flex flex-col space-y-4">
<TableFilter
enabledFilters={{
type: initialType === undefined,
status: true,
timeRange: true,
search: true
}}
selectedTypes={selectedTypes}
selectedStatus={selectedStatus}
selectedTimeRange={dateRange}
selectedQuickSelection={selectedQuickSelection}
onTypeChange={setSelectedTypes}
onStatusChange={setSelectedStatus}
onTimeRangeChange={setDateRange}
onQuickSelectionChange={setSelectedQuickSelection}
onSearch={setSelectedSearch}
searchValues={selectedSearch}
onClearAll={clearAllFilters}
enablePagination={true}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
total={total}
onPageChange={goToPage}
onPageSizeChange={setPageSizeHandler}
onRefresh={clearCacheAndRefresh}
loading={loading}
/>
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions(buildQueryParams(1))}
/>
</div>
)
}
+19 -23
View File
@@ -15,11 +15,14 @@ export interface DeveloperSectionProps {
export const DeveloperSection = React.memo(function DeveloperSection({ className }: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const codeContent = `curl https://credit.linux.do/epay/submit.php \\
-u sk_live_...: \\
-d amount=1000 \\
-d currency="cny" \\
-d description="Pro Plan"`;
const codeContent = `# 注册用户
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
"username": "developer",
"email": "dev@example.com",
"password": "secure_password"
}'`;
const onCopy = async () => {
try {
@@ -79,11 +82,10 @@ export const DeveloperSection = React.memo(function DeveloperSection({ className
</div>
<pre className="text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto">
<code className="block">
<span className="text-purple-400">curl</span> <span className="text-green-400">https://credit.linux.do/epay/submit.php</span> \{'\n'}
{' '}-u <span className="text-yellow-400">sk_live_...:</span> \{'\n'}
{' '}-d <span className="text-blue-400">amount</span>=<span className="text-orange-400">1000</span> \{'\n'}
{' '}-d <span className="text-blue-400">currency</span>=<span className="text-green-400">&quot;cny&quot;</span> \{'\n'}
{' '}-d <span className="text-blue-400">description</span>=<span className="text-green-400">&quot;Pro Plan&quot;</span>
<span className="text-green-400"># 快速开始</span>{'\n'}
<span className="text-purple-400">curl</span> -X <span className="text-yellow-400">POST</span> <span className="text-green-400">https://api.example.com/api/v1/auth/register</span> \{'\n'}
{' '}-H <span className="text-blue-400">&quot;Content-Type: application/json&quot;</span> \{'\n'}
{' '}-d <span className="text-orange-400">&quot;username=dev&amp;password=***&quot;</span>
</code>
</pre>
</div>
@@ -100,19 +102,19 @@ export const DeveloperSection = React.memo(function DeveloperSection({ className
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6">
简单直接,<br />
开发者快速集成
开发者友好,<br />
极速集成上手
</h2>
<p className="text-muted-foreground text-lg leading-relaxed mb-8">
标准化的 RESTful API 接口,清晰的错误提示,完善的调试工具。无论您使用什么编程语言,只需几行代码,即可完成积分服务功能接入。
标准化的 RESTful API 接口,完整的 TypeScript 类型定义,详细的文档和 Swagger UI。无论使用什么编程语言,只需几行代码,即可快速集成。
</p>
<ul className="space-y-4 mb-8">
{[
"RESTful API 接口,语义清晰",
"完善的 Webhook 回调通知",
"简单调试,开发测试零成本",
"完善的文档和示例代码"
"RESTful API,语义清晰,易于理解",
"OpenAPI / Swagger 接口文档",
"TypeScript 类型定义,开发友好",
"丰富的代码示例和集成指南"
].map((item, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-foreground/80">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
@@ -130,12 +132,6 @@ export const DeveloperSection = React.memo(function DeveloperSection({ className
API 文档
</Button>
</Link>
<Link href="/merchant">
<Button variant="default" className="rounded-full text-xs hover:bg-primary/70">
<Key className="w-3 h-3" />
获取 API Key
</Button>
</Link>
</div>
</motion.div>
</div>
+14 -15
View File
@@ -20,12 +20,12 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
<div className="lg:col-span-2 space-y-6">
<Link href="/" className="flex items-center gap-2">
<div className="w-10 h-10 p-2 rounded bg-primary text-sm text-primary-foreground flex items-center justify-center font-bold">
LDC
MP
</div>
<span className="text-2xl font-bold tracking-tight text-foreground">LINUX DO Credit</span>
<span className="text-2xl font-bold tracking-tight text-foreground">Modern Platform</span>
</Link>
<p className="text-muted-foreground text-base leading-relaxed max-w-sm">
专为 LINUX DO 社区打造的积分服务基础服务设施,快速集成、全球覆盖、安全可靠,致力于打造积分生态,助您轻松流通积分。
通用的、现代化的后台管理系统脚手架。基于 Go + React 构建,开箱即用、完整基建、极易扩展。
</p>
<div className="flex gap-4 pt-2">
<SocialLink icon={Github} href="https://github.com/linux-do/credit/" />
@@ -33,35 +33,34 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
</div>
<div className="lg:col-span-1">
<h3 className="font-semibold text-foreground mb-6">动态</h3>
<h3 className="font-semibold text-foreground mb-6">产品</h3>
<ul className="space-y-4 text-sm text-muted-foreground">
<li><FooterLink href="https://github.com/linux-do/credit/commits/master/">更新日志</FooterLink></li>
<li><FooterLink href="https://github.com/linux-do/credit/graphs/contributors">开源贡献</FooterLink></li>
<li><FooterLink href="http://github.com/linux-do/credit/releases">发行版本</FooterLink></li>
<li><FooterLink href="/home">仪表盘</FooterLink></li>
<li><FooterLink href="/settings">系统设置</FooterLink></li>
</ul>
</div>
<div className="lg:col-span-1">
<h3 className="font-semibold text-foreground mb-6">资源</h3>
<h3 className="font-semibold text-foreground mb-6">开发</h3>
<ul className="space-y-4 text-sm text-muted-foreground">
<li><FooterLink href="/docs/how-to-use">使用文档</FooterLink></li>
<li><FooterLink href="/docs/how-to-use">快速开始</FooterLink></li>
<li><FooterLink href="/docs/api">API 文档</FooterLink></li>
<li><FooterLink href="https://linux.do/t/topic/1365853">积分脚本</FooterLink></li>
<li><FooterLink href="https://github.com/linux-do/credit">源代码</FooterLink></li>
</ul>
</div>
<div className="lg:col-span-1">
<h3 className="font-semibold text-foreground mb-6">友链</h3>
<h3 className="font-semibold text-foreground mb-6">社区</h3>
<ul className="space-y-4 text-sm text-muted-foreground">
<li><FooterLink href="https://github.com/linux-do/credit/issues">GitHub Issues</FooterLink></li>
<li><FooterLink href="https://github.com/linux-do/credit/discussions">讨论</FooterLink></li>
<li><FooterLink href="https://linux.do/">LINUX DO</FooterLink></li>
<li><FooterLink href="https://wiki.linux.do/">LINUX DO Wiki</FooterLink></li>
<li><FooterLink href="https://cdk.linux.do/">LINUX DO CDK</FooterLink></li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
<p>© 2026 LINUX DO Credit. All rights reserved.</p>
<p>© 2026 Modern Platform. All rights reserved.</p>
<div className="flex gap-8">
<Link href="/docs/privacy-policy" className="hover:text-foreground transition-colors">隐私政策</Link>
<Link href="/docs/terms-of-service" className="hover:text-foreground transition-colors">服务条款</Link>
@@ -72,7 +71,7 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
<div className="absolute bottom-0 left-0 w-full overflow-hidden pointer-events-none opacity-[0.02]">
<div className="text-[12vw] 2xl:text-[180px] font-black leading-none text-foreground whitespace-nowrap select-none text-center transform translate-y-1/3 transition-all duration-700">
LINUX DO Credit
Modern Platform
</div>
</div>
</footer>
+21 -33
View File
@@ -49,9 +49,9 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-[1.1] mb-6 text-foreground"
>
LINUX DO Credit <br />
Modern Platform <br />
<span className="bg-clip-text text-primary">
让积分流通更简单
为二次开发而生
</span>
</motion.h1>
@@ -62,9 +62,9 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10"
>
专为 LINUX DO 社区打造的积分流通基础设施
通用的、现代化的后台管理系统脚手架
<br className="hidden md:block" />
快速集成、全球覆盖、安全可靠,助您轻松流通积分。
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
</motion.p>
<motion.div
@@ -104,15 +104,15 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
>
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
<span>极速到账</span>
<span>开箱即用</span>
</div>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
<span>全球覆盖</span>
<span>高度可扩展</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-green-500" />
<span>安全加密</span>
<span>工业级基建</span>
</div>
</motion.div>
</div>
@@ -131,25 +131,22 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
<div className="relative z-10 w-full h-64 bg-background/40 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl p-6 flex flex-col justify-between transform transition-transform hover:scale-[1.02] duration-500">
<div className="flex gap-4 items-center">
<CreditCard className="size-6" />
<span>LINUX DO Credit</span>
<span>Modern Platform</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="h-2 w-24 bg-white/10 rounded-full" />
<div className="h-2 w-16 bg-white/10 rounded-full" />
<div className="text-xs text-muted-foreground mb-2">Stack</div>
<div className="flex flex-wrap gap-2">
<span className="inline-block px-2 py-1 bg-blue-500/10 text-blue-500 rounded text-xs font-medium">Go • Gin</span>
<span className="inline-block px-2 py-1 bg-purple-500/10 text-purple-500 rounded text-xs font-medium">React • Next.js</span>
</div>
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-sm text-muted-foreground mb-1">Total Balance</p>
<div className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-1">
<span>LDC</span>
<CountingNumber
number={balance}
decimalPlaces={2}
thousandSeparator=","
initiallyStable={true}
/>
<p className="text-sm text-muted-foreground mb-1">Ready for</p>
<div className="text-lg font-bold tracking-tight text-foreground flex items-center gap-1">
<span>Develop</span>
</div>
</div>
<div className="h-8 w-8 rounded-full bg-primary" />
@@ -163,22 +160,13 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
className="absolute -top-6 -right-6 z-20 bg-background/60 backdrop-blur-xl border border-white/20 p-4 rounded-2xl shadow-xl"
>
<div className="flex items-center gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors duration-500",
income < 0 ? "bg-red-500/20 text-red-500" : "bg-green-500/20 text-green-500"
)}>
<Wallet className="size-5" />
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-emerald-500/20 text-emerald-500">
<Zap className="size-5" />
</div>
<div>
<p className="text-xs text-muted-foreground">Income</p>
<div className="text-sm font-bold flex items-center gap-1">
<span>{income < 0 ? "-" : "+"} LDC</span>
<CountingNumber
number={Math.abs(income)}
decimalPlaces={2}
thousandSeparator=","
initiallyStable={true}
/>
<p className="text-xs text-muted-foreground">Features</p>
<div className="text-sm font-bold">
<span>Modern</span>
</div>
</div>
</div>
+9 -13
View File
@@ -31,15 +31,15 @@ const BellButton = memo(function BellButton() {
}
return (
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="size-9 text-muted-foreground hover:text-foreground"
onClick={handleClick}
>
<Bell
className="size-[18px]"
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
<Bell
className="size-[18px]"
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
/>
<span className="sr-only">通知</span>
</Button>
@@ -50,7 +50,7 @@ const BellButton = memo(function BellButton() {
/**
* 站点头部组件
* 用于显示站点头部
*
*
* @example
* ```tsx
* <SiteHeader />
@@ -114,10 +114,6 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
</Button>
<Button className="mx-1 size-7 rounded-full bg-primary text-primary-foreground hover:bg-primary/90" onClick={() => router.push('/merchant')}>
<Plus className="size-4" />
<span className="sr-only">新建</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
@@ -165,8 +161,8 @@ function AppBanner() {
>
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span className="text-foreground">「LINUX DO Credit」实时积分收入脚本已发布</span>
<a href="https://linux.do/t/topic/1365853" target="_blank" className="underline underline-offset-4 hover:text-foreground">
<span className="text-foreground">最新通知</span>
<a href="https://example.com" target="_blank" className="underline underline-offset-4 hover:text-foreground">
查看详情
</a>
</p>
+18 -42
View File
@@ -47,18 +47,15 @@ import {
Home,
CreditCard,
Settings,
Wallet,
FileText,
CircleDollarSign,
LogOut,
Store,
ChevronDown,
UserRound,
FileQuestionMark,
ShieldCheck,
Globe,
Layers,
Trophy,
Key,
Palette,
ArrowUpRight,
} from "lucide-react"
@@ -68,23 +65,16 @@ import { useUser } from "@/contexts/user-context"
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "集市", url: "/merchant", icon: Store },
{ title: "积分", url: "/balance", icon: Wallet },
{ title: "活动", url: "/trade", icon: CircleDollarSign },
{ title: "排行榜", url: "/leaderboard", icon: Trophy },
],
admin: [
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "积分配置", url: "/admin/credit", icon: Settings },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
],
document: [
{ title: "接口文档", url: "/docs/api", icon: CreditCard },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText },
],
products: [
{ title: "在线流转", url: "/merchant/online-paying", icon: Globe },
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
{ title: "组件库", url: "/components", icon: Palette },
],
}
@@ -325,13 +315,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
) : (
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
@@ -339,28 +337,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
服务
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.products.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
+9 -71
View File
@@ -4,24 +4,16 @@ import * as React from "react"
import { createContext, useContext, useState, useRef, useCallback } from "react"
import services from "@/lib/services"
import type { UpdateUserPayConfigRequest } from "@/lib/services/admin/types"
import type { UserPayConfig, SystemConfig, UpdateSystemConfigRequest } from "@/lib/services"
import type { SystemConfig, UpdateSystemConfigRequest } from "@/lib/services"
import { handleContextError } from "@/lib/utils/error-handling"
/** Admin 上下文状态接口 */
export interface AdminContextState {
userPayConfigs: UserPayConfig[]
userPayConfigsLoading: boolean
userPayConfigsError: Error | null
refetchUserPayConfigs: () => Promise<void>
updateUserPayConfig: (id: string, data: UpdateUserPayConfigRequest) => Promise<void>
deleteUserPayConfig: (id: string) => Promise<void>
systemConfigs: SystemConfig[]
systemConfigsLoading: boolean
systemConfigsError: Error | null
refetchSystemConfigs: () => Promise<void>
refetchSystemConfigs: (type?: 'system' | 'business') => Promise<void>
updateSystemConfig: (key: string, data: UpdateSystemConfigRequest) => Promise<void>
deleteSystemConfig: (key: string) => Promise<void>
}
@@ -41,69 +33,22 @@ const AdminContext = createContext<AdminContextState | null>(null)
* @param {React.ReactNode} children - Admin Provider 的子元素
*/
export function AdminProvider({ children }: { children: React.ReactNode }) {
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
const [userPayConfigsLoading, setUserPayConfigsLoading] = useState(false)
const [userPayConfigsError, setUserPayConfigsError] = useState<Error | null>(null)
const [systemConfigs, setSystemConfigs] = useState<SystemConfig[]>([])
const [systemConfigsLoading, setSystemConfigsLoading] = useState(false)
const [systemConfigsError, setSystemConfigsError] = useState<Error | null>(null)
const userPayRequestIdRef = useRef(0)
const systemRequestIdRef = useRef(0)
/** 获取用户积分配置列表 */
const refetchUserPayConfigs = useCallback(async () => {
const requestId = ++userPayRequestIdRef.current
try {
setUserPayConfigsLoading(true)
setUserPayConfigsError(null)
const data = await services.admin.listUserPayConfigs()
if (requestId !== userPayRequestIdRef.current) {
return
}
setUserPayConfigs(data)
setUserPayConfigsLoading(false)
} catch (error) {
if (requestId !== userPayRequestIdRef.current) {
return
}
const errorObject = handleContextError(error, '加载积分配置失败', { logError: true })
setUserPayConfigsError(errorObject)
setUserPayConfigsLoading(false)
}
}, [])
/** 更新用户积分配置 */
const updateUserPayConfig = useCallback(async (id: string, data: UpdateUserPayConfigRequest) => {
try {
await services.admin.updateUserPayConfig(id, data)
await refetchUserPayConfigs()
} catch (error) {
handleContextError(error, '更新用户积分配置失败')
throw error
}
}, [refetchUserPayConfigs])
/** 删除用户积分配置 */
const deleteUserPayConfig = useCallback(async (id: string) => {
await services.admin.deleteUserPayConfig(id)
await refetchUserPayConfigs()
}, [refetchUserPayConfigs])
const lastConfigTypeRef = useRef<'system' | 'business' | undefined>(undefined)
/** 获取系统配置列表 */
const refetchSystemConfigs = useCallback(async () => {
const refetchSystemConfigs = useCallback(async (type?: 'system' | 'business') => {
lastConfigTypeRef.current = type
const requestId = ++systemRequestIdRef.current
try {
setSystemConfigsLoading(true)
setSystemConfigsError(null)
const data = await services.admin.listSystemConfigs()
const data = await services.admin.listSystemConfigs(type)
if (requestId !== systemRequestIdRef.current) {
return
@@ -126,7 +71,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
const updateSystemConfig = useCallback(async (key: string, data: UpdateSystemConfigRequest) => {
try {
await services.admin.updateSystemConfig(key, data)
await refetchSystemConfigs()
await refetchSystemConfigs(lastConfigTypeRef.current)
} catch (error) {
handleContextError(error, '更新系统配置失败')
throw error
@@ -136,17 +81,10 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
/** 删除系统配置 */
const deleteSystemConfig = useCallback(async (key: string) => {
await services.admin.deleteSystemConfig(key)
await refetchSystemConfigs()
await refetchSystemConfigs(lastConfigTypeRef.current)
}, [refetchSystemConfigs])
const value: AdminContextState = {
userPayConfigs,
userPayConfigsLoading,
userPayConfigsError,
refetchUserPayConfigs,
updateUserPayConfig,
deleteUserPayConfig,
systemConfigs,
systemConfigsLoading,
systemConfigsError,
@@ -167,7 +105,7 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
*
* @example
* ```tsx
* const { userPayConfigs, systemConfigs } = useAdmin()
* const { systemConfigs } = useAdmin()
* ```
* @returns {AdminContextState} Admin 上下文状态
*/
-178
View File
@@ -1,178 +0,0 @@
"use client"
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"
import services, { type MerchantAPIKey, type UpdateAPIKeyRequest } from "@/lib/services"
import { handleContextError } from "@/lib/utils/error-handling"
/** 商户数据状态接口 */
interface MerchantDataState {
apiKeys: MerchantAPIKey[]
loading: boolean
error: string | null
}
/** 商户 Context 接口 */
interface MerchantContextType extends MerchantDataState {
loadAPIKeys: () => Promise<void>
createAPIKey: (data: {
app_name: string
app_homepage_url: string
redirect_uri?: string
notify_url: string
}) => Promise<MerchantAPIKey>
updateAPIKey: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
deleteAPIKey: (id: string) => Promise<void>
refresh: () => Promise<void>
}
/** 商户 Context(全局状态管理) */
const MerchantContext = createContext<MerchantContextType | undefined>(undefined)
/** 商户 Provider */
export function MerchantProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<MerchantDataState>({
apiKeys: [],
loading: false,
error: null,
})
/** 使用 ref 来标记是否已经加载过数据(全局级别) */
const hasLoadedRef = useRef(false)
const isMountedRef = useRef(true)
const apiKeysRef = useRef(state.apiKeys)
/** 同步更新 ref */
apiKeysRef.current = state.apiKeys
/** 获取 API Keys */
const loadAPIKeys = useCallback(async () => {
if (hasLoadedRef.current && apiKeysRef.current.length > 0) {
return
}
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const data = await services.merchant.listAPIKeys()
if (!isMountedRef.current) return
const validKeys = Array.isArray(data) ? data.filter(key => key != null) : []
setState(prev => ({
...prev,
apiKeys: validKeys,
loading: false,
error: null
}))
hasLoadedRef.current = true
} catch (error) {
const errorObject = handleContextError(error, '加载 API Keys 失败', { showToast: true })
if (errorObject.message === '请求已取消') {
return
}
if (!isMountedRef.current) return
setState(prev => ({
...prev,
loading: false,
error: errorObject.message
}))
}
}, [])
/** 创建 API Key */
const createAPIKey = useCallback(async (data: {
app_name: string
app_homepage_url: string
redirect_uri?: string
notify_url: string
}): Promise<MerchantAPIKey> => {
const newKey = await services.merchant.createAPIKey(data)
if (!isMountedRef.current) return newKey
setState(prev => ({
...prev,
apiKeys: [newKey, ...prev.apiKeys]
}))
return newKey
}, [])
/** 更新 API Key */
const updateAPIKey = useCallback(async (id: string, data: UpdateAPIKeyRequest): Promise<void> => {
await services.merchant.updateAPIKey(id, data)
if (!isMountedRef.current) return
/** 更新本地状态中的API Key */
setState(prev => ({
...prev,
apiKeys: prev.apiKeys.map(key =>
key.id === id ? { ...key, ...data } : key
)
}))
}, [])
/** 删除 API Key */
const deleteAPIKey = useCallback(async (id: string) => {
await services.merchant.deleteAPIKey(id)
if (!isMountedRef.current) return
setState(prev => ({
...prev,
apiKeys: prev.apiKeys.filter(key => key && key.id !== id)
}))
}, [])
/** 刷新数据 */
const refresh = useCallback(async () => {
hasLoadedRef.current = false
await loadAPIKeys()
}, [loadAPIKeys])
const value: MerchantContextType = {
...state,
loadAPIKeys,
createAPIKey,
updateAPIKey,
deleteAPIKey,
refresh,
}
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
return (
<MerchantContext.Provider value={value}>
{children}
</MerchantContext.Provider>
)
}
/**
* 使用商户数据 Hook
* 用于在组件中使用商户数据状态
*
* @example
* ```tsx
* const { apiKeys, loading, error, loadAPIKeys, createAPIKey, updateAPIKey, deleteAPIKey, refresh } = useMerchant()
* ```
*/
export function useMerchant(): MerchantContextType {
const context = useContext(MerchantContext)
if (context === undefined) {
throw new Error('useMerchant must be used within a MerchantProvider')
}
return context
}
-279
View File
@@ -1,279 +0,0 @@
"use client"
import * as React from "react"
import { createContext, useContext, useCallback, useState, useRef, useEffect } from "react"
import services from "@/lib/services"
import type { Order, TransactionQueryParams } from "@/lib/services"
import { generateTransactionCacheKey } from "@/lib/utils"
/** 交易上下文状态接口 */
interface TransactionContextState {
transactions: Order[]
total: number
currentPage: number
pageSize: number
totalPages: number
loading: boolean
error: Error | null
lastParams: Partial<TransactionQueryParams>
fetchTransactions: (params: Partial<TransactionQueryParams>) => Promise<void>
goToPage: (page: number) => Promise<void>
setPageSize: (size: number) => void
refresh: () => Promise<void>
clearCacheAndRefresh: () => Promise<void>
reset: () => void
updateOrderStatus: (orderId: string, updates: Partial<Pick<Order, 'status' | 'dispute_id'>>) => void
}
const MAX_CACHE_SIZE = 50
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟
/** 交易上下文 */
const TransactionContext = createContext<TransactionContextState | null>(null)
/** 交易 Provider Props 接口 */
interface TransactionProviderProps {
children: React.ReactNode
defaultParams?: Partial<TransactionQueryParams>
}
/**
* 交易 Provider
* 提供交易数据的全局状态管理
*
* @param {React.ReactNode} children - 交易 Provider 的子元素
* @param {Partial<TransactionQueryParams>} defaultParams - 默认查询参数
* @example
* ```tsx
* <TransactionProvider defaultParams={{ type: 'receive', page_size: 20 }}>
* <TransactionList />
* </TransactionProvider>
* ```
*/
export function TransactionProvider({ children, defaultParams = {} }: TransactionProviderProps) {
const [transactions, setTransactions] = useState<Order[]>([])
const [total, setTotal] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(defaultParams.page_size || 20)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [lastParams, setLastParams] = useState<Partial<TransactionQueryParams>>(defaultParams)
/** 使用 useRef 存储缓存 */
const cacheRef = useRef<Record<string, { data: Order[], total: number, timestamp: number }>>({})
const latestRequestIdRef = useRef(0)
const cleanExpiredCache = useCallback(() => {
const now = Date.now()
Object.keys(cacheRef.current).forEach(key => {
if (now - cacheRef.current[key].timestamp > CACHE_DURATION) {
delete cacheRef.current[key]
}
})
}, [])
const addToCache = useCallback((key: string, value: { data: Order[], total: number, timestamp: number }) => {
const cacheKeys = Object.keys(cacheRef.current)
if (cacheKeys.length >= MAX_CACHE_SIZE) {
const oldestKey = cacheKeys.reduce((oldest, current) => {
return cacheRef.current[current].timestamp < cacheRef.current[oldest].timestamp ? current : oldest
})
delete cacheRef.current[oldestKey]
}
cacheRef.current[key] = value
}, [])
/** 获取交易列表 */
const fetchTransactions = useCallback(async (params: Partial<TransactionQueryParams>) => {
const queryParams: TransactionQueryParams = {
page: params.page || 1,
page_size: params.page_size || pageSize,
...params,
}
/** 生成唯一的请求 ID */
const requestId = ++latestRequestIdRef.current
/** 生成缓存key */
const cacheKey = generateTransactionCacheKey(queryParams as Parameters<typeof generateTransactionCacheKey>[0])
const cached = cacheRef.current[cacheKey]
const now = Date.now()
/** 缓存所有页面,不仅仅第一页 */
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
if (requestId !== latestRequestIdRef.current) {
return
}
/** 使用缓存数据,同步更新状态 */
setTransactions(cached.data)
setTotal(cached.total)
setCurrentPage(queryParams.page)
setPageSize(queryParams.page_size)
setLastParams(prev => ({ ...prev, ...params }))
setError(null)
setLoading(false)
return
}
/** 发起API请求 */
setLoading(true)
setError(null)
// 不清空 total,保持显示之前的总数
// setTransactions([]) - 也不清空,保持之前的数据直到新数据到达
try {
const result = await services.transaction.getTransactions(queryParams)
if (requestId !== latestRequestIdRef.current) {
return
}
/** 总是替换数据,不累加 */
setTransactions(result.orders)
/** 缓存所有页的数据 */
addToCache(cacheKey, {
data: result.orders,
total: result.total,
timestamp: now
})
cleanExpiredCache()
setTotal(result.total)
setCurrentPage(queryParams.page)
setPageSize(queryParams.page_size)
setLastParams(prev => ({ ...prev, ...params }))
} catch (err) {
if (err instanceof Error && err.message === '请求已被取消') {
return
}
if (requestId !== latestRequestIdRef.current) {
return
}
setError(err instanceof Error ? err : new Error('获取交易记录失败'))
console.error('获取交易记录失败:', err)
} finally {
if (requestId === latestRequestIdRef.current) {
setLoading(false)
}
}
}, [pageSize, addToCache, cleanExpiredCache])
/** 跳转到指定页 */
const goToPage = useCallback(async (page: number) => {
if (loading) return
await fetchTransactions({
...lastParams,
page,
})
}, [fetchTransactions, lastParams, loading])
/** 设置分页大小 */
const setPageSizeHandler = useCallback((size: number) => {
setPageSize(size)
fetchTransactions({
...lastParams,
page: 1,
page_size: size,
})
}, [fetchTransactions, lastParams])
const refresh = useCallback(async () => {
const cacheKey = generateTransactionCacheKey({ ...lastParams, page: currentPage, page_size: pageSize } as Parameters<typeof generateTransactionCacheKey>[0])
delete cacheRef.current[cacheKey]
await fetchTransactions({
...lastParams,
page: currentPage,
})
}, [fetchTransactions, lastParams, pageSize, currentPage])
/** 清除所有缓存并刷新 */
const clearCacheAndRefresh = useCallback(async () => {
cacheRef.current = {}
await fetchTransactions({
...lastParams,
page: 1,
})
}, [fetchTransactions, lastParams])
/** 乐观更新订单状态 */
const updateOrderStatus = useCallback((orderId: string, updates: Partial<Pick<Order, 'status' | 'dispute_id'>>) => {
setTransactions(prev =>
prev.map(order =>
order.id === orderId
? { ...order, ...updates }
: order
)
)
cacheRef.current = {}
}, [])
/** 重置状态 */
const reset = useCallback(() => {
setTransactions([])
setTotal(0)
setCurrentPage(1)
setPageSize(defaultParams.page_size || 20)
setError(null)
setLastParams(defaultParams)
}, [defaultParams])
const totalPages = Math.ceil(total / pageSize)
const value: TransactionContextState = {
transactions,
total,
currentPage,
pageSize,
totalPages,
loading,
error,
lastParams,
fetchTransactions,
goToPage,
setPageSize: setPageSizeHandler,
refresh,
clearCacheAndRefresh,
reset,
updateOrderStatus,
}
useEffect(() => {
return () => {
cacheRef.current = {}
}
}, [])
return (
<TransactionContext.Provider value={value}>
{children}
</TransactionContext.Provider>
)
}
/**
* 使用交易上下文
*
* @param {React.ReactNode} children - 交易上下文 Provider 的子元素
* @param {Partial<TransactionQueryParams>} defaultParams - 默认查询参数
*/
export function useTransaction() {
const context = useContext(TransactionContext)
if (!context) {
throw new Error('useTransaction 必须在 TransactionProvider 内部使用')
}
return context
}
-8
View File
@@ -16,7 +16,6 @@ interface UserState {
/** 用户上下文接口 */
interface UserContextValue extends UserState {
refetch: () => Promise<void>
updatePayKey: (payKey: string) => Promise<void>
getTrustLevelLabel: (trustLevel: TrustLevel) => string
getPayLevelLabel: (payLevel: PayLevel) => string
logout: () => Promise<void>
@@ -100,12 +99,6 @@ export function UserProvider({ children }: { children: ReactNode }) {
await fetchUser()
}, [fetchUser])
/** 更新支付密码 */
const updatePayKey = useCallback(async (payKey: string) => {
await services.user.updatePayKey(payKey)
await fetchUser()
}, [fetchUser])
/** 用户登出 */
const logout = useCallback(async () => {
try {
@@ -144,7 +137,6 @@ export function UserProvider({ children }: { children: ReactNode }) {
value={{
...state,
refetch,
updatePayKey,
getTrustLevelLabel,
getPayLevelLabel,
logout,
-81
View File
@@ -1,81 +0,0 @@
"use client"
import * as React from "react"
import type { DisputeWithOrder, DisputeStatus } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
import { toast } from "sonner"
const DISPUTE_PAGE_SIZE = 20
/**
* 争议数据获取 Hook
* 用于获取争议数据
*
* @param fetchFn 获取争议数据的函数
* @returns 争议数据
*/
interface UseDisputeDataProps {
fetchFn: (params: { page: number; page_size: number; status: DisputeStatus }) => Promise<{ total: number; disputes: DisputeWithOrder[] }>
}
export const useDisputeData = ({ fetchFn }: UseDisputeDataProps) => {
const { user } = useUser()
const [disputes, setDisputes] = React.useState<{ count: number; list: DisputeWithOrder[] }>({ count: 0, list: [] })
const [loading, setLoading] = React.useState<boolean>(true)
const fetchFnRef = React.useRef(fetchFn)
React.useEffect(() => {
fetchFnRef.current = fetchFn
}, [fetchFn])
const fetchData = React.useCallback(async () => {
if (!user) return
try {
setLoading(true)
const result = await fetchFnRef.current({
page: 1,
page_size: DISPUTE_PAGE_SIZE,
status: 'disputing'
}).catch(() => ({ total: 0, disputes: [] }))
setDisputes({
count: result.total,
list: result.disputes
})
} catch (err) {
toast.error('获取争议数据失败', {
description: err instanceof Error ? err.message : '获取争议数据失败'
})
} finally {
setLoading(false)
}
}, [user])
React.useEffect(() => {
fetchData()
}, [fetchData])
const handleRefresh = React.useCallback(async () => {
setLoading(true)
try {
const result = await fetchFnRef.current({
page: 1,
page_size: DISPUTE_PAGE_SIZE,
status: 'disputing'
})
setDisputes({
count: result.total,
list: result.disputes
})
} catch (err) {
toast.error('刷新争议数据失败', {
description: err instanceof Error ? err.message : '刷新争议数据失败'
})
} finally {
setLoading(false)
}
}, [])
return { disputes, loading, handleRefresh, refetchData: fetchData }
}
-127
View File
@@ -1,127 +0,0 @@
"use client";
import * as React from "react";
import { LeaderboardService } from "@/lib/services/leaderboard";
import type {
LeaderboardListResponse,
LeaderboardListRequest,
LeaderboardEntry,
UserRankResponse,
} from "@/lib/services/leaderboard";
export const useLeaderboard = () => {
const [data, setData] = React.useState<LeaderboardListResponse | null>(null);
const [allItems, setAllItems] = React.useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = React.useState<UserRankResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [loadingMore, setLoadingMore] = React.useState(false);
const [myRankLoading, setMyRankLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const currentPageRef = React.useRef(1);
const fetchData = React.useCallback(
async (
queryParams: LeaderboardListRequest,
append = false,
ignore?: { current: boolean },
) => {
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
setAllItems([]);
currentPageRef.current = 1;
}
setError(null);
try {
const response = await LeaderboardService.getList(queryParams);
if (ignore?.current) return;
setData(response);
if (append) {
setAllItems((prev) => [...prev, ...response.items]);
} else {
setAllItems(response.items);
}
currentPageRef.current = response.page;
} catch (err) {
if (ignore?.current) return;
if (err instanceof Error) {
setError(err);
}
} finally {
if (!ignore?.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[],
);
const fetchMyRank = React.useCallback(
async (ignore?: { current: boolean }) => {
setMyRankLoading(true);
try {
const response = await LeaderboardService.getMyRank();
if (!ignore?.current) {
setMyRank(response);
}
} catch {
if (!ignore?.current) {
setMyRank(null);
}
} finally {
if (!ignore?.current) {
setMyRankLoading(false);
}
}
},
[],
);
React.useEffect(() => {
const ignore = { current: false };
fetchData({ page: 1, page_size: 50 }, false, ignore);
fetchMyRank(ignore);
return () => {
ignore.current = true;
};
}, [fetchData, fetchMyRank]);
const loadNextPage = React.useCallback(() => {
if (
data &&
currentPageRef.current * data.page_size < data.total &&
!loadingMore
) {
const nextPage = currentPageRef.current + 1;
fetchData({ page: nextPage, page_size: data.page_size }, true);
}
}, [data, loadingMore, fetchData]);
const refresh = React.useCallback(() => {
fetchData({ page: 1, page_size: 50 }, false);
fetchMyRank();
}, [fetchData, fetchMyRank]);
const hasMore = data
? currentPageRef.current * data.page_size < data.total
: false;
return {
data,
items: allItems,
myRank,
loading,
loadingMore,
myRankLoading,
error,
hasMore,
loadNextPage,
refresh,
};
};
+3 -119
View File
@@ -3,9 +3,6 @@ import type {
SystemConfig,
CreateSystemConfigRequest,
UpdateSystemConfigRequest,
UserPayConfig,
CreateUserPayConfigRequest,
UpdateUserPayConfigRequest,
TaskMeta,
TaskTypeResponse,
DispatchTaskRequest,
@@ -63,8 +60,9 @@ export class AdminService extends BaseService {
* console.log('系统配置数量:', configs.length);
* ```
*/
static async listSystemConfigs(): Promise<SystemConfig[]> {
return this.get<SystemConfig[]>('/system-configs');
static async listSystemConfigs(type?: 'system' | 'business'): Promise<SystemConfig[]> {
const query = type ? `?type=${type}` : '';
return this.get<SystemConfig[]>(`/system-configs${query}`);
}
/**
@@ -127,121 +125,7 @@ export class AdminService extends BaseService {
return this.delete<void>(`/system-configs/${ key }`);
}
// ==================== 用户积分配置管理 ====================
/**
* 创建用户积分配置
* @param request - 创建用户积分配置的请求参数
* @returns 创建的用户积分配置
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {ValidationError} 当参数验证失败或等级已存在时
*
* @example
* ```typescript
* const config = await AdminService.createUserPayConfig({
* level: PayLevel.Premium,
* min_score: 1000,
* max_score: null,
* daily_limit: 100000,
* fee_rate: 0.01
* });
* console.log('配置ID:', config.id);
* ```
*
* @remarks
* - min_score 必须 >= 0
* - max_score 必须大于 min_score(如果提供)
* - fee_rate 必须在 0-1 之间,最多2位小数
*/
static async createUserPayConfig(
request: CreateUserPayConfigRequest,
): Promise<UserPayConfig> {
return this.post<UserPayConfig>('/user-pay-configs', request);
}
/**
* 获取用户积分配置列表
* @returns 用户积分配置列表(按最低分数升序排序)
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
*
* @example
* ```typescript
* const configs = await AdminService.listUserPayConfigs();
* console.log('积分配置数量:', configs.length);
* ```
*/
static async listUserPayConfigs(): Promise<UserPayConfig[]> {
return this.get<UserPayConfig[]>('/user-pay-configs');
}
/**
* 获取单个用户积分配置
* @param id - 配置ID
* @returns 用户积分配置信息
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
*
* @example
* ```typescript
* const config = await AdminService.getUserPayConfig(123);
* console.log('手续费率:', config.fee_rate);
* ```
*/
static async getUserPayConfig(id: string): Promise<UserPayConfig> {
return this.get<UserPayConfig>(`/user-pay-configs/${ id }`);
}
/**
* 更新用户积分配置
* @param id - 配置ID
* @param request - 更新用户积分配置的请求参数
* @returns void
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* await AdminService.updateUserPayConfig(123, {
* min_score: 500,
* max_score: 999,
* daily_limit: 50000,
* fee_rate: 0.02
* });
* ```
*
* @remarks
* - min_score 必须 >= 0
* - max_score 必须大于 min_score(如果提供)
* - fee_rate 必须在 0-1 之间,最多2位小数
*/
static async updateUserPayConfig(
id: string,
request: UpdateUserPayConfigRequest,
): Promise<void> {
return this.put<void>(`/user-pay-configs/${ id }`, request);
}
/**
* 删除用户积分配置
* @param id - 配置ID
* @returns void
* @throws {UnauthorizedError} 当未登录时
* @throws {ForbiddenError} 当无管理员权限时
* @throws {NotFoundError} 当配置不存在时
*
* @example
* ```typescript
* await AdminService.deleteUserPayConfig(123);
* ```
*/
static async deleteUserPayConfig(id: string): Promise<void> {
return this.delete<void>(`/user-pay-configs/${ id }`);
}
// ==================== 任务管理 ====================
+3 -15
View File
@@ -2,9 +2,10 @@
* 管理员服务模块
*
* @description
* 提供系统配置和用户积分配置管理功能,包括:
* 提供系统配置、用户与任务管理功能,包括:
* - 系统配置管理(创建、查询、更新、删除)
* - 用户积分配置管理(创建、查询、更新、删除)
* - 异步任务配置及下发
* - 用户账号状态管理
*
* @remarks
* 所有接口都需要管理员权限
@@ -15,15 +16,6 @@
*
* // 获取系统配置列表
* const configs = await AdminService.listSystemConfigs();
*
* // 创建用户积分配置
* await AdminService.createUserPayConfig({
* level: 1,
* min_score: 0,
* max_score: 999,
* daily_limit: 10000,
* fee_rate: 0.01
* });
* ```
*/
@@ -32,9 +24,6 @@ export type {
SystemConfig,
CreateSystemConfigRequest,
UpdateSystemConfigRequest,
UserPayConfig,
CreateUserPayConfigRequest,
UpdateUserPayConfigRequest,
TaskMeta,
DispatchTaskRequest,
AdminUser,
@@ -42,4 +31,3 @@ export type {
ListUsersResponse,
UpdateUserStatusRequest,
} from './types';
+5 -63
View File
@@ -1,4 +1,4 @@
import type { PayLevel } from '@/lib/services/auth/types';
/**
* 系统配置信息
@@ -8,6 +8,8 @@ export interface SystemConfig {
key: string;
/** 配置值 */
value: string;
/** 配置类型:'system' | 'business' */
type: 'system' | 'business';
/** 配置描述 */
description: string;
/** 创建时间 */
@@ -24,6 +26,8 @@ export interface CreateSystemConfigRequest {
key: string;
/** 配置值(最大255字符) */
value: string;
/** 配置类型:'system' | 'business' */
type: 'system' | 'business';
/** 配置描述(最大255字符,可选) */
description?: string;
}
@@ -38,69 +42,7 @@ export interface UpdateSystemConfigRequest {
description?: string;
}
/**
* 用户积分配置信息
*/
export interface UserPayConfig {
/** 配置ID */
id: string;
/** 积分等级 */
level: PayLevel;
/** 最低分数 */
min_score: number;
/** 最高分数(可选) */
max_score: number | null;
/** 每日限额(可选) */
daily_limit: number | null;
/** 手续费率(0-1之间的小数,最多2位小数) */
fee_rate: number | string;
/** 积分费率(0-1之间的小数,最多2位小数) */
score_rate: number | string;
/** 分发费率(0-1之间的小数,最多2位小数) */
distribute_rate: number | string;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at: string;
}
/**
* 创建用户积分配置请求参数
*/
export interface CreateUserPayConfigRequest {
/** 积分等级 */
level: PayLevel;
/** 最低分数(必须 >= 0) */
min_score: number;
/** 最高分数(可选,必须大于 min_score) */
max_score?: number | null;
/** 每日限额(可选) */
daily_limit?: number | null;
/** 手续费率(0-1之间的小数,最多2位小数) */
fee_rate: number | string;
/** 积分费率(0-1之间的小数,最多2位小数) */
score_rate: number | string;
/** 分发费率(0-1之间的小数,最多2位小数) */
distribute_rate: number | string;
}
/**
* 更新用户积分配置请求参数
*/
export interface UpdateUserPayConfigRequest {
/** 最低分数(必须 >= 0) */
min_score: number;
/** 最高分数(可选,必须大于 min_score) */
max_score?: number | null;
/** 每日限额(可选) */
daily_limit?: number | null;
/** 手续费率(0-1之间的小数,最多2位小数) */
fee_rate: number | string;
/** 积分费率(0-1之间的小数,最多2位小数) */
score_rate: number | string;
/** 分发费率(0-1之间的小数,最多2位小数) */
distribute_rate: number | string;
}
// ==================== 任务管理 ====================
-2
View File
@@ -56,8 +56,6 @@ export interface User {
pending_balance: string;
/** 支付分数 */
pay_score: number;
/** 是否有支付密钥 */
is_pay_key: boolean;
/** 是否为管理员 */
is_admin: boolean;
/** 当日剩余配额 */
@@ -1,6 +1,5 @@
import { BaseService } from '../core/base.service';
import type { PublicConfigResponse } from './types';
import type { UserPayConfig } from '../admin/types';
/**
* 配置服务
@@ -16,29 +15,9 @@ export class ConfigService extends BaseService {
* @example
* ```typescript
* const config = await ConfigService.getPublicConfig();
* console.log('争议时间窗口:', config.dispute_time_window_hours, '小时');
* ```
*/
static async getPublicConfig(): Promise<PublicConfigResponse> {
return this.get<PublicConfigResponse>('/public');
}
/**
* 获取用户平台等级配置(公开接口)
* 返回所有平台等级的配置信息,用于向用户展示不同等级的权益
*
* @returns 用户支付配置列表
*
* @example
* ```typescript
* const payConfigs = await ConfigService.getUserPayConfigs();
* payConfigs.forEach(config => {
* console.log(`等级 ${config.level}: 手续费率 ${config.fee_rate}, 每日限额 ${config.daily_limit}`);
* });
* ```
*/
static async getUserPayConfigs(): Promise<UserPayConfig[]> {
return this.get<UserPayConfig[]>('/user-pay');
}
}
+8 -16
View File
@@ -2,20 +2,12 @@
* 公共配置响应
*/
export interface PublicConfigResponse {
/** 争议时间窗口(小时) */
dispute_time_window_hours: number;
/** 红包功能是否启用 */
red_envelope_enabled: boolean;
/** 单个红包的最大积分上限 */
red_envelope_max_amount: string;
/** 每日发红包的个数限制 */
red_envelope_daily_limit: number;
/** 红包手续费率(0-1之间的小数) */
red_envelope_fee_rate: string;
/** 每个红包的最大可领取人数上限 */
red_envelope_max_recipients: number;
/** 到账最短时间 **/
settlement_delay_days_min: number;
/** 到账最长时间 **/
settlement_delay_days_max: number;
/** 允许上传的图片扩展名 */
upload_allowed_extensions: string;
/** 站点名称 */
site_name: string;
/** 是否允许注册 */
registration_enabled: boolean;
/** 每个用户最大 API Key 数量 */
max_api_keys_per_user: number;
}
-118
View File
@@ -1,6 +1,5 @@
import axios, { AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig } from 'axios';
import { toast } from 'sonner';
import { showRiskWarningToast } from '@/components/common/risk/risk-warning-toast';
import { apiConfig } from './config';
import {
ApiErrorBase,
@@ -37,108 +36,7 @@ const cancelTokens = new Map<string, CancelTokenSource>();
*/
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();
const RISK_LEVEL_HEADER = 'x-credit-risk-level';
const RISK_LABELS_HEADER = 'x-credit-risk-labels';
const RISK_ITEMS_HEADER = 'x-credit-risks';
const RISK_BLOCKED_CODE = 'RISK_BLOCKED';
const RISK_BLOCKED_EVENT = 'credit-risk-blocked';
interface RiskItem {
label: string;
value?: string;
desc?: string;
}
interface RiskInfo {
risk_level: string;
risk_labels: string[];
risks: RiskItem[];
}
function decodeBase64JSON(value?: string): unknown {
if (!value || typeof window === 'undefined') return [];
try {
const binary = window.atob(value);
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
const json = new TextDecoder().decode(bytes);
return JSON.parse(json);
} catch {
return [];
}
}
function normalizeRiskItems(value: unknown): RiskItem[] {
if (!Array.isArray(value)) return [];
return value.reduce<RiskItem[]>((items, item) => {
if (!item || typeof item !== 'object') return items;
const label = 'label' in item ? (item as { label?: unknown }).label : undefined;
if (typeof label !== 'string' || !label.trim()) return items;
const value = 'value' in item ? (item as { value?: unknown }).value : undefined;
const desc = 'desc' in item ? (item as { desc?: unknown }).desc : undefined;
items.push({
label: label.trim(),
value: typeof value === 'string' ? value.trim() : undefined,
desc: typeof desc === 'string' ? desc.trim() : undefined,
});
return items;
}, []);
}
function normalizeRiskLabels(value: unknown): string[] {
return Array.isArray(value) ? value.filter((label): label is string => typeof label === 'string' && !!label.trim()).map(label => label.trim()) : [];
}
function riskLabelsFromItems(items: RiskItem[]): string[] {
return items.map(item => item.label).filter(Boolean);
}
function riskInfoFromHeaders(headers: AxiosResponse['headers']): RiskInfo | null {
const riskLevel = headers[RISK_LEVEL_HEADER];
if (typeof riskLevel !== 'string' || !riskLevel) return null;
const riskLabelsHeader = headers[RISK_LABELS_HEADER];
const riskItemsHeader = headers[RISK_ITEMS_HEADER];
const risks = typeof riskItemsHeader === 'string' ? normalizeRiskItems(decodeBase64JSON(riskItemsHeader)) : [];
const labels = typeof riskLabelsHeader === 'string' ? normalizeRiskLabels(decodeBase64JSON(riskLabelsHeader)) : riskLabelsFromItems(risks);
return {
risk_level: riskLevel,
risk_labels: labels,
risks,
};
}
function riskInfoFromDetails(details: unknown): RiskInfo | null {
if (!details || typeof details !== 'object') return null;
const riskLevel = 'risk_level' in details ? (details as { risk_level?: unknown }).risk_level : undefined;
const riskLabels = 'risk_labels' in details ? (details as { risk_labels?: unknown }).risk_labels : undefined;
const riskItems = 'risks' in details ? (details as { risks?: unknown }).risks : undefined;
if (typeof riskLevel !== 'string' || !riskLevel) return null;
const risks = normalizeRiskItems(riskItems);
const labels = normalizeRiskLabels(riskLabels);
return {
risk_level: riskLevel,
risk_labels: labels.length ? labels : riskLabelsFromItems(risks),
risks,
};
}
function showRiskWarning(riskInfo: RiskInfo): void {
showRiskWarningToast(riskInfo);
}
function showRiskBlockedDialog(riskInfo: RiskInfo): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<RiskInfo>(RISK_BLOCKED_EVENT, { detail: riskInfo }));
}
/**
* 生成请求的唯一键
@@ -204,11 +102,6 @@ apiClient.interceptors.response.use(
cancelTokens.delete(requestKey);
pendingRequests.delete(requestKey);
const riskInfo = riskInfoFromHeaders(response.headers);
if (riskInfo) {
showRiskWarning(riskInfo);
}
return response;
},
(error: AxiosError<ApiError>) => {
@@ -232,17 +125,6 @@ apiClient.interceptors.response.use(
/* 403 权限不足错误 */
if (error.response?.status === 403) {
if (error.response.data?.error_code === RISK_BLOCKED_CODE) {
const riskInfo = riskInfoFromDetails(error.response.data.details) || riskInfoFromHeaders(error.response.headers);
if (riskInfo) {
showRiskBlockedDialog(riskInfo);
}
return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '账号存在风险', RISK_BLOCKED_CODE, error.response.data?.details),
);
}
return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试', error.response.data?.error_code, error.response.data?.details),
);
@@ -1,69 +0,0 @@
import { BaseService } from '../core/base.service';
import type {
DailyStatsResponse,
TopCustomersResponse,
} from './types';
/**
* 仪表板服务
* 处理仪表板统计数据相关的 API 请求
*/
export class DashboardService extends BaseService {
protected static readonly basePath = '/api/v1/dashboard';
/**
* 获取每日收支统计
* @param days - 查询天数,最大7天,最小1天
* @returns 每日统计数据列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* const stats = await DashboardService.getDailyStats(7);
* console.log('每日统计:', stats);
* // 输出: [
* // { date: '2025-12-22', income: '1000.00', expense: '500.00' },
* // { date: '2025-12-21', income: '800.00', expense: '300.00' },
* // ...
* // ]
* ```
*
* @remarks
* - days 参数必须在 1-7 之间
* - 返回的数据按日期降序排列
* - income 和 expense 为字符串格式的金额
*/
static async getDailyStats(days: number): Promise<DailyStatsResponse> {
return this.get<DailyStatsResponse>('/stats/daily', { days });
}
/**
* 获取 Top 客户统计
* @param days - 查询天数,最大7天,最小1天
* @param limit - 返回数量,最大10条,最小1条
* @returns Top 客户列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* const customers = await DashboardService.getTopCustomers(7, 5);
* console.log('Top 5 客户:', customers);
* // 输出: [
* // { user_id: 123, username: 'user1', total_amount: '5000.00', order_count: 10 },
* // { user_id: 456, username: 'user2', total_amount: '3000.00', order_count: 8 },
* // ...
* // ]
* ```
*
* @remarks
* - days 参数必须在 1-7 之间
* - limit 参数必须在 1-10 之间
* - 返回的数据按累计付款金额降序排列
* - total_amount 为字符串格式的金额
*/
static async getTopCustomers(days: number, limit: number): Promise<TopCustomersResponse> {
return this.get<TopCustomersResponse>('/stats/top-customers', { days, limit });
}
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Dashboard 服务模块
*
* @description
* 提供仪表板统计数据相关的服务,包括:
* - 每日收支统计
* - Top 客户统计
*
* @example
* ```typescript
* import { DashboardService } from '@/lib/services/dashboard';
*
* // 获取最近7天的收支统计
* const dailyStats = await DashboardService.getDailyStats(7);
*
* // 获取 Top 5 客户
* const topCustomers = await DashboardService.getTopCustomers(7, 5);
* ```
*/
export { DashboardService } from './dashboard.service';
export type {
DailyStatsItem,
DailyStatsResponse,
GetDailyStatsRequest,
TopCustomer,
TopCustomersResponse,
GetTopCustomersRequest,
} from './types';
-57
View File
@@ -1,57 +0,0 @@
/**
* Dashboard 服务类型定义
*/
/**
* 每日统计项
*/
export interface DailyStatsItem {
/** 日期,格式: YYYY-MM-DD */
date: string;
/** 当日收入金额 */
income: string;
/** 当日支出金额 */
expense: string;
}
/**
* 每日统计响应
*/
export type DailyStatsResponse = DailyStatsItem[];
/**
* 获取每日统计请求参数
*/
export interface GetDailyStatsRequest {
/** 查询天数,最大7天,最小1天 */
days: number;
}
/**
* Top 客户项
*/
export interface TopCustomer {
/** 客户用户ID */
user_id: number;
/** 客户用户名 */
username: string;
/** 累计付款金额 */
total_amount: string;
/** 订单数量 */
order_count: number;
}
/**
* Top 客户响应
*/
export type TopCustomersResponse = TopCustomer[];
/**
* 获取 Top 客户请求参数
*/
export interface GetTopCustomersRequest {
/** 查询天数,最大7天,最小1天 */
days: number;
/** 返回数量,最大10条,最小1条 */
limit: number;
}
@@ -1,128 +0,0 @@
import { BaseService } from '../core/base.service';
import type {
ListDisputesRequest,
ListDisputesResponse,
RefundReviewRequest,
CloseDisputeRequest,
CreateDisputeRequest,
CreateDisputeResponse,
} from './types';
/**
* 争议服务
* 处理争议相关的 API 请求
*/
export class DisputeService extends BaseService {
protected static readonly basePath = '/api/v1/order';
/**
* 创建争议
* @param data - 争议信息
* @returns void
* @throws {UnauthorizedError} 当未登录时
* @throws {NotFoundError} 当订单不存在或不符合争议条件时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* await DisputeService.createDispute({
* order_id: 123,
* reason: '商品质量问题'
* });
* ```
*/
static async createDispute(data: CreateDisputeRequest): Promise<CreateDisputeResponse> {
return this.post<CreateDisputeResponse>('/dispute', data);
}
/**
* 查询用户发起的争议列表
* @param params - 查询参数
* @returns 争议列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* const result = await DisputeService.listDisputes({
* page: 1,
* page_size: 20,
* status: 'disputing'
* });
* console.log('争议数量:', result.total);
* ```
*/
static async listDisputes(
params: ListDisputesRequest
): Promise<ListDisputesResponse> {
return this.post<ListDisputesResponse>('/disputes', params);
}
/**
* 查询商户的争议列表
* @param params - 查询参数
* @returns 争议列表
* @throws {UnauthorizedError} 当未登录时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* const result = await DisputeService.listMerchantDisputes({
* page: 1,
* page_size: 20
* });
* console.log('商户争议数量:', result.total);
* ```
*/
static async listMerchantDisputes(
params: ListDisputesRequest
): Promise<ListDisputesResponse> {
return this.post<ListDisputesResponse>('/disputes/merchant', params);
}
/**
* 退款审核(商户使用)
* @param data - 审核请求
* @returns void
* @throws {UnauthorizedError} 当未登录时
* @throws {NotFoundError} 当争议不存在时
* @throws {ValidationError} 当参数验证失败时
*
* @example
* ```typescript
* // 同意退款
* await DisputeService.refundReview({
* dispute_id: 123,
* status: 'refund'
* });
*
* // 拒绝退款
* await DisputeService.refundReview({
* dispute_id: 123,
* status: 'closed',
* reason: '不符合退款条件'
* });
* ```
*/
static async refundReview(data: RefundReviewRequest): Promise<void> {
return this.post('/refund-review', data);
}
/**
* 关闭争议(用户主动关闭)
* @param data - 关闭争议请求
* @returns void
* @throws {UnauthorizedError} 当未登录时
* @throws {NotFoundError} 当争议不存在时
*
* @example
* ```typescript
* await DisputeService.closeDispute({
* dispute_id: 123
* });
* ```
*/
static async closeDispute(data: CloseDisputeRequest): Promise<void> {
return this.post('/dispute/close', data);
}
}
-31
View File
@@ -1,31 +0,0 @@
/**
* 争议服务模块
*
* @description
* 提供争议处理相关的功能,包括:
* - 创建争议
* - 查询用户发起的争议列表
* - 查询商户的争议列表
* - 退款审核(商户)
* - 关闭争议(用户)
*
* @example
* ```typescript
* import { DisputeService } from '@/lib/services';
*
* // 创建争议
* await DisputeService.createDispute({
* order_id: 123,
* reason: '商品质量问题'
* });
*
* // 查询争议列表
* const disputes = await DisputeService.listDisputes({
* page: 1,
* page_size: 20
* });
* ```
*/
export { DisputeService } from './dispute.service';
export type * from './types';
-105
View File
@@ -1,105 +0,0 @@
/**
* 争议状态
*/
export type DisputeStatus = 'disputing' | 'refund' | 'closed';
/**
* 争议信息
*/
export interface Dispute {
/** 争议 ID */
id: string;
/** 订单 ID */
order_id: string;
/** 发起者用户 ID */
initiator_user_id: number;
/** 争议原因 */
reason: string;
/** 争议状态 */
status: DisputeStatus;
/** 处理者用户 ID */
handler_user_id?: number;
/** 发起者账户 */
initiator_username: string;
/** 处理者账户 */
handler_username: string;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at: string;
}
/**
* 争议列表项(包含订单信息)
*/
export interface DisputeWithOrder extends Dispute {
/** 订单名称 */
order_name: string;
/** 收款方账户 */
payee_username: string;
/** 订单金额 */
amount: string;
}
/**
* 查询争议列表请求
*/
export interface ListDisputesRequest {
/** 页码,从 1 开始 */
page: number;
/** 每页数量,1-100 */
page_size: number;
/** 状态筛选(可选) */
status?: DisputeStatus;
/** 争议 ID(可选) */
dispute_id?: string;
}
/**
* 争议列表响应
*/
export interface ListDisputesResponse {
/** 总记录数 */
total: number;
/** 当前页码 */
page: number;
/** 每页数量 */
page_size: number;
/** 争议列表 */
disputes: DisputeWithOrder[];
}
/**
* 退款审核请求
*/
export interface RefundReviewRequest {
/** 争议 ID */
dispute_id: string;
/** 审核结果 */
status: 'refund' | 'closed';
/** 拒绝原因(status 为 closed 时必填,最大 100 字符) */
reason?: string;
}
/**
* 关闭争议请求
*/
export interface CloseDisputeRequest {
/** 争议 ID */
dispute_id: string;
}
/**
* 创建争议请求
*/
export interface CreateDisputeRequest {
/** 订单 ID */
order_id: string;
/** 争议原因(最大 100 字符) */
reason: string;
}
/**
* 创建争议响应
*/
export type CreateDisputeResponse = Dispute;
+1 -103
View File
@@ -8,7 +8,7 @@
* import services from '@/lib/services';
*
* const user = await services.auth.getUserInfo();
* const transactions = await services.transaction.getTransactions({ page: 1, page_size: 20 });
* const systemConfig = await services.admin.listSystemConfigs();
* ```
*
* @example
@@ -21,15 +21,9 @@
*/
import { AuthService } from './auth';
import { TransactionService } from './transaction';
import { MerchantService } from './merchant';
import { AdminService } from './admin';
import { UserService } from './user';
import { DisputeService } from './dispute';
import { ConfigService } from './config';
import { DashboardService } from './dashboard';
import { LeaderboardService } from './leaderboard';
import { RedEnvelopeService } from './redenvelope';
import { UploadService } from './upload';
/**
@@ -42,24 +36,12 @@ import { UploadService } from './upload';
const services = {
/** 认证服务 */
auth: AuthService,
/** 交易服务 */
transaction: TransactionService,
/** 商户服务 */
merchant: MerchantService,
/** 管理员服务 */
admin: AdminService,
/** 用户服务 */
user: UserService,
/** 争议服务 */
dispute: DisputeService,
/** 配置服务 */
config: ConfigService,
/** 仪表板服务 */
dashboard: DashboardService,
/** 排行榜服务 */
leaderboard: LeaderboardService,
/** 红包服务 */
redEnvelope: RedEnvelopeService,
/** 上传服务 */
upload: UploadService,
} as const;
@@ -102,62 +84,16 @@ export type {
export { AuthService, TrustLevel } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest } from './auth';
// 交易服务
export { TransactionService } from './transaction';
export { DEFAULT_ORDER_TYPES } from './transaction';
export type {
Order,
OrderType,
OrderStatus,
TransferStatus,
TransactionQueryParams,
TransactionListResponse,
} from './transaction';
// 争议服务
export { DisputeService } from './dispute';
export type {
Dispute,
DisputeStatus,
DisputeWithOrder,
ListDisputesRequest,
ListDisputesResponse,
RefundReviewRequest,
CloseDisputeRequest,
CreateDisputeRequest,
} from './dispute';
// 配置服务
export { ConfigService } from './config';
export type { PublicConfigResponse } from './config';
// 商户服务
export { MerchantService } from './merchant';
export type {
MerchantAPIKey,
CreateAPIKeyRequest,
UpdateAPIKeyRequest,
PayMerchantOrderRequest,
GetMerchantOrderRequest,
GetMerchantOrderResponse,
PaymentLink,
CreatePaymentLinkRequest,
QueryMerchantOrderRequest,
QueryMerchantOrderResponse,
RefundMerchantOrderRequest,
RefundMerchantOrderResponse,
GetPaymentLinkInfoResponse,
} from './merchant';
// 管理员服务
export { AdminService } from './admin';
export type {
SystemConfig,
CreateSystemConfigRequest,
UpdateSystemConfigRequest,
UserPayConfig,
CreateUserPayConfigRequest,
UpdateUserPayConfigRequest,
TaskMeta,
DispatchTaskRequest,
AdminUser,
@@ -168,44 +104,6 @@ export type {
// 用户服务
export { UserService } from './user';
export type { UpdatePayKeyRequest } from './user';
// 仪表板服务
export { DashboardService } from './dashboard';
export type {
DailyStatsItem,
DailyStatsResponse,
GetDailyStatsRequest,
TopCustomer,
TopCustomersResponse,
GetTopCustomersRequest,
} from './dashboard';
// 红包服务
export { RedEnvelopeService } from './redenvelope';
export type {
RedEnvelopeType,
RedEnvelopeStatus,
RedEnvelope,
RedEnvelopeClaim,
CreateRedEnvelopeRequest,
CreateRedEnvelopeResponse,
ClaimRedEnvelopeRequest,
ClaimRedEnvelopeResponse,
RedEnvelopeDetailResponse,
RedEnvelopeListParams,
RedEnvelopeListResponse,
} from './redenvelope';
// 排行榜服务
export { LeaderboardService } from './leaderboard';
export type {
LeaderboardEntry,
LeaderboardListRequest,
LeaderboardListResponse,
UserRankInfo,
UserRankResponse,
} from './leaderboard';
// 上传服务
export { UploadService } from './upload';
@@ -1,26 +0,0 @@
/**
* Leaderboard 服务模块
*
* @description
* 提供排行榜相关的服务,基于 available_balance 排序
*
* @example
* ```typescript
* import { LeaderboardService } from '@/lib/services/leaderboard';
*
* // 获取排行榜
* const list = await LeaderboardService.getList({ page: 1, page_size: 20 });
*
* // 获取当前用户排名
* const myRank = await LeaderboardService.getMyRank();
* ```
*/
export { LeaderboardService } from "./leaderboard.service";
export type {
LeaderboardEntry,
LeaderboardListRequest,
LeaderboardListResponse,
UserRankInfo,
UserRankResponse,
} from "./types";
@@ -1,42 +0,0 @@
import { BaseService } from "../core/base.service";
import type {
LeaderboardListRequest,
LeaderboardListResponse,
UserRankResponse,
} from "./types";
/**
* 排行榜服务
* 基于 available_balance 排序
*/
export class LeaderboardService extends BaseService {
protected static readonly basePath = "/api/v1/leaderboard";
/**
* 获取排行榜列表
* @param params - 分页参数
* @returns 排行榜列表响应
*/
static async getList(
params?: LeaderboardListRequest,
): Promise<LeaderboardListResponse> {
return this.get<LeaderboardListResponse>("", params);
}
/**
* 获取当前用户排名
* @returns 用户排名响应
*/
static async getMyRank(): Promise<UserRankResponse> {
return this.get<UserRankResponse>("/me");
}
/**
* 获取指定用户排名
* @param userId - 用户 ID
* @returns 用户排名响应
*/
static async getUserRankById(userId: number): Promise<UserRankResponse> {
return this.get<UserRankResponse>(`/users/${ userId }`);
}
}
@@ -1,68 +0,0 @@
/**
* Leaderboard 服务类型定义
* 使用 available_balance 排序
*/
/**
* 排行榜条目
*/
export interface LeaderboardEntry {
/** 用户ID */
user_id: number;
/** 用户名 */
username: string;
/** 头像URL */
avatar_url: string;
/** 可用余额 */
available_balance: string;
}
/**
* 排行榜请求参数
*/
export interface LeaderboardListRequest {
/** 页码 */
page: number;
/** 每页数量 */
page_size: number;
/** 索引签名 */
[key: string]: unknown;
}
/**
* 排行榜列表响应
*/
export interface LeaderboardListResponse {
/** 排序字段 */
sort_by: string;
/** 排序方向 */
order: string;
/** 当前页码 */
page: number;
/** 每页数量 */
page_size: number;
/** 总数 */
total: number;
/** 排行榜条目列表 */
items: LeaderboardEntry[];
}
/**
* 用户排名信息
*/
export interface UserRankInfo {
/** 用户ID */
user_id: number;
/** 排名 */
rank: number;
/** 可用余额 */
available_balance: string;
}
/**
* 用户排名响应
*/
export interface UserRankResponse {
/** 用户排名信息 */
user: UserRankInfo;
}
-45
View File
@@ -1,45 +0,0 @@
/**
* 商户服务模块
*
* @description
* 提供商户相关的功能,包括:
* - API Key 管理(创建、查询、更新、删除)
* - 支付链接管理(创建、列表、删除)
* - 商户订单查询和支付
* - 商户订单退款
*
* @example
* ```typescript
* import { MerchantService } from '@/lib/services';
*
* // 创建 API Key
* const apiKey = await MerchantService.createAPIKey({
* app_name: '我的应用',
* app_homepage_url: 'https://example.com',
* redirect_uri: 'https://example.com/callback',
* notify_url: 'https://example.com/notify'
* });
*
* // 获取支付链接
* const links = await MerchantService.listPaymentLinks(apiKey.id);
* ```
*/
export { MerchantService } from './merchant.service';
export type {
MerchantAPIKey,
CreateAPIKeyRequest,
UpdateAPIKeyRequest,
PayMerchantOrderRequest,
GetMerchantOrderRequest,
GetMerchantOrderResponse,
PaymentLink,
CreatePaymentLinkRequest,
PayByLinkRequest,
GetPaymentLinkInfoResponse,
QueryMerchantOrderRequest,
QueryMerchantOrderResponse,
RefundMerchantOrderRequest,
RefundMerchantOrderResponse,
} from './types';

Some files were not shown because too many files have changed in this diff Show More