更新登录功能
This commit is contained in:
935
src/home/product/buy.tsx
Normal file
935
src/home/product/buy.tsx
Normal file
@@ -0,0 +1,935 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import type { CreateOrderData } from "@/api/order"
|
||||
import { checkPayStatus, createOrder, createTestAccount } from "@/api/order"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import Wrap from "@/components/wrap"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ============ 工具函数 ============
|
||||
function randomChars(length: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function randomDigits(length: number): string {
|
||||
let result = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += Math.floor(Math.random() * 10).toString()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 类型定义 ============
|
||||
type PaymentState = {
|
||||
orderData: CreateOrderData
|
||||
polling: boolean
|
||||
}
|
||||
|
||||
// ============ 子组件 ============
|
||||
|
||||
// 价格卡片组件
|
||||
function PriceCard({ product, isTest }: { product: Product; isTest: boolean }) {
|
||||
return (
|
||||
<div className="bg-linear-to-r from-orange-50 to-amber-50 rounded-2xl p-5 mb-6 flex text-center border border-orange-100/60">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">套餐价格</span>
|
||||
{isTest ? (
|
||||
<span className="text-3xl font-bold text-green-500">免费</span>
|
||||
) : (
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-orange-500">
|
||||
¥{product.price.toFixed(2)}
|
||||
</span>
|
||||
{product.originalPrice !== undefined &&
|
||||
product.originalPrice > product.price && (
|
||||
<span className="text-sm text-gray-400 line-through mb-0.5">
|
||||
¥{product.originalPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{product.duration && (
|
||||
<span className="text-sm text-gray-500 bg-white/60 px-4 py-1.5 rounded-full">
|
||||
{product.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 模式切换 Tab 组件
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 mb-6">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2.5 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "single"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("single")}
|
||||
>
|
||||
单个注册
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2.5 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "batch"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("batch")}
|
||||
>
|
||||
批量注册
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 连接数选择器组件
|
||||
function ConnectCountSelector({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
label: string
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full"
|
||||
disabled={value <= 1}
|
||||
onClick={() => onChange(Math.max(1, value - 1))}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 12H4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="w-12 text-center text-xl font-semibold text-gray-700">
|
||||
{value}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full"
|
||||
onClick={() => onChange(value + 1)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{hint && <span className="text-xs text-gray-400 ml-1">{hint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 余额展示组件
|
||||
function BalanceDisplay({ balance }: { balance: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-5 bg-gray-50/80 rounded-xl border border-gray-100">
|
||||
<span className="text-sm text-gray-600">余额</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">
|
||||
¥{balance.toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
>
|
||||
去充值 →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 支付方式选择组件
|
||||
function PaymentMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
支付方式
|
||||
</Label>
|
||||
<RadioGroup value={value} onValueChange={onChange} className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 金额汇总组件
|
||||
function PriceSummary({ total }: { total: number }) {
|
||||
return (
|
||||
<div className="border-t border-gray-100 pt-4 mt-2 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">总金额</span>
|
||||
<span className="font-medium text-gray-800">¥{total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">应付款</span>
|
||||
<span className="text-2xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 表单操作按钮组件
|
||||
function FormActions({
|
||||
onBack,
|
||||
onSubmit,
|
||||
submitText,
|
||||
submitting,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
submitText: string
|
||||
submitting: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-12 rounded-xl text-base border-gray-200 hover:bg-gray-50"
|
||||
onClick={onBack}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 hover:shadow-lg hover:shadow-blue-200/50 transition-all"
|
||||
onClick={onSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : submitText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 优惠券选择组件
|
||||
function CouponSelector() {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
选择优惠券
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="h-12 rounded-xl">
|
||||
<SelectValue placeholder="请选择优惠券" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">暂无可用优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 单个注册表单组件 ============
|
||||
function SingleRegisterForm({
|
||||
account,
|
||||
setAccount,
|
||||
pwd,
|
||||
setPwd,
|
||||
connectCount,
|
||||
setConnectCount,
|
||||
payType,
|
||||
setPayType,
|
||||
isTest,
|
||||
isDayCard,
|
||||
isNormalPackage,
|
||||
total,
|
||||
submitting,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
product: Product
|
||||
account: string
|
||||
setAccount: (v: string) => void
|
||||
pwd: string
|
||||
setPwd: (v: string) => void
|
||||
connectCount: number
|
||||
setConnectCount: (v: number) => void
|
||||
payType: string
|
||||
setPayType: (v: string) => void
|
||||
isTest: boolean
|
||||
isDayCard: boolean
|
||||
isNormalPackage: boolean
|
||||
total: number
|
||||
submitting: boolean
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 天卡提示 */}
|
||||
{isDayCard && (
|
||||
<div className="text-sm text-orange-500 text-center font-medium bg-orange-50 py-2.5 rounded-xl border border-orange-100">
|
||||
⚠️ 天卡不支持退款,请谨慎购买
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品账号
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">4至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{/* IP密码 */}
|
||||
<div className="flex">
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品密码
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">1至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{isNormalPackage && (
|
||||
<>
|
||||
<ConnectCountSelector
|
||||
value={connectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
/>
|
||||
|
||||
<CouponSelector />
|
||||
|
||||
<BalanceDisplay balance={10.0} />
|
||||
|
||||
<PaymentMethodSelector value={payType} onChange={setPayType} />
|
||||
|
||||
<PriceSummary total={total} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormActions
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
submitText={isTest ? "领取试用" : "确认支付"}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册表单组件 ============
|
||||
function BatchRegisterForm({
|
||||
batchAccount,
|
||||
setBatchAccount,
|
||||
batchStart,
|
||||
setBatchStart,
|
||||
batchCount,
|
||||
setBatchCount,
|
||||
batchPwd,
|
||||
setBatchPwd,
|
||||
batchConnectCount,
|
||||
setBatchConnectCount,
|
||||
batchPayType,
|
||||
setBatchPayType,
|
||||
batchPreview,
|
||||
total,
|
||||
submitting,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
batchAccount: string
|
||||
setBatchAccount: (v: string) => void
|
||||
batchStart: number
|
||||
setBatchStart: (v: number) => void
|
||||
batchCount: number
|
||||
setBatchCount: (v: number) => void
|
||||
batchPwd: string
|
||||
setBatchPwd: (v: string) => void
|
||||
batchConnectCount: number
|
||||
setBatchConnectCount: (v: number) => void
|
||||
batchPayType: string
|
||||
setBatchPayType: (v: string) => void
|
||||
batchPreview: string
|
||||
total: number
|
||||
submitting: boolean
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 批量说明 */}
|
||||
<div className="text-sm text-gray-600 leading-relaxed p-4 bg-blue-50/70 rounded-xl border border-blue-100">
|
||||
<p>
|
||||
批量注册的账号会使用【账号前缀】+【开始数】+【个数】顺序进行注册,
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
如:注册账号前缀为【user】开始数为【2】个数为【10】,则注册的账号为:
|
||||
<span className="text-blue-600 font-medium">
|
||||
{" "}
|
||||
user2,user3,user4,....user11
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP账号前缀
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">3至8位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
开始号
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">本批次账号的起始账号</p>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
注册个数
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">本批次的账号个数</p>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="text-sm text-gray-600 p-3.5 bg-gray-50/80 rounded-xl border border-gray-100">
|
||||
即将生成的账号:
|
||||
<span className="font-medium text-gray-800">{batchPreview}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP密码 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品密码
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">1至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
|
||||
<CouponSelector />
|
||||
|
||||
<BalanceDisplay balance={10.0} />
|
||||
|
||||
<PaymentMethodSelector value={batchPayType} onChange={setBatchPayType} />
|
||||
|
||||
<PriceSummary total={total} />
|
||||
|
||||
<FormActions
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
submitText="确认支付"
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 支付弹窗组件 ============
|
||||
function PaymentDialog({
|
||||
payment,
|
||||
onClose,
|
||||
}: {
|
||||
payment: PaymentState | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!payment) return null
|
||||
|
||||
const isWechat = payment.orderData.OrderInfo.PayType === 70
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-lg">
|
||||
{isWechat ? "微信支付" : "支付宝支付"} | 收银台
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex justify-center">
|
||||
{isWechat ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(payment.orderData.PayData)}`}
|
||||
alt="微信支付二维码"
|
||||
className="w-50 h-50 rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: 支付宝表单由后端返回
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: payment.orderData.PayData,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-3 space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">收款方</span>
|
||||
<span className="font-medium">聚IP</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">金额</span>
|
||||
<span className="font-bold text-orange-500">
|
||||
¥{payment.orderData.OrderInfo.OtherPayAmount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">下单时间</span>
|
||||
<span className="text-gray-600">
|
||||
{payment.orderData.OrderInfo.CreateTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{payment.orderData.OrderInfo.OrderNo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-400">
|
||||
支付完成后将自动关闭此窗口
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BuyPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const product = location.state?.product as Product | undefined
|
||||
|
||||
// 状态
|
||||
const [mode, setMode] = useState<"single" | "batch">("single")
|
||||
const [account, setAccount] = useState("")
|
||||
const [pwd, setPwd] = useState("")
|
||||
const [connectCount, setConnectCount] = useState(1)
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [payment, setPayment] = useState<PaymentState | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
const [batchPayType, setBatchPayType] = useState("100")
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
if (!product) {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setConnectCount(1)
|
||||
setPayType("100")
|
||||
setMode("single")
|
||||
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchStart(1)
|
||||
setBatchCount(1)
|
||||
setBatchPwd(pw)
|
||||
setBatchConnectCount(1)
|
||||
setBatchPayType("100")
|
||||
}, [product, navigate])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = useCallback(
|
||||
(orderNo: string) => {
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
const result = await checkPayStatus(orderNo)
|
||||
if (result.success && result.data === 1) {
|
||||
stopPolling()
|
||||
setPayment(null)
|
||||
toast.success("支付成功!")
|
||||
}
|
||||
}, 3000)
|
||||
},
|
||||
[stopPolling],
|
||||
)
|
||||
|
||||
if (!product) return null
|
||||
|
||||
// 计算属性
|
||||
const price = product.price
|
||||
const packageId = Number(product.id)
|
||||
const isTest = product.isTest ?? false
|
||||
const isDayCard = product.card === "天"
|
||||
const showTabs = !isTest && !isDayCard
|
||||
const isNormalPackage = !isTest && !isDayCard
|
||||
|
||||
const singleTotal = price * connectCount
|
||||
const batchTotal = price * batchConnectCount * batchCount
|
||||
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 3) {
|
||||
const items: string[] = []
|
||||
for (let i = batchStart; i <= end; i++) {
|
||||
items.push(`${batchAccount}${i}`)
|
||||
}
|
||||
return items.join(", ")
|
||||
}
|
||||
return `${batchAccount}${batchStart}, ${batchAccount}${batchStart + 1}, ${batchAccount}${batchStart + 2}...${batchAccount}${end}`
|
||||
})()
|
||||
|
||||
// 支付处理函数
|
||||
const handleSinglePay = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oPayType = isTest ? 0 : Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
|
||||
const result = await createOrder({
|
||||
PackageId: packageId,
|
||||
OrderType: 1,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: connectCount,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
if (result.data === "00") {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
const orderData = result.data as CreateOrderData
|
||||
setPayment({ orderData, polling: true })
|
||||
startPolling(orderData.OrderInfo.OrderNo)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchPay = async () => {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oPayType = Number(batchPayType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
|
||||
const result = await createOrder({
|
||||
PackageId: packageId,
|
||||
OrderType: 2,
|
||||
Account: batchAccount.trim(),
|
||||
Pwd: batchPwd.trim(),
|
||||
ConnectCount: batchConnectCount,
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
if (result.data === "00") {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
const orderData = result.data as CreateOrderData
|
||||
setPayment({ orderData, polling: true })
|
||||
startPolling(orderData.OrderInfo.OrderNo)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestClaim = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createTestAccount({
|
||||
ProductId: product.productId,
|
||||
PackageId: packageId,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
})
|
||||
if (result.success) {
|
||||
toast.success("领取成功")
|
||||
navigate(-1)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClosePayment = () => {
|
||||
stopPolling()
|
||||
setPayment(null)
|
||||
}
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 判断单个表单的提交处理
|
||||
const handleSingleSubmit = isTest ? handleTestClaim : handleSinglePay
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen">
|
||||
<div className="text-center mt-20">
|
||||
<h4 className="text-2xl font-bold text-gray-800">{product.title}</h4>
|
||||
<p className="text-sm text-amber-600 mb-6">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 价格卡片 */}
|
||||
<PriceCard product={product} isTest={isTest} />
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && <ModeTabs mode={mode} onModeChange={setMode} />}
|
||||
|
||||
{/* 表单内容 */}
|
||||
{!showTabs || mode === "single" ? (
|
||||
<SingleRegisterForm
|
||||
product={product}
|
||||
account={account}
|
||||
setAccount={setAccount}
|
||||
pwd={pwd}
|
||||
setPwd={setPwd}
|
||||
connectCount={connectCount}
|
||||
setConnectCount={setConnectCount}
|
||||
payType={payType}
|
||||
setPayType={setPayType}
|
||||
isTest={isTest}
|
||||
isDayCard={isDayCard}
|
||||
isNormalPackage={isNormalPackage}
|
||||
total={singleTotal}
|
||||
submitting={submitting}
|
||||
onBack={handleBack}
|
||||
onSubmit={handleSingleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<BatchRegisterForm
|
||||
batchAccount={batchAccount}
|
||||
setBatchAccount={setBatchAccount}
|
||||
batchStart={batchStart}
|
||||
setBatchStart={setBatchStart}
|
||||
batchCount={batchCount}
|
||||
setBatchCount={setBatchCount}
|
||||
batchPwd={batchPwd}
|
||||
setBatchPwd={setBatchPwd}
|
||||
batchConnectCount={batchConnectCount}
|
||||
setBatchConnectCount={setBatchConnectCount}
|
||||
batchPayType={batchPayType}
|
||||
setBatchPayType={setBatchPayType}
|
||||
batchPreview={batchPreview}
|
||||
total={batchTotal}
|
||||
submitting={submitting}
|
||||
onBack={handleBack}
|
||||
onSubmit={handleBatchPay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 支付弹窗 */}
|
||||
<PaymentDialog payment={payment} onClose={handleClosePayment} />
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { fetchProducts } from "@/api/product"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { PRODUCT_DATA } from "@/lib/models/product"
|
||||
import type { Tab } from "@/lib/models/product"
|
||||
import { transformToTabs } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
import ProductGrid from "./productGrid"
|
||||
import ProductSidebar from "./productSidebar"
|
||||
@@ -18,45 +20,93 @@ const TAB_ALIAS: Record<string, string> = {
|
||||
|
||||
export default function ProductPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [activeVersionId, setActiveVersionId] = useState<string>("v1")
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [activeBrandId, setActiveBrandId] = useState("")
|
||||
const [selectedVersionId, setSelectedVersionId] = useState("")
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts().then(result => {
|
||||
if (result.success) {
|
||||
const newTabs = transformToTabs(result.data)
|
||||
setTabs(newTabs)
|
||||
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
||||
setActiveBrandId(newTabs[0].brands[0].id)
|
||||
initializedRef.current = true
|
||||
}
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const urlTab = searchParams.get("tab") || "dynamic"
|
||||
const activeTabId = TAB_ALIAS[urlTab] || urlTab
|
||||
const activeBrandId = searchParams.get("brand") || ""
|
||||
|
||||
const currentTab =
|
||||
PRODUCT_DATA.find(t => t.id === activeTabId) || PRODUCT_DATA[0]
|
||||
const currentTab = tabs.find(t => t.id === activeTabId) || tabs[0]
|
||||
const currentBrand =
|
||||
currentTab.brands.find(b => b.id === activeBrandId) || currentTab.brands[0]
|
||||
currentTab?.brands.find(b => b.id === activeBrandId) ||
|
||||
currentTab?.brands[0]
|
||||
|
||||
const effectiveVersionId =
|
||||
selectedVersionId || currentBrand?.versions?.[0]?.id || ""
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
const targetTab = PRODUCT_DATA.find(t => t.id === tabId)
|
||||
setSearchParams({ tab: tabId })
|
||||
const targetTab = tabs.find(t => t.id === tabId)
|
||||
const firstBrand = targetTab?.brands[0]
|
||||
setSearchParams({ tab: tabId, brand: firstBrand?.id || "" })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(firstBrand?.id || "")
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const handleBrandSelect = (brandId: string) => {
|
||||
setSearchParams({ tab: activeTabId, brand: brandId })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(brandId)
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const getDisplayProducts = () => {
|
||||
const displayProducts = (() => {
|
||||
if (!currentBrand) return []
|
||||
|
||||
if (currentBrand.versions && currentBrand.versions.length > 0) {
|
||||
return currentBrand.products.filter(p => p.version === activeVersionId)
|
||||
return currentBrand.products.filter(p => p.version === effectiveVersionId)
|
||||
}
|
||||
return currentBrand.products
|
||||
})()
|
||||
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">加载中...</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-red-400">加载失败:{error}</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">暂无产品数据</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const displayProducts = getDisplayProducts()
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-16 pt-30 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<ProductTabs
|
||||
tabs={PRODUCT_DATA}
|
||||
tabs={tabs}
|
||||
activeId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
@@ -73,7 +123,6 @@ export default function ProductPage() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{!isPurchaseInfo ? (
|
||||
<>
|
||||
{" "}
|
||||
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
@@ -105,10 +154,10 @@ export default function ProductPage() {
|
||||
{currentBrand.versions.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setActiveVersionId(v.id)}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||
activeVersionId === v.id
|
||||
effectiveVersionId === v.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import vector from "../_assets/vector.webp"
|
||||
@@ -7,6 +8,8 @@ type ProductGridProps = {
|
||||
}
|
||||
|
||||
export default function ProductGrid({ products }: ProductGridProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||
{products.map(product => (
|
||||
@@ -72,6 +75,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => navigate("/product/buy", { state: { product } })}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user