From aefbb8143e74d2803684b767b4d194f81c5a4e0a Mon Sep 17 00:00:00 2001 From: Eamon <17516219072@163.com> Date: Mon, 14 Sep 2026 13:37:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=E7=9A=84?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E5=85=85=E5=80=BC=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/admin/dashboard/page.tsx | 6 +- src/api/base.ts | 1 + src/api/order.ts | 8 +- .../composites/dialogs/rechargeDialog.tsx | 296 ++++++++++++++---- src/home/login/index.tsx | 10 +- src/home/pay/index.tsx | 136 +------- src/home/product/http.tsx | 103 +++++- src/lib/api.ts | 2 + src/lib/models/http.tsx | 3 +- 9 files changed, 363 insertions(+), 202 deletions(-) diff --git a/src/admin/dashboard/page.tsx b/src/admin/dashboard/page.tsx index d4b472d..93b77a5 100644 --- a/src/admin/dashboard/page.tsx +++ b/src/admin/dashboard/page.tsx @@ -407,7 +407,11 @@ export default function DashboardPage() { onOpenChange={setRealNameOpen} onVerified={refresh} /> - + ( return { success: false, status: response.status, + code: json.Code, message: json.Message || "请求失败", } } diff --git a/src/api/order.ts b/src/api/order.ts index b02144f..36ee77a 100644 --- a/src/api/order.ts +++ b/src/api/order.ts @@ -167,6 +167,8 @@ export async function createRechargeOrder(data: { } // 轮询充值支付状态:返回 true 表示支付成功 +// 新后端 /user/IsPay 响应体:{ Code: 0|1, Message: "" },Code 本身即支付状态 +// 兼容旧格式 { Code: 10000, Data: 0|1 } export async function checkRechargeStatus( orderNo: string, ): Promise> { @@ -175,6 +177,8 @@ export async function checkRechargeStatus( undefined, "GET", ) - if (!res.success) return res - return { success: true, data: res.data === 1 } + if (res.success) return { success: true, data: res.data === 1 } + if (res.code === 1) return { success: true, data: true } + if (res.code === 0) return { success: true, data: false } + return res } diff --git a/src/components/composites/dialogs/rechargeDialog.tsx b/src/components/composites/dialogs/rechargeDialog.tsx index d44b7b1..fae07fd 100644 --- a/src/components/composites/dialogs/rechargeDialog.tsx +++ b/src/components/composites/dialogs/rechargeDialog.tsx @@ -1,5 +1,12 @@ -import { useCallback, useEffect, useState } from "react" +import { CheckCircle2, Loader2, XCircle } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" import { toast } from "sonner" +import { + checkRechargeStatus, + createRechargeOrder, + type RechargeOrderResult, +} from "@/api/order" +import { QrCodeImage } from "@/components/qrCodeImage" import { Button } from "@/components/ui/button" import { Dialog, @@ -15,37 +22,258 @@ import wechat from "@/home/_assets/wechat.svg" type PayChannel = "50" | "30" +const POLL_INTERVAL = 3000 + interface RechargeDialogProps { open: boolean onOpenChange: (open: boolean) => void + // 支付成功后回调(刷新余额等) + onPaid?: () => void } -export function RechargeDialog({ open, onOpenChange }: RechargeDialogProps) { +// 余额充值(与官网 juipnet User/Index.cshtml 一致): +// - 下单 POST /user/CreateOrder +// - 支付宝(50):PayData 为自动提交表单,渲染后自动跳转支付宝网关 +// - 微信(30):PayData 为扫码链接,直接渲染二维码,轮询 /user/IsPay +export function RechargeDialog({ + open, + onOpenChange, + onPaid, +}: RechargeDialogProps) { const [amount, setAmount] = useState("100") const [payChannel, setPayChannel] = useState("50") + const [order, setOrder] = useState(null) + const [creating, setCreating] = useState(false) + const [orderError, setOrderError] = useState("") + const [paid, setPaid] = useState(false) + const [failCount, setFailCount] = useState(0) + + const alipayBoxRef = useRef(null) + const alipaySubmittedRef = useRef(false) + const reset = useCallback(() => { setAmount("100") setPayChannel("50") + setOrder(null) + setCreating(false) + setOrderError("") + setPaid(false) + setFailCount(0) + alipaySubmittedRef.current = false }, []) useEffect(() => { if (!open) reset() }, [open, reset]) - const handleGoPay = () => { + const handleGoPay = async () => { const chargeAmount = Number(amount) if (!Number.isFinite(chargeAmount) || chargeAmount <= 0) { toast.error("请输入正确的充值金额") return } - window.open( - `/pay?amount=${chargeAmount}&channel=${payChannel}`, - "_blank", - "noopener,noreferrer", + setCreating(true) + setOrderError("") + const res = await createRechargeOrder({ + ChargeAmount: chargeAmount, + PayChannel: Number(payChannel), + }) + setCreating(false) + if (!res.success) { + setOrderError(res.message) + return + } + if (!res.data?.PayData) { + setOrderError("下单失败,请稍后重试") + return + } + setOrder(res.data) + } + + // 支付宝:渲染 PayData 表单并自动提交,跳转支付宝网关 + useEffect(() => { + if (!open || payChannel !== "50" || !order || orderError) return + if (alipaySubmittedRef.current) return + alipaySubmittedRef.current = true + const box = alipayBoxRef.current + if (!box) return + box.innerHTML = order.PayData + box.querySelector("form")?.submit() + }, [open, payChannel, order, orderError]) + + // 微信:轮询 /user/IsPay 直到支付成功 + useEffect(() => { + if (!open || payChannel !== "30" || !order || paid) return + let stopped = false + + const poll = async () => { + const res = await checkRechargeStatus(order.OrderInfo.OrderNo) + if (stopped) return + if (res.success && res.data) { + setPaid(true) + toast.success("充值成功,余额已更新") + onPaid?.() + } else if (!res.success) { + setFailCount(count => count + 1) + } + } + + poll() + const timer = window.setInterval(poll, POLL_INTERVAL) + return () => { + stopped = true + window.clearInterval(timer) + } + }, [open, payChannel, order, paid, onPaid]) + + const isWechat = payChannel === "30" + + const renderBody = () => { + if (creating) { + return ( +
+ +

正在生成订单...

+
+ ) + } + + if (orderError) { + return ( +
+ +

{orderError}

+ +
+ ) + } + + if (order && !isWechat) { + return ( +
+ +

正在跳转支付宝支付...

+

+ 如未自动跳转,请检查浏览器是否拦截 +

+
+ ) + } + + if (order && isWechat) { + if (paid) { + return ( +
+ +

支付成功

+

余额已更新

+ +
+ ) + } + return ( +
+
+ 微信支付 + 微信支付 | 收银台 +
+ +
+
+ 应付金额 + + ¥{Number(order.OrderInfo.PaymentAmount ?? amount).toFixed(2)} + +
+
+ 收款方 + 聚IP +
+
+ 订单号 + + {order.OrderInfo.OrderNo} + +
+
+ +
+
+ +
+

+ 请使用微信扫码支付,支付完成后自动确认 +

+
+ + 正在等待支付结果 + {failCount > 2 && "(网络波动,请稍候)"} +
+
+
+ ) + } + + return ( +
+
+ + setAmount(e.target.value)} + className="h-11 bg-gray-50 border-gray-200" + placeholder="请输入充值金额" + /> +
+ +
+ + setPayChannel(value as PayChannel)} + className="flex gap-6" + > +
+ + +
+
+ + +
+
+
+ + +
) - onOpenChange(false) } return ( @@ -55,57 +283,9 @@ export function RechargeDialog({ open, onOpenChange }: RechargeDialogProps) { 账户充值 -
-
- - setAmount(e.target.value)} - className="h-11 bg-gray-50 border-gray-200" - placeholder="请输入充值金额" - /> -
+ {renderBody()} -
- - setPayChannel(value as PayChannel)} - className="flex gap-6" - > -
- - -
-
- - -
-
-
- - -
+
) diff --git a/src/home/login/index.tsx b/src/home/login/index.tsx index ec0048d..37a676a 100644 --- a/src/home/login/index.tsx +++ b/src/home/login/index.tsx @@ -92,9 +92,17 @@ export default function LoginPage() { function LoginForm() { const navigate = useNavigate() + const location = useLocation() const [showPassword, setShowPassword] = useState(false) const [submitting, setSubmitting] = useState(false) + // 登录成功后回跳来源页(仅接受站内路径) + const rawRedirect = new URLSearchParams(location.search).get("redirect") + const redirect = + rawRedirect?.startsWith("/") && !rawRedirect.startsWith("//") + ? rawRedirect + : "/" + const form = useForm({ resolver: zodResolver(loginSchema), defaultValues: { @@ -116,7 +124,7 @@ function LoginForm() { toast.success("登录成功", { description: "欢迎回来!", }) - navigate("/") + navigate(redirect, { replace: true }) } else { toast.error(resp.message || "登录失败") } diff --git a/src/home/pay/index.tsx b/src/home/pay/index.tsx index ca1fa15..ccf2ae7 100644 --- a/src/home/pay/index.tsx +++ b/src/home/pay/index.tsx @@ -1,12 +1,6 @@ import { CheckCircle2, Loader2, XCircle } from "lucide-react" import { useCallback, useEffect, useState } from "react" -import { useSearchParams } from "react-router-dom" -import { - checkPayStatus, - checkRechargeStatus, - createRechargeOrder, - type RechargeOrderResult, -} from "@/api/order" +import { checkPayStatus } from "@/api/order" import { QrCodeImage } from "@/components/qrCodeImage" import RealNameCheck from "@/components/realNameCheck" import { Button } from "@/components/ui/button" @@ -16,10 +10,10 @@ import wechat from "../_assets/wechat.svg" const POLL_INTERVAL = 3000 -// 收银台(独立窗口): -// - 充值模式 /pay?amount=100&channel=30:页面内下单 /user/CreateOrder,轮询 /user/IsPay -// - 购买模式 /pay?mode=order:主页面已下单,读取 localStorage 支付数据,轮询 /product/IsPay -// 参考 juipnet Host/Views/User/Index.cshtml(charge)与 Product/buy.cshtml(payCallback/isPay) +// 购买收银台(独立窗口): +// 主页面下单成功后把支付数据写入 localStorage,本页读取并轮询 /product/IsPay +// 参考 juipnet Host/Views/Product/buy.cshtml(payCallback/isPay) +// (余额充值已改为个人中心页面内弹层,见 rechargeDialog.tsx) type PollState = "paid" | "pending" | "error" @@ -57,131 +51,13 @@ function usePayPolling( } export default function PayPage() { - const [searchParams] = useSearchParams() - const mode = searchParams.get("mode") - return ( <> - {mode === "order" ? : } + ) } - -// ==================== 充值模式 ==================== - -function RechargePay() { - const [searchParams] = useSearchParams() - const amountParam = Number(searchParams.get("amount")) - const channelParam = searchParams.get("channel") - - const valid = - Number.isFinite(amountParam) && - amountParam > 0 && - (channelParam === "50" || channelParam === "30") - - const [order, setOrder] = useState(null) - const [orderError, setOrderError] = useState("") - const [autoOpened, setAutoOpened] = useState(false) - - useEffect(() => { - if (!valid) return - let cancelled = false - createRechargeOrder({ - ChargeAmount: amountParam, - PayChannel: Number(channelParam), - }).then(res => { - if (cancelled) return - if (res.success) { - setOrder(res.data) - } else { - setOrderError(res.message) - } - }) - return () => { - cancelled = true - } - }, [valid, amountParam, channelParam]) - - useEffect(() => { - if (!order?.PayData || autoOpened) return - - // 支付宝:PayData 是支付链接或自动提交表单,直接跳转; - // 微信:交给 CashierLayout 判断(链接/表单跳转,weixin:// 扫码内容显示二维码) - if (channelParam !== "50") return - - setAutoOpened(true) - const payData = order.PayData - if (/^https?:\/\//i.test(payData)) { - window.location.href = payData - } else { - document.open() - document.write(payData) - document.close() - } - }, [order, channelParam, autoOpened]) - - const checkPaid = useCallback(async (orderNo: string): Promise => { - const res = await checkRechargeStatus(orderNo) - if (!res.success) return "error" - return res.data ? "paid" : "pending" - }, []) - - const { paid, failCount } = usePayPolling(order?.OrderInfo.OrderNo, checkPaid) - - const isAlipay = channelParam === "50" - - if (isAlipay) { - return ( -
-
- {orderError ? ( - <> - -

{orderError}

-

请回到原页面重新发起

- - ) : !order ? ( - <> - -

正在生成订单...

- - ) : paid ? ( - <> - -

支付成功

-

- 订单已生效,可以关闭本页面 -

- - ) : ( - <> - -

正在跳转支付宝...

-

- 如未自动跳转,请检查浏览器是否拦截 -

- - )} -
-
- ) - } - - return ( - - ) -} // ==================== 购买模式 ==================== function OrderPay() { diff --git a/src/home/product/http.tsx b/src/home/product/http.tsx index 5ff5ab7..5061a59 100644 --- a/src/home/product/http.tsx +++ b/src/home/product/http.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react" +import { useNavigate } from "react-router-dom" import { Toaster, toast } from "sonner" import { calcGamePrice, @@ -8,7 +9,9 @@ import { fetchGameList, fetchHttpBalance, } from "@/api/http" +import { QrCodeImage } from "@/components/qrCodeImage" import RealNameCheck from "@/components/realNameCheck" +import { useProfileStore } from "@/components/stores/profile" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" @@ -101,10 +104,20 @@ const DEFAULT_GAME: GameOrderData = { } export default function HttpPage() { + const navigate = useNavigate() + const token = useProfileStore(state => state.token) const [activeTab, setActiveTab] = useState("prepaid") const [customAmount, setCustomAmount] = useState("") const [customIpAmount, setCustomIpAmount] = useState("") + // 未登录时跳转登录页,登录后回跳本页(参考 juipnet 产品页做法) + const ensureLogin = () => { + if (token !== null) return true + toast.error("请先登录") + navigate(`/login?redirect=${encodeURIComponent("/product/http")}`) + return false + } + const [duration, setDuration] = useState(DURATION_OPTIONS[0].value) const [period, setPeriod] = useState(PERIOD_OPTIONS[0].value) const [periodCount, setPeriodCount] = useState(1) @@ -130,6 +143,10 @@ export default function HttpPage() { const isRecharge = true const [isLoading, setIsLoading] = useState(false) + // 微信扫码支付弹窗 + const [qrCodeOpen, setQrCodeOpen] = useState(false) + const [qrContent, setQrContent] = useState("") + // 长效游戏支付(H币) const [gamePayOpen, setGamePayOpen] = useState(false) const [balance, setBalance] = useState(0) @@ -265,6 +282,7 @@ export default function HttpPage() { } const openGamePay = async () => { + if (!ensureLogin()) return if (lineCountRef.current <= 0) { toast.error("可用IP为0!") return @@ -283,6 +301,7 @@ export default function HttpPage() { } const payGameOrder = async () => { + if (!ensureLogin()) return setGamePaying(true) const res = await createHttpOrder({ order_type: 5, @@ -307,6 +326,14 @@ export default function HttpPage() { } else { toast.error(result.msg || "支付宝支付数据异常") } + } else if (result.code === 3) { + if (result.data) { + setQrContent(result.data) + setQrCodeOpen(true) + setGamePayOpen(false) + } else { + toast.error(result.msg || "微信支付数据异常") + } } else { toast.error(result.msg || "支付失败") } @@ -351,18 +378,52 @@ export default function HttpPage() { const totalPrice = calculatePrice() // 处理支付 - const handlePay = () => { + const handlePay = async () => { + if (!ensureLogin()) return setIsLoading(true) - // 模拟支付请求 - setTimeout(() => { - setIsLoading(false) + const res = await createHttpOrder({ + order_type: 1, + money: payAmount, + pay_type: Number(payMethod), + data: {}, + }) + setIsLoading(false) + if (!res.success) { + toast.error(res.message) + return + } + const result = res.data + if (result.code === 1) { setPayDialogOpen(false) - toast.success(`支付成功!支付金额:¥${payAmount.toFixed(2)}`) - }, 1500) + toast.success(result.msg || "充值成功") + } else if (result.code === 2) { + if (result.data && alipayBoxRef.current) { + alipayBoxRef.current.innerHTML = result.data + alipayBoxRef.current.querySelector("form")?.submit() + setPayDialogOpen(false) + } else { + toast.error(result.msg || "支付宝支付数据异常") + } + } else if (result.code === 3) { + if (result.data) { + setQrContent(result.data) + setQrCodeOpen(true) + setPayDialogOpen(false) + } else { + toast.error(result.msg || "微信支付数据异常") + } + } else { + toast.error(result.msg || "支付失败") + } } - // 打开支付弹窗 + // 打开支付弹窗(仅预储值 tab 支持支付宝/微信在线充值) const openPayDialog = (amount?: number) => { + if (!ensureLogin()) return + if (activeTab !== "prepaid") { + toast.error("该套餐暂不支持在线支付,请联系客服购买") + return + } const effectivePrice = Number(amount ?? totalPrice) if (effectivePrice <= 0) { toast.error("请输入有效的充值金额") @@ -1030,6 +1091,30 @@ export default function HttpPage() { {/* 支付宝表单渲染容器 */}
+ + {/* 微信扫码支付弹窗 */} + + + + 微信扫码支付 + +
+
+ +
+

+ 请使用微信扫码支付 +

+ +
+
+
) @@ -1177,9 +1262,9 @@ function OptionBtn({ className={cn( "rounded-md text-sm font-medium transition-all", active && - "bg-linear-to-r from-blue-500 to-cyan-400 text-white border-transparent shadow-sm hover:opacity-90", + "bg-linear-to-r from-blue-500 to-cyan-400 text-white border-transparent shadow-sm hover:opacity-90", !active && - "border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-500", + "border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-500", className, )} > diff --git a/src/lib/api.ts b/src/lib/api.ts index ad70939..04770c5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -13,6 +13,8 @@ type ApiResponse = success: false status?: number message: string + // 后端业务码(非 10000 时透传),如 /user/IsPay 的 Code 0/1 即支付状态 + code?: number } | { success: true diff --git a/src/lib/models/http.tsx b/src/lib/models/http.tsx index e0105c8..bbb04bd 100644 --- a/src/lib/models/http.tsx +++ b/src/lib/models/http.tsx @@ -26,7 +26,8 @@ export type HttpOrderInfo = { order_type: number money: number pay_type: number - data: GameOrderData + // 预储值(order_type=1)下单时 data 为空对象 + data: GameOrderData | Record } export type CreateOrderResult = {