Files
2026-04-26 01:53:15 +08:00

404 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { requestWithAuth } from "@/lib/api"
import {
getWalletBalance,
listWalletTransactions,
topUpWallet,
withdrawWallet,
} from "@/lib/api/transactions"
import { toApiError } from "@/lib/errors"
import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { WalletTransaction } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import {
ArrowDownLeft,
ArrowUpRight,
CreditCard,
DollarSign,
RefreshCw,
Wallet,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
const typeLabels: Record<string, string> = {
topup: "充值",
payment: "支付",
income: "收入",
withdrawal: "提现",
refund: "退款",
}
const typeIcons: Record<string, typeof ArrowUpRight> = {
topup: ArrowDownLeft,
payment: ArrowUpRight,
income: ArrowDownLeft,
withdrawal: ArrowUpRight,
refund: ArrowDownLeft,
}
const typeVariants: Record<string, "success" | "info" | "warning" | "neutral" | "destructive"> = {
topup: "success",
income: "success",
refund: "info",
payment: "neutral",
withdrawal: "neutral",
}
export default function WalletPage() {
const { currentRole } = useAuthStore()
const isConsumer = currentRole === "consumer"
const [balance, setBalance] = useState<number | null>(null)
const [transactions, setTransactions] = useState<WalletTransaction[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isMutating, setIsMutating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [customAmount, setCustomAmount] = useState("")
const [lastRefreshedAt, setLastRefreshedAt] = useState<string | null>(null)
const filteredTransactions = useMemo(() => {
return transactions.filter((tx) => {
if (isConsumer) {
return ["topup", "payment", "refund"].includes(tx.type)
}
return ["income", "withdrawal"].includes(tx.type)
})
}, [isConsumer, transactions])
const incomeSummary = useMemo(() => {
const income = transactions
.filter((tx) => tx.type === "income")
.reduce((total, tx) => total + Number(tx.amount), 0)
const withdrawn = transactions
.filter((tx) => tx.type === "withdrawal")
.reduce((total, tx) => total + Number(tx.amount), 0)
return {
income,
available: balance ?? 0,
withdrawn,
}
}, [balance, transactions])
const loadWalletData = useCallback(
async (options?: { showToast?: boolean; silent?: boolean }) => {
if (!options?.silent) {
setIsLoading(true)
setLoadError(null)
}
try {
const res = await requestWithAuth(async () => {
const [b, items] = await Promise.all([
getWalletBalance(),
listWalletTransactions({ offset: 0, limit: 1000 }),
])
return { balance: b, transactions: items }
})
if (!res) {
setLoadError("请先登录")
return
}
setBalance(res.balance)
setTransactions(res.transactions)
if (options?.showToast) notifySuccess("交易记录已刷新")
} catch (error) {
setLoadError(toApiError(error).msg)
} finally {
setIsLoading(false)
}
},
[],
)
useEffect(() => {
let cancelled = false
void (async () => {
try {
const res = await requestWithAuth(async () => {
const [b, items] = await Promise.all([
getWalletBalance(),
listWalletTransactions({ offset: 0, limit: 100 }),
])
return { balance: b, transactions: items }
})
if (cancelled) return
if (!res) {
setLoadError("请先登录")
return
}
setBalance(res.balance)
setTransactions(res.transactions)
} catch (error) {
if (cancelled) return
setLoadError(toApiError(error).msg)
}
})()
return () => {
cancelled = true
}
}, [])
const handleTopUp = async (rawAmount?: number) => {
const amount = rawAmount ?? Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) {
notifyInfo("请输入有效金额")
return
}
setIsMutating(true)
try {
const res = await requestWithAuth(async () => {
await topUpWallet({ amount })
await loadWalletData({ silent: true })
return true
})
if (!res) return
notifySuccess("充值成功")
setCustomAmount("")
setSelectedAmount(null)
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setIsMutating(false)
}
}
const handleWithdraw = async () => {
const amount = Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) {
notifyInfo("请输入有效金额")
return
}
setIsMutating(true)
try {
const res = await requestWithAuth(async () => {
await withdrawWallet({ amount })
await loadWalletData({ silent: true })
return true
})
if (!res) return
notifySuccess("提现成功")
setCustomAmount("")
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setIsMutating(false)
}
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<h1 className="text-2xl font-bold"></h1>
<Card className="border-border/60 shadow-sm">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">
{isConsumer ? "账户余额" : "收入余额"}
</p>
<p className="text-3xl font-bold mt-1 tracking-tight">
{balance === null ? (
<span className="text-muted-foreground opacity-50">--</span>
) : (
<>¥{balance.toFixed(2)}</>
)}
</p>
{loadError && <p className="text-xs text-destructive mt-2">{loadError}</p>}
</div>
<Wallet className="h-10 w-10 text-muted-foreground/30" />
</div>
<div className="flex gap-2 mt-6">
{isConsumer ? (
<Button
disabled={isMutating}
onClick={() => void handleTopUp(selectedAmount ?? undefined)}
>
<DollarSign className="mr-1 h-4 w-4" />
</Button>
) : (
<Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
<CreditCard className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
{isConsumer ? (
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{[50, 100, 200, 500, 1000, 2000].map((amount) => (
<Button
key={`amount-${amount}`}
variant={selectedAmount === amount ? "default" : "outline"}
className="h-12 border-border/60"
onClick={() => {
setSelectedAmount(amount)
setCustomAmount(amount.toString())
}}
>
¥{amount}
</Button>
))}
</div>
<Separator className="bg-border/60" />
<div className="flex gap-2">
<Input
placeholder="自定义金额"
type="number"
value={customAmount}
className="border-border/60 focus-visible:ring-primary/20"
onChange={(event) => {
setCustomAmount(event.target.value)
setSelectedAmount(null)
}}
/>
<Button disabled={isMutating} onClick={() => void handleTopUp()}>
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="border-border/60 shadow-sm">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.income.toFixed(2)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.available.toFixed(2)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥{incomeSummary.withdrawn.toFixed(2)}</p>
</div>
</div>
<Separator className="my-6 bg-border/60" />
<div className="flex gap-2">
<Input
placeholder="提现金额"
type="number"
value={customAmount}
className="border-border/60 focus-visible:ring-primary/20"
onChange={(event) => setCustomAmount(event.target.value)}
/>
<Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
</Button>
</div>
</CardContent>
</Card>
)}
<Card className="border-border/60 shadow-sm overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between border-b border-border/60 bg-muted/10 py-4">
<div className="space-y-1">
<CardTitle className="text-base"></CardTitle>
{lastRefreshedAt && (
<p className="text-xs text-muted-foreground">{lastRefreshedAt}</p>
)}
</div>
<Button
variant="ghost"
size="sm"
disabled={isLoading}
onClick={async () => {
await loadWalletData({ showToast: true })
setLastRefreshedAt(new Date().toLocaleTimeString("zh-CN"))
}}
>
<RefreshCw className={`mr-1 h-3.5 w-3.5 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<EmptyState
title="加载中"
description="正在获取交易记录..."
icon={RefreshCw}
className="border-0"
/>
) : filteredTransactions.length > 0 ? (
<div className="divide-y divide-border/60">
{filteredTransactions.map((tx) => {
const Icon = typeIcons[tx.type]
const isIncome = tx.type === "topup" || tx.type === "income" || tx.type === "refund"
return (
<div
key={tx.id}
className="flex items-center justify-between p-4 hover:bg-muted/10 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted/40 flex items-center justify-center border border-border/60">
{Icon ? (
<Icon className="h-4 w-4 text-foreground/70" />
) : (
<ArrowUpRight className="h-4 w-4 text-foreground/70" />
)}
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">{tx.description}</p>
<p className="text-[11px] text-muted-foreground">
{new Date(tx.createdAt).toLocaleString("zh-CN")}
</p>
</div>
</div>
<div className="text-right space-y-1">
<p
className={`text-sm font-bold ${isIncome ? "text-success" : "text-foreground"}`}
>
{isIncome ? "+" : ""}¥{Math.abs(Number(tx.amount)).toFixed(2)}
</p>
<Badge
variant={typeVariants[tx.type] || "neutral"}
className="text-[10px] px-1.5 py-0 leading-tight"
>
{typeLabels[tx.type]}
</Badge>
</div>
</div>
)
})}
</div>
) : (
<EmptyState
title="暂无交易记录"
description="你的所有收支记录将显示在这里。"
className="border-0"
/>
)}
</CardContent>
</Card>
</div>
)
}