feat(wallet): add runtime balance flow and role-gated order posting

This commit is contained in:
zetaloop
2026-02-22 08:17:31 +08:00
parent ea822aaa8d
commit dc629c9472
4 changed files with 207 additions and 26 deletions
+63 -11
View File
@@ -8,13 +8,15 @@ import {
RefreshCw,
Wallet,
} from "lucide-react"
import { useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { mockTransactions, walletBalance } from "@/lib/mock"
import { notifySuccess } from "@/lib/toast"
import { useAuthStore } from "@/store/auth"
import { useWalletStore } from "@/store/wallet"
const typeLabels: Record<string, string> = {
topup: "充值",
@@ -35,18 +37,42 @@ const typeIcons: Record<string, typeof ArrowUpRight> = {
export default function WalletPage() {
const { currentRole } = useAuthStore()
const isConsumer = currentRole === "consumer"
const balance = useWalletStore((state) => state.balance)
const transactions = useWalletStore((state) => state.transactions)
const topUp = useWalletStore((state) => state.topUp)
const withdraw = useWalletStore((state) => state.withdraw)
const [selectedAmount, setSelectedAmount] = useState<number | null>(null)
const [customAmount, setCustomAmount] = useState("")
const [lastRefreshedAt, setLastRefreshedAt] = useState<string | null>(null)
const filteredTransactions = mockTransactions.filter((tx) => {
const filteredTransactions = transactions.filter((tx) => {
if (isConsumer) {
return ["topup", "payment", "refund"].includes(tx.type)
}
return ["income", "withdrawal"].includes(tx.type)
})
const incomeBalance = mockTransactions
.filter((tx) => tx.type === "income")
const incomeBalance = transactions
.filter((tx) => ["income", "withdrawal"].includes(tx.type))
.reduce((acc, tx) => acc + tx.amount, 0)
const handleTopUp = (rawAmount?: number) => {
const amount = rawAmount ?? Number(customAmount)
if (!Number.isFinite(amount) || amount <= 0) return
topUp(amount)
notifySuccess(`充值成功 +¥${amount}`)
setCustomAmount("")
setSelectedAmount(null)
}
const handleWithdraw = () => {
const amount = Number(customAmount || "0") || Number(incomeBalance.toFixed(2))
if (!Number.isFinite(amount) || amount <= 0) return
withdraw(amount)
notifySuccess(`提现申请已提交 ¥${amount}`)
setCustomAmount("")
}
return (
<div className="max-w-2xl space-y-6">
<h1 className="text-2xl font-bold"></h1>
@@ -59,19 +85,19 @@ export default function WalletPage() {
{isConsumer ? "账户余额" : "收入余额"}
</p>
<p className="text-3xl font-bold mt-1">
¥{isConsumer ? walletBalance.toFixed(2) : incomeBalance.toFixed(2)}
¥{isConsumer ? balance.toFixed(2) : incomeBalance.toFixed(2)}
</p>
</div>
<Wallet className="h-10 w-10 text-muted-foreground" />
</div>
<div className="flex gap-2 mt-4">
{isConsumer ? (
<Button>
<Button onClick={() => handleTopUp(selectedAmount ?? undefined)}>
<DollarSign className="mr-1 h-4 w-4" />
</Button>
) : (
<Button variant="outline">
<Button variant="outline" onClick={handleWithdraw}>
<CreditCard className="mr-1 h-4 w-4" />
</Button>
@@ -88,15 +114,31 @@ export default function WalletPage() {
<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="outline" className="h-12">
<Button
key={`amount-${amount}`}
variant={selectedAmount === amount ? "default" : "outline"}
className="h-12"
onClick={() => {
setSelectedAmount(amount)
setCustomAmount(amount.toString())
}}
>
¥{amount}
</Button>
))}
</div>
<Separator />
<div className="flex gap-2">
<Input placeholder="自定义金额" type="number" />
<Button></Button>
<Input
placeholder="自定义金额"
type="number"
value={customAmount}
onChange={(event) => {
setCustomAmount(event.target.value)
setSelectedAmount(null)
}}
/>
<Button onClick={() => handleTopUp()}></Button>
</div>
</CardContent>
</Card>
@@ -127,12 +169,22 @@ export default function WalletPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button variant="ghost" size="sm">
<Button
variant="ghost"
size="sm"
onClick={() => {
setLastRefreshedAt(new Date().toLocaleTimeString("zh-CN"))
notifySuccess("交易记录已刷新")
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="space-y-3">
{lastRefreshedAt && (
<p className="text-xs text-muted-foreground">{lastRefreshedAt}</p>
)}
{filteredTransactions.length > 0 ? (
filteredTransactions.map((tx) => {
const Icon = typeIcons[tx.type]
+19 -6
View File
@@ -4,7 +4,7 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"
import { ArrowLeft, ImagePlus, X } from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Badge } from "@/components/ui/badge"
@@ -20,8 +20,10 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { mockOrders, mockPosts } from "@/lib/mock"
import { mockPosts } from "@/lib/mock"
import { useRequireAuth } from "@/lib/use-require-auth"
import { useAuthStore } from "@/store/auth"
import { useOrderStore } from "@/store/orders"
const postSchema = z.object({
title: z.string().min(2, "标题至少2个字符").max(50, "标题最多50个字符"),
@@ -33,6 +35,9 @@ const tagOptions = ["英雄联盟", "王者荣耀", "CS2", "原神", "上分", "
export default function NewPostPage() {
const router = useRouter()
const { isAuthenticated, requireAuth } = useRequireAuth()
const currentRole = useAuthStore((state) => state.currentRole)
const userId = useAuthStore((state) => state.user?.id)
const orders = useOrderStore((state) => state.orders)
const [postType, setPostType] = useState("normal")
const [selectedTags, setSelectedTags] = useState<string[]>([])
const [imageCount, setImageCount] = useState(0)
@@ -53,6 +58,16 @@ export default function NewPostPage() {
)
}
useEffect(() => {
if (currentRole !== "consumer" && postType === "show_order") {
setPostType("normal")
}
}, [currentRole, postType])
const availableOrders = orders.filter(
(order) => order.status === "completed" && order.consumerId === userId,
)
const onSubmit = async () => {
if (!isAuthenticated) {
requireAuth(() => undefined)
@@ -87,7 +102,7 @@ export default function NewPostPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="normal"></SelectItem>
<SelectItem value="show_order"></SelectItem>
{currentRole === "consumer" && <SelectItem value="show_order"></SelectItem>}
<SelectItem value="quote"></SelectItem>
</SelectContent>
</Select>
@@ -101,9 +116,7 @@ export default function NewPostPage() {
<SelectValue placeholder="选择要展示的订单" />
</SelectTrigger>
<SelectContent>
{mockOrders
.filter((order) => order.status === "completed")
.map((order) => (
{availableOrders.map((order) => (
<SelectItem key={order.id} value={order.id}>
{order.service.title} · {order.playerName}
</SelectItem>
+13 -5
View File
@@ -11,12 +11,13 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { mockPlayers, mockServices, walletBalance } from "@/lib/mock"
import { mockPlayers, mockServices } from "@/lib/mock"
import { notifySuccess } from "@/lib/toast"
import { useRequireAuth } from "@/lib/use-require-auth"
import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat"
import { useOrderStore } from "@/store/orders"
import { useWalletStore } from "@/store/wallet"
export default function NewOrderPage() {
const router = useRouter()
@@ -24,6 +25,8 @@ export default function NewOrderPage() {
const { requireAuth } = useRequireAuth()
const createOrder = useOrderStore((state) => state.createOrder)
const ensureOrderSession = useChatStore((state) => state.ensureOrderSession)
const balance = useWalletStore((state) => state.balance)
const deductBalance = useWalletStore((state) => state.deductBalance)
const serviceId = searchParams.get("serviceId")
const service = mockServices.find((s) => s.id === serviceId)
@@ -163,9 +166,9 @@ export default function NewOrderPage() {
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CreditCard className="h-4 w-4" />
<span>: ¥{walletBalance}</span>
{walletBalance < totalPrice && <span className="text-destructive"></span>}
{walletBalance < totalPrice && (
<span>: ¥{balance.toFixed(2)}</span>
{balance < totalPrice && <span className="text-destructive"></span>}
{balance < totalPrice && (
<Button variant="outline" size="sm" asChild>
<Link href="/wallet"></Link>
</Button>
@@ -181,7 +184,7 @@ export default function NewOrderPage() {
<Button
className="w-full"
size="lg"
disabled={walletBalance < totalPrice}
disabled={balance < totalPrice}
onClick={() =>
requireAuth(async () => {
await new Promise((resolve) => setTimeout(resolve, 500))
@@ -201,6 +204,11 @@ export default function NewOrderPage() {
status: "pending_accept",
})
const paid = deductBalance(order.id, totalPrice)
if (!paid) {
return
}
ensureOrderSession(order)
setSubmitted(true)
notifySuccess("下单成功")
+108
View File
@@ -0,0 +1,108 @@
import { create } from "zustand"
import { generateId } from "@/lib/id"
import { mockTransactions, walletBalance } from "@/lib/mock"
import type { WalletTransaction } from "@/lib/types"
interface WalletState {
balance: number
transactions: WalletTransaction[]
topUp: (amount: number) => void
withdraw: (amount: number) => void
deductBalance: (orderId: string, amount: number) => boolean
addIncome: (orderId: string, amount: number) => void
addTransaction: (transaction: WalletTransaction) => void
}
export const useWalletStore = create<WalletState>((set, get) => ({
balance: walletBalance,
transactions: mockTransactions,
topUp: (amount) => {
if (!Number.isFinite(amount) || amount <= 0) return
const now = new Date().toISOString()
set((state) => ({
balance: state.balance + amount,
transactions: [
{
id: generateId("tx"),
type: "topup",
amount,
description: "充值",
createdAt: now,
},
...state.transactions,
],
}))
},
withdraw: (amount) => {
if (!Number.isFinite(amount) || amount <= 0) return
const now = new Date().toISOString()
set((state) => ({
transactions: [
{
id: generateId("tx"),
type: "withdrawal",
amount: -amount,
description: "提现到银行卡",
createdAt: now,
},
...state.transactions,
],
}))
},
deductBalance: (orderId, amount) => {
if (!Number.isFinite(amount) || amount <= 0) return false
const state = get()
const paid = state.transactions.some(
(transaction) => transaction.type === "payment" && transaction.description.includes(orderId),
)
if (paid || state.balance < amount) {
return false
}
const now = new Date().toISOString()
set((prev) => ({
balance: prev.balance - amount,
transactions: [
{
id: generateId("tx"),
type: "payment",
amount: -amount,
description: `支付订单 ${orderId}`,
createdAt: now,
},
...prev.transactions,
],
}))
return true
},
addIncome: (orderId, amount) => {
if (!Number.isFinite(amount) || amount <= 0) return
const state = get()
const exists = state.transactions.some(
(transaction) => transaction.type === "income" && transaction.description.includes(orderId),
)
if (exists) return
const now = new Date().toISOString()
const income = Number((amount * 0.85).toFixed(2))
set((prev) => ({
transactions: [
{
id: generateId("tx"),
type: "income",
amount: income,
description: `订单 ${orderId} 收入(扣除15%抽成)`,
createdAt: now,
},
...prev.transactions,
],
}))
},
addTransaction: (transaction) => {
set((state) => ({
transactions: [transaction, ...state.transactions],
}))
},
}))