fix(wallet): persist balance actions through backend

This commit is contained in:
zetaloop
2026-04-25 14:22:45 +08:00
parent e4a57b54ca
commit 874ee5cb9a
2 changed files with 121 additions and 27 deletions
+91 -17
View File
@@ -6,7 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { requestWithAuth } from "@/lib/api" import { requestWithAuth } from "@/lib/api"
import { getWalletBalance, listWalletTransactions } from "@/lib/api/transactions" import {
getWalletBalance,
listWalletTransactions,
topUpWallet,
withdrawWallet,
} from "@/lib/api/transactions"
import { toApiError } from "@/lib/errors" import { toApiError } from "@/lib/errors"
import { notifyInfo, notifySuccess } from "@/lib/toast" import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { WalletTransaction } from "@/lib/types" import type { WalletTransaction } from "@/lib/types"
@@ -44,6 +49,7 @@ export default function WalletPage() {
const [balance, setBalance] = useState<number | null>(null) const [balance, setBalance] = useState<number | null>(null)
const [transactions, setTransactions] = useState<WalletTransaction[]>([]) const [transactions, setTransactions] = useState<WalletTransaction[]>([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isMutating, setIsMutating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [selectedAmount, setSelectedAmount] = useState<number | null>(null) const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
@@ -59,6 +65,21 @@ export default function WalletPage() {
}) })
}, [isConsumer, transactions]) }, [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( const loadWalletData = useCallback(
async (options?: { showToast?: boolean; silent?: boolean }) => { async (options?: { showToast?: boolean; silent?: boolean }) => {
if (!options?.silent) { if (!options?.silent) {
@@ -122,17 +143,53 @@ export default function WalletPage() {
} }
}, []) }, [])
const handleTopUp = (rawAmount?: number) => { const handleTopUp = async (rawAmount?: number) => {
const amount = rawAmount ?? Number(customAmount) const amount = rawAmount ?? Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) return if (!Number.isFinite(amount) || amount <= 0) {
notifyInfo("充值暂未开放") notifyInfo("请输入有效金额")
setCustomAmount("") return
setSelectedAmount(null)
} }
const handleWithdraw = () => { setIsMutating(true)
notifyInfo("提现暂未开放") try {
const res = await requestWithAuth(async () => {
await topUpWallet({ amount })
await loadWalletData({ silent: true })
return true
})
if (!res) return
notifySuccess("充值成功")
setCustomAmount("") 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 ( return (
@@ -159,12 +216,15 @@ export default function WalletPage() {
</div> </div>
<div className="flex gap-2 mt-4"> <div className="flex gap-2 mt-4">
{isConsumer ? ( {isConsumer ? (
<Button onClick={() => handleTopUp(selectedAmount ?? undefined)}> <Button
disabled={isMutating}
onClick={() => void handleTopUp(selectedAmount ?? undefined)}
>
<DollarSign className="mr-1 h-4 w-4" /> <DollarSign className="mr-1 h-4 w-4" />
</Button> </Button>
) : ( ) : (
<Button variant="outline" onClick={handleWithdraw}> <Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
<CreditCard className="mr-1 h-4 w-4" /> <CreditCard className="mr-1 h-4 w-4" />
</Button> </Button>
@@ -205,7 +265,9 @@ export default function WalletPage() {
setSelectedAmount(null) setSelectedAmount(null)
}} }}
/> />
<Button onClick={() => handleTopUp()}></Button> <Button disabled={isMutating} onClick={() => void handleTopUp()}>
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -217,18 +279,30 @@ export default function WalletPage() {
<CardContent> <CardContent>
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥1,280.00</p> <p className="text-lg font-bold">¥{incomeSummary.income.toFixed(2)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥320.00</p> <p className="text-lg font-bold">¥{incomeSummary.available.toFixed(2)}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
<p className="text-lg font-bold">¥5,400.00</p> <p className="text-lg font-bold">¥{incomeSummary.withdrawn.toFixed(2)}</p>
</div> </div>
</div> </div>
<Separator className="my-4" />
<div className="flex gap-2">
<Input
placeholder="提现金额"
type="number"
value={customAmount}
onChange={(event) => setCustomAmount(event.target.value)}
/>
<Button variant="outline" disabled={isMutating} onClick={() => void handleWithdraw()}>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -258,7 +332,7 @@ export default function WalletPage() {
) : filteredTransactions.length > 0 ? ( ) : filteredTransactions.length > 0 ? (
filteredTransactions.map((tx) => { filteredTransactions.map((tx) => {
const Icon = typeIcons[tx.type] const Icon = typeIcons[tx.type]
const isIncome = Number(tx.amount) > 0 const isIncome = tx.type === "topup" || tx.type === "income" || tx.type === "refund"
return ( return (
<div key={tx.id} className="flex items-center justify-between"> <div key={tx.id} className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+29 -9
View File
@@ -7,12 +7,12 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { getPlayerById, getServiceById } from "@/lib/api" import { getPlayerById, getServiceById, requestWithAuth } from "@/lib/api"
import { createPaidOrder } from "@/lib/api/orders" import { createPaidOrder } from "@/lib/api/orders"
import { notifySuccess } from "@/lib/toast" import { getWalletBalance } from "@/lib/api/transactions"
import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { Player, PlayerService } from "@/lib/types" import type { Player, PlayerService } from "@/lib/types"
import { useRequireAuth } from "@/lib/use-require-auth" import { useRequireAuth } from "@/lib/use-require-auth"
import { useWalletStore } from "@/store/wallet"
import { ArrowLeft, CheckCircle, CreditCard, ShieldCheck } from "lucide-react" import { ArrowLeft, CheckCircle, CreditCard, ShieldCheck } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
@@ -22,11 +22,11 @@ export default function NewOrderPage() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { requireAuth } = useRequireAuth() const { requireAuth } = useRequireAuth()
const balance = useWalletStore((state) => state.balance)
const serviceId = searchParams.get("serviceId") const serviceId = searchParams.get("serviceId")
const [service, setService] = useState<PlayerService | null>(null) const [service, setService] = useState<PlayerService | null>(null)
const [player, setPlayer] = useState<Player | null>(null) const [player, setPlayer] = useState<Player | null>(null)
const [balance, setBalance] = useState<number | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -72,6 +72,22 @@ export default function NewOrderPage() {
} }
}, [serviceId]) }, [serviceId])
useEffect(() => {
let cancelled = false
void requestWithAuth(() => getWalletBalance())
.then((nextBalance) => {
if (!cancelled) setBalance(nextBalance ?? null)
})
.catch(() => {
if (!cancelled) setBalance(null)
})
return () => {
cancelled = true
}
}, [])
const [quantity, setQuantity] = useState(1) const [quantity, setQuantity] = useState(1)
const [note, setNote] = useState("") const [note, setNote] = useState("")
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
@@ -213,9 +229,11 @@ export default function NewOrderPage() {
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<CreditCard className="h-4 w-4" /> <CreditCard className="h-4 w-4" />
<span>: ¥{balance.toFixed(2)}</span> <span>: {balance === null ? "--" : `¥${balance.toFixed(2)}`}</span>
{balance < totalPrice && <span className="text-destructive"></span>} {balance !== null && balance < totalPrice && (
{balance < totalPrice && ( <span className="text-destructive"></span>
)}
{balance !== null && balance < totalPrice && (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href="/wallet"></Link> <Link href="/wallet"></Link>
</Button> </Button>
@@ -231,7 +249,7 @@ export default function NewOrderPage() {
<Button <Button
className="w-full" className="w-full"
size="lg" size="lg"
disabled={balance < totalPrice} disabled={balance === null || balance < totalPrice}
onClick={() => onClick={() =>
requireAuth(() => { requireAuth(() => {
void Promise.resolve( void Promise.resolve(
@@ -243,9 +261,11 @@ export default function NewOrderPage() {
note, note,
}), }),
).then((result) => { ).then((result) => {
if (!result.decision.ok || !result.order) { if (!result.decision.ok) {
notifyInfo(result.decision.error.msg)
return return
} }
if (!result.order) return
const nextOrder = result.order const nextOrder = result.order
setSubmitted(true) setSubmitted(true)