fix(wallet): persist balance actions through backend
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user