更新登录功能

This commit is contained in:
Eamon
2026-08-13 18:28:20 +08:00
parent 115ee3bfeb
commit 4235458bea
25 changed files with 1900 additions and 873 deletions

935
src/home/product/buy.tsx Normal file
View 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>
)
}

View File

@@ -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",
)}

View File

@@ -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>