更新登录功能

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

2
.env
View File

@@ -1,3 +1,3 @@
VITE_API_BASE_URL=http://192.168.3.42:8080 VITE_API_BASE_URL=
VITE_CLIENT_ID=web VITE_CLIENT_ID=web
VITE_CLIENT_SECRET=web VITE_CLIENT_SECRET=web

158
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,158 @@
# juip-web - AI 开发助手指南
## 项目概述
Vite + React 19 纯 SPA 代理服务站点,包含公开营销页与用户管理后台。使用 Bun 运行时、react-router-dom 7 路由、Tailwind CSS 4、shadcn/ui (radix-nova 风格)、sonner 提示、biome 代码规范。
**核心业务**: IP 代理产品展示与购买、订单支付、短信验证码注册登录、后台资源/通道/白名单管理
## 架构模式
### 三层数据流架构
1. **API 层** (`src/api/*.ts`) - 所有后端调用统一入口
- 每个文件对应一个业务域 (auth, order, product, verify, captcha)
- 通过 `base.ts` 的三种调用模式访问后端:
- `callPublic()` - 无需认证的公开接口
- `callByDevice()` - 使用 CLIENT_ID/CLIENT_SECRET 的设备级认证
- `callByUser()` - 使用用户会话 token 的用户级认证
- 所有调用返回统一的 `ApiResponse<T>` 类型
- 后端存在两套响应约定,`base.ts` 自动识别:
- 裸 JSON (OAuth2 接口、`/script/linedata/*.php` 等) - 原样返回
- 会员接口信封 `{Code, Message, Data}` - 顶层有 `Code` 字段时自动解包: `Code === 10000` 成功返回 `Data`/`data`,否则转 `success: false` + `Message`
2. **会话状态** (`src/api/token.ts`) - 令牌存取唯一入口
- 登录后 token/userInfo 由后端 Set-Cookie 下发,经 `apiLogin` 镜像到 localStorage
- `setSession()` / `clearSession()` 统一派发 `auth-change` 事件驱动 UI 重渲染
- UI 组件 (header/authGuard) 通过 `isAuthed()` 读取登录态,禁止直接读 localStorage
- 401 时 user 模式自动 `clearSession()`device 模式自动清缓存重取设备令牌
3. **UI 层** - 业务域 API 函数供组件直接调用
- 公开页组件位于 `src/home/*`,后台位于 `src/admin/*`
- `/admin` 路由由 `AuthGuard` 保护,未登录重定向 `/login?redirect=`
### API 层使用示例
```typescript
// 业务域文件: src/api/xxx.ts
import type { ApiResponse } from "@/lib/api"
import { callByUser, callPublic } from "./base"
// 1. 公开接口 (GET 自动并发去重)
const resp = await callPublic<{ token: string; base64: string }>(
"/api/captcha/get",
undefined,
"GET",
)
// 2. 设备级认证 (client_credentials 令牌模块内单飞缓存,提前 60s 过期)
const resp2 = await callByDevice<{ access_token: string }>(
"/api/auth/token",
{ grant_type: "client_credentials" },
)
// 3. 用户级认证 (读取会话 token401 自动清会话)
const resp3 = await callByUser<CreateOrderData>("/product/CreateOrder", data)
const resp4 = await callByUser<number>(
`/product/IsPay?orderNo=${orderNo}`,
undefined,
"GET",
)
// 4. Code 信封接口 (顶层有 Code 字段自动解包Code === 10000 成功)
const resp5 = await callPublic("/user/ApiLogin", {
Logincode: "xxx",
Password: "xxx",
})
const resp6 = await callPublic<ProductItem[]>(
"/product/ApiProductActive",
undefined,
"GET",
)
// 5. 统一响应处理
if (resp.success) {
console.log(resp.data) // 强类型 T
} else {
console.error(resp.status, resp.message)
}
```
### 组件调用示例
```typescript
import { fetchProducts } from "@/api/product"
import { toast } from "sonner"
const result = await fetchProducts()
if (result.success) {
setProducts(result.data)
} else {
toast.error(result.message)
}
```
## 路由组织
- `/` - 公开站点 (LayoutPage 统一头部/底部): 首页、`/product`(+`/buy`/`/http`/`/routeros`)、`/ipline``/softdownload``/help``/client`
- `/login` - 登录/注册 (tab 状态经 `location.state.tab` 切换)
- `/admin` - 用户后台 (AuthGuard 保护): dashboard、channels、whitelist、funds、product/long、product/short
路由定义集中在 `src/routers/index.tsx`
## 开发工作流
### 命令速查
```bash
bun install # 安装依赖
bun dev # 开发服务器 (vite --host)
bun run build # 生产构建 (tsc -b && vite build)
bun run lint # biome 检查并自动修复
```
### 代码规范 (biome)
- 无分号、双引号、2 空格缩进、单参数箭头函数省略括号
- import 自动排序 (organizeImports)
- `noNonNullAssertion` 为 error禁用 `!` 断言
- 提交前运行 `bun run lint`
### 组件开发模式
**shadcn/ui 组件** (`src/components/ui/*.tsx`)
- 通过 CLI 添加: `bunx shadcn@latest add <component>`
- 类名合并使用 `cn()` (来自 `@/lib/utils`)
**业务组件**
- `src/components/composites/*` - 复杂对话框等
- `src/components/*` - 通用组件 (wrap 布局容器、authGuard 路由守卫、data-table、page)
**表单**: react-hook-form + zod 校验
**提示**: 统一使用 sonner (`toast.success / toast.error`)
## 项目特定约定
### 类型定义
- 后端响应统一为 `ApiResponse<T>` (定义于 `src/lib/api.ts`):
- 成功: `{success: true, data: T}`
- 失败: `{success: false, status?: number, message: string}`
- 请求/响应业务类型定义在对应业务域 API 文件中 (如 `src/api/order.ts``CreateOrderRequest`)
- UI 展示模型位于 `src/lib/models/*`
### 环境变量 (见 `.env`)
- `VITE_API_BASE_URL` - 后端地址 (留空表示同源,登录 Cookie 自动携带)
- `VITE_CLIENT_ID` / `VITE_CLIENT_SECRET` - OAuth2 设备认证凭据 (当前均为 `web`)
## 关键文件参考
- [src/api/base.ts](src/api/base.ts) - API 调用核心 (三模式 + Code 信封适配 + GET 去重 + 设备令牌缓存)
- [src/api/token.ts](src/api/token.ts) - 会话状态存取与 auth-change 事件
- [src/api/auth.ts](src/api/auth.ts) - 登录/注册/退出业务域
- [src/lib/api.ts](src/lib/api.ts) - ApiResponse 类型与后端配置
- [src/routers/index.tsx](src/routers/index.tsx) - 全部路由定义
- [src/components/authGuard.tsx](src/components/authGuard.tsx) - 后台路由守卫
- [src/home/header.tsx](src/home/header.tsx) - 登录态响应式更新 (auth-change/focus 事件)

View File

@@ -1,8 +1,14 @@
import { RouterProvider } from "react-router-dom" import { RouterProvider } from "react-router-dom"
import { Toaster } from "@/components/ui/sonner"
import { router } from "./routers" import { router } from "./routers"
function App() { function App() {
return <RouterProvider router={router} /> return (
<>
<RouterProvider router={router} />
<Toaster position="top-center" richColors expand />
</>
)
} }
export default App export default App

View File

@@ -17,6 +17,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { type ReactNode, useState } from "react" import { type ReactNode, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import ProfileOrLogin from "@/components/profileOrLogin"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Tooltip, Tooltip,
@@ -100,22 +101,12 @@ export function Header() {
<AdminNavMenu /> <AdminNavMenu />
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3"> <div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
{/* <Link <ProfileOrLogin />
to="/"
className="flex-none h-16 flex items-center justify-center text-sm"
>
返回首页
</Link> */}
<UserCenter />
</div> </div>
</header> </header>
) )
} }
function UserCenter() {
return <div className="flex items-center gap-2"></div>
}
export function Navbar() { export function Navbar() {
const { navbar } = useLayout() const { navbar } = useLayout()

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from "react" import { useCallback, useRef, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { NavLink, useNavigate } from "react-router-dom" import { NavLink } from "react-router-dom"
import ProductDropdown from "@/components/productDropdown"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function AdminNavMenu() { export function AdminNavMenu() {
@@ -8,7 +9,7 @@ export function AdminNavMenu() {
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden"> <nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
<AdminNavItem to="/"></AdminNavItem> <AdminNavItem to="/"></AdminNavItem>
<AdminMenuItem text="产品购买"> <AdminMenuItem text="产品购买">
<ProductDropdown /> <ProductDropdown size="sm" />
</AdminMenuItem> </AdminMenuItem>
<AdminNavItem to="/ipline">IP线路表</AdminNavItem> <AdminNavItem to="/ipline">IP线路表</AdminNavItem>
<AdminNavItem to="/softdownload"></AdminNavItem> <AdminNavItem to="/softdownload"></AdminNavItem>
@@ -138,110 +139,3 @@ function AdminMenuItem({
</div> </div>
) )
} }
function ProductDropdown() {
const navigate = useNavigate()
const handleClick = (path: string) => {
navigate(path)
}
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<ProductCard
title="动静态IP"
desc="千万级动态短效IP自动去重高并发采集"
tag="热门"
onClick={() => handleClick("/product?tab=short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => window.open("https://lanhuip.com", "_blank")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product?tab=http")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP更易于调用与开发"
onClick={() => handleClick("/product?tab=tunnel")}
/>
<ProductCard
title="动态Vps"
desc="IP独享高带宽灵活控制存活周期"
onClick={() => window.open("http://vps.juip.com", "_blank")}
/>
</div>
<div className="flex items-center flex-wrap gap-2 pt-2 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">:</span>
<button
onClick={() => handleClick("/product?tab=short")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<button
onClick={() => handleClick("/product?tab=global")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<button
onClick={() => handleClick("/product?tab=tunnel")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/product")}
className="text-xs text-orange-500 hover:underline font-medium whitespace-nowrap"
>
</button>
</div>
</div>
)
}
function ProductCard({
title,
desc,
tag,
onClick,
}: {
title: string
desc: string
tag?: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="group flex items-start gap-2 p-2 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-xs whitespace-nowrap">
{title}
</span>
{tag && (
<span className="text-[10px] bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full shrink-0">
{tag}
</span>
)}
</div>
<p className="text-[11px] text-gray-500 mt-0.5 leading-relaxed line-clamp-2">
{desc}
</p>
</div>
</button>
)
}

View File

@@ -1,103 +1,63 @@
import type { ApiResponse } from "@/lib/api" import { callPublic } from "./base"
import { callByDevice, callByUser } from "./base" import { clearSession, readCookie, setSession } from "./token"
export interface User { export async function apiLogin(props: {
id: number logincode: string
username: string
loginCode: string
phone: string
name: string
wx: string
qq: string
taobao: string
email: string
restAmount: number
}
export type LoginMode = "phone_code" | "password"
export async function login(props: {
username: string
password: string password: string
remember: boolean }): Promise<{ success: boolean; message?: string }> {
mode: LoginMode const result = await callPublic("/user/ApiLogin", {
code?: string Logincode: props.logincode,
wx?: string Password: props.password,
qq?: string
}): Promise<ApiResponse> {
const result = await callByDevice<{
access_token: string
refresh_token: string
expires_in: number
}>("/api/auth/token", {
...props,
grant_type: "password",
login_type: props.mode,
}) })
if (!result.success) return result if (!result.success) {
return { success: false, message: result.message || "用户名或者密码不正确" }
}
const { access_token, refresh_token, expires_in } = result.data const token = readCookie("token")
localStorage.setItem("auth_token", access_token) const userInfoRaw = readCookie("userInfo")
localStorage.setItem("auth_refresh", refresh_token)
localStorage.setItem(
"auth_expires_in",
String(Date.now() + expires_in * 1000),
)
return { success: true, data: undefined } if (!token) {
return { success: false, message: "登录成功但未获取到 token" }
}
let userInfo: unknown = userInfoRaw
if (userInfoRaw) {
try {
userInfo = JSON.parse(userInfoRaw)
} catch {
userInfo = userInfoRaw
}
}
setSession(token, userInfo)
return { success: true }
}
export async function apiRegister(props: {
phone: string
password: string
code: string
wx?: string
qq?: string
}): Promise<{ success: boolean; message?: string }> {
const result = await callPublic("/user/ApiRegist", {
Code: props.code,
Phone: props.phone,
Pwd: props.password,
QQ: props.qq ?? "",
Wx: props.wx ?? "",
})
if (!result.success) {
return { success: false, message: result.message }
}
return { success: true }
} }
export async function logout() { export async function logout() {
const accessToken = localStorage.getItem("auth_token") clearSession()
const refreshToken = localStorage.getItem("auth_refresh")
if (accessToken && refreshToken) {
await callByUser("/api/auth/revoke", {
access_token: accessToken,
refresh_token: refreshToken,
})
}
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
window.location.href = "/" window.location.href = "/"
} }
export async function getProfile() {
return await callByUser<User>("/api/auth/introspect")
}
export async function refreshAuth(): Promise<{
access_token: string
refresh_token: string
} | null> {
const refreshToken = localStorage.getItem("auth_refresh")
if (!refreshToken) return null
const resp = await callByDevice<{
access_token: string
refresh_token: string
expires_in: number
}>(`/api/auth/token`, {
grant_type: "refresh_token",
refresh_token: refreshToken,
})
if (!resp.success) {
localStorage.removeItem("auth_refresh")
return null
}
const { access_token, refresh_token, expires_in } = resp.data
localStorage.setItem("auth_token", access_token)
localStorage.setItem("auth_refresh", refresh_token)
localStorage.setItem(
"auth_expires_in",
String(Date.now() + expires_in * 1000),
)
return { access_token, refresh_token }
}

View File

@@ -3,53 +3,32 @@ import {
type ApiResponse, type ApiResponse,
CLIENT_ID, CLIENT_ID,
CLIENT_SECRET, CLIENT_SECRET,
UnauthorizedError,
} from "@/lib/api" } from "@/lib/api"
import { clearSession, getUserToken } from "./token"
let deviceToken: string | null = null type CallMode = "public" | "device" | "user"
let deviceTokenExpire: Date | null = null
async function call<T = undefined>( async function request<R = undefined>(
endpoint: string, endpoint: string,
body?: string, init: {
auth?: string, method: "GET" | "POST"
): Promise<ApiResponse<T>> { body?: string
auth?: string
},
): Promise<ApiResponse<R>> {
try { try {
const headers: HeadersInit = { const headers: HeadersInit = {}
"Content-Type": "application/json", if (init.body !== undefined) {
headers["Content-Type"] = "application/json"
} }
if (auth) headers["Authorization"] = auth if (init.auth) headers.Authorization = init.auth
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: "POST", method: init.method,
headers, headers,
body, body: init.body,
}) })
if (response.status === 401) {
// 如果是 刷新Token 的接口本身报 401说明 RefreshToken 彻底失效,直接抛出错误
if (
endpoint.includes("/auth/token") ||
endpoint.includes("/auth/revoke")
) {
throw UnauthorizedError
}
// 动态引入 auth.ts 里的刷新函数,避免循环引用报错
const { refreshAuth } = await import("./auth")
// 尝试刷新 Token
const newTokens = await refreshAuth()
if (newTokens) {
// 刷新成功,用新 Token 重试当前请求(递归调用)
return call(endpoint, body, `Bearer ${newTokens.access_token}`)
} else {
// 刷新失败,抛出 401 错误,让上层处理跳转
throw UnauthorizedError
}
}
const contentType = response.headers.get("Content-Type") ?? "text/plain" const contentType = response.headers.get("Content-Type") ?? "text/plain"
if (contentType.includes("text/plain")) { if (contentType.includes("text/plain")) {
@@ -61,11 +40,18 @@ async function call<T = undefined>(
message: text || "请求失败", message: text || "请求失败",
} }
} }
return { success: true, data: undefined as T } return { success: true, data: undefined as R }
} }
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const json = await response.json() const json = (await response.json()) as R & {
message?: string
error_description?: string
Code?: number
Message?: string
Data?: unknown
data?: unknown
}
if (!response.ok) { if (!response.ok) {
return { return {
success: false, success: false,
@@ -73,6 +59,20 @@ async function call<T = undefined>(
message: json.message || json.error_description || "请求失败", message: json.message || json.error_description || "请求失败",
} }
} }
if (typeof json.Code === "number") {
if (json.Code === 10000) {
return {
success: true,
data: (json.Data ?? json.data ?? undefined) as R,
}
}
return {
success: false,
message: json.Message || "请求失败",
}
}
return { success: true, data: json } return { success: true, data: json }
} }
@@ -87,72 +87,124 @@ async function call<T = undefined>(
} }
} }
// ====================== let deviceToken: string | null = null
// Public let deviceTokenExpire = 0
// ====================== let deviceTokenPromise: Promise<string | null> | null = null
async function acquireDeviceToken(): Promise<string | null> {
if (deviceToken && Date.now() < deviceTokenExpire) return deviceToken
if (!deviceTokenPromise) {
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
deviceTokenPromise = request<{
access_token: string
expires_in: number
}>("/api/auth/token", {
method: "POST",
body: JSON.stringify({ grant_type: "client_credentials" }),
auth: `Basic ${basic}`,
})
.then(resp => {
if (!resp.success) {
deviceToken = null
return null
}
deviceToken = resp.data.access_token
deviceTokenExpire = Date.now() + (resp.data.expires_in - 60) * 1000
return resp.data.access_token
})
.finally(() => {
deviceTokenPromise = null
})
}
return deviceTokenPromise
}
const inflight = new Map<string, Promise<ApiResponse<unknown>>>()
function dedupGet<R>(
endpoint: string,
run: () => Promise<ApiResponse<R>>,
): Promise<ApiResponse<R>> {
const existing = inflight.get(endpoint)
if (existing) return existing as Promise<ApiResponse<R>>
const promise = run().finally(() => inflight.delete(endpoint))
inflight.set(endpoint, promise)
return promise
}
async function call<R = undefined>(
mode: CallMode,
endpoint: string,
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
const body = data === undefined ? undefined : JSON.stringify(data)
const run = async (): Promise<ApiResponse<R>> => {
let auth: string | undefined
if (mode === "user") {
const token = getUserToken()
if (!token) {
return {
success: false,
status: 401,
message: "未登录或会话已过期",
}
}
auth = `Bearer ${token}`
} else if (mode === "device") {
const token = await acquireDeviceToken()
if (!token) {
return {
success: false,
status: 401,
message: "设备凭证获取失败",
}
}
auth = `Bearer ${token}`
}
const resp = await request<R>(endpoint, { method, body, auth })
if (!resp.success && resp.status === 401) {
if (mode === "user") {
clearSession()
} else if (mode === "device") {
deviceToken = null
deviceTokenExpire = 0
}
}
return resp
}
if (method === "GET" && data === undefined) {
return dedupGet<R>(endpoint, run)
}
return run()
}
async function callPublic<R = undefined>( async function callPublic<R = undefined>(
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> { ): Promise<ApiResponse<R>> {
return call(endpoint, data ? JSON.stringify(data) : undefined) return call<R>("public", endpoint, data, method)
} }
// ======================
// Device
// ======================
async function callByDevice<R = undefined>( async function callByDevice<R = undefined>(
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> { ): Promise<ApiResponse<R>> {
// 检查内存中的 Token 是否过期 return call<R>("device", endpoint, data, method)
if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) {
// 用 btoa 生成 Basic 认证头
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
const resp = await call<{
access_token: string
expires_in: number
}>(
"/api/auth/token",
JSON.stringify({ grant_type: "client_credentials" }),
`Basic ${basic}`,
)
if (!resp.success) {
return resp
}
deviceToken = resp.data.access_token
// 提前 60 秒过期,防止临界点失效
deviceTokenExpire = new Date(
Date.now() + (resp.data.expires_in - 60) * 1000,
)
}
return call(
endpoint,
data ? JSON.stringify(data) : undefined,
`Bearer ${deviceToken}`,
)
} }
// ======================
// User
// ======================
async function callByUser<R = undefined>( async function callByUser<R = undefined>(
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> { ): Promise<ApiResponse<R>> {
// 这里我们假设用户登录后的 Token 存在 localStorage 里 return call<R>("user", endpoint, data, method)
// 如果你是用 Cookie 存 Token这里用 js-cookie 的 get 方法替换即可
const token = localStorage.getItem("auth_token")
if (!token) {
throw UnauthorizedError
}
return call(
endpoint,
data ? JSON.stringify(data) : undefined,
`Bearer ${token}`,
)
} }
export { callByDevice, callByUser, callPublic } export { callByDevice, callByUser, callPublic }

View File

@@ -1,13 +1,9 @@
import { callPublic } from "@/api/base" import { callPublic } from "./base"
// 获取验证码
// 假设你的后端接口为 /api/captcha/get返回 { token: 'abc', base64: 'data:image/png...' }
export async function getCaptcha() { export async function getCaptcha() {
return await callPublic<{ token: string; base64: string }>("/api/captcha/get") return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
} }
// 校验验证码
// 假设后端接口为 /api/captcha/verify返回 { success: true/false }
export async function verifyCaptcha(token: string, answer: string) { export async function verifyCaptcha(token: string, answer: string) {
return await callPublic<boolean>("/api/captcha/verify", { token, answer }) return await callPublic<boolean>("/api/captcha/verify", { token, answer })
} }

64
src/api/order.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { ApiResponse } from "@/lib/api"
import { callByUser } from "./base"
export type CreateOrderRequest = {
PackageId: number
OrderType: number
Account: string
Pwd: string
ConnectCount: number
MinPostfix?: number
MaxPostfix?: number
CouponId?: number
UseAccountAmount?: number
OPayType: number
PayChannel?: number
Price?: number
}
export type CreateTestAccountRequest = {
ProductId: number
PackageId: number
Account: string
Pwd: string
}
export type OrderInfo = {
OrderNo: string
OtherPayAmount: number
CreateTime: string
PayType: number
}
export type CreateOrderData = {
OrderInfo: OrderInfo
PayData: string
}
export async function createOrder(
data: CreateOrderRequest,
): Promise<ApiResponse<CreateOrderData | string>> {
return await callByUser<CreateOrderData | string>(
"/product/CreateOrder",
data,
)
}
export async function createTestAccount(
data: CreateTestAccountRequest,
): Promise<ApiResponse<string>> {
return await callByUser<string>(
"/api/course/v1/productaccount/CreateTestAccount",
data,
)
}
export async function checkPayStatus(
orderNo: string,
): Promise<ApiResponse<number>> {
return await callByUser<number>(
`/product/IsPay?orderNo=${orderNo}`,
undefined,
"GET",
)
}

11
src/api/product.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { ApiResponse } from "@/lib/api"
import type { ProductItem } from "@/lib/models/product"
import { callPublic } from "./base"
export async function fetchProducts(): Promise<ApiResponse<ProductItem[]>> {
return await callPublic<ProductItem[]>(
"/product/ApiProductActive",
undefined,
"GET",
)
}

39
src/api/token.ts Normal file
View File

@@ -0,0 +1,39 @@
const AUTH_EVENT = "auth-change"
const TOKEN_KEY = "auth_token"
const USER_KEY = "auth_user"
export function readCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
export function getUserToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function getUserInfo<T = unknown>(): T | null {
const raw = localStorage.getItem(USER_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as T
} catch {
return null
}
}
export function isAuthed(): boolean {
return getUserToken() !== null
}
export function setSession(token: string, userInfo: unknown) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(userInfo))
window.dispatchEvent(new Event(AUTH_EVENT))
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
window.dispatchEvent(new Event(AUTH_EVENT))
}

View File

@@ -1,15 +1,14 @@
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
import { callByDevice } from "./base" import { callPublic } from "./base"
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login" export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
export async function sendSMS( export async function sendSMS(
phone: string, phone: string,
purpose: SMSPurpose = "User_Code", purpose: SMSPurpose = "User_Code",
): Promise<ApiResponse> { ): Promise<ApiResponse<undefined>> {
return await callByDevice("/api/verify/sms", { return await callPublic("/user/SendPhonesCodevefy", {
phone,
purpose,
key: purpose, key: purpose,
phone,
}) })
} }

View File

@@ -1,57 +1,10 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { Navigate, useLocation } from "react-router-dom" import { Navigate, useLocation } from "react-router-dom"
import { getProfile, refreshAuth } from "@/api/auth" import { isAuthed } from "@/api/token"
export default function AuthGuard({ children }: { children: React.ReactNode }) { export default function AuthGuard({ children }: { children: React.ReactNode }) {
const location = useLocation() const location = useLocation()
const [isValid, setIsValid] = useState<boolean | null>(null) const [isValid] = useState(isAuthed)
useEffect(() => {
const checkAuth = async () => {
try {
const accessToken = localStorage.getItem("auth_token")
const refreshToken = localStorage.getItem("auth_refresh")
if (!accessToken && !refreshToken) {
setIsValid(false)
return
}
const profileResp = await getProfile()
if (profileResp.success) {
setIsValid(true)
return
}
if (refreshToken) {
const newTokens = await refreshAuth()
if (newTokens) {
setIsValid(true)
return
}
}
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
setIsValid(false)
} catch {
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
setIsValid(false)
}
}
checkAuth()
}, [])
if (isValid === null)
return (
<div className="flex justify-center items-center h-screen">
...
</div>
)
if (!isValid) { if (!isValid) {
return ( return (

View File

@@ -0,0 +1,184 @@
import { useNavigate } from "react-router-dom"
import { cn } from "@/lib/utils"
const SIZE_STYLES = {
lg: {
wrap: "space-y-4",
grid: "grid-cols-3 gap-4",
card: "gap-3 p-3",
title: "text-sm",
titleGap: "gap-2",
desc: "text-xs mt-1",
tag: "px-2 py-0.5",
footer: "gap-3 pt-3",
footerLink: "text-sm",
},
sm: {
wrap: "space-y-3",
grid: "grid-cols-3 gap-3",
card: "gap-2 p-2",
title: "text-xs",
titleGap: "gap-1.5",
desc: "text-[11px] mt-0.5",
tag: "px-1.5 py-0.5",
footer: "gap-2 pt-2",
footerLink: "text-xs",
},
} as const
export default function ProductDropdown({
size = "lg",
onNavigate,
}: {
size?: keyof typeof SIZE_STYLES
onNavigate?: () => void
}) {
const navigate = useNavigate()
const s = SIZE_STYLES[size]
const handleClick = (path: string) => {
navigate(path)
onNavigate?.()
}
return (
<div className={s.wrap}>
<div className={cn("grid", s.grid)}>
<ProductCard
size={s}
title="动静态IP"
desc="千万级动态短效IP自动去重高并发采集"
tag="热门"
onClick={() => handleClick("/product?tab=short")}
/>
<ProductCard
size={s}
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => window.open("https://lanhuip.com", "_blank")}
/>
<ProductCard
size={s}
title="HTTP/S5"
desc="海量全球住宅优质IP4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/http")}
/>
<ProductCard
size={s}
title="软路由成本价"
desc="云端自动切换IP更易于调用与开发"
onClick={() => handleClick("/product/routeros")}
/>
<ProductCard
size={s}
title="动态Vps"
desc="IP独享高带宽灵活控制存活周期"
onClick={() => window.open("http://vps.juip.com", "_blank")}
/>
</div>
<div
className={cn(
"flex items-center flex-wrap border-t border-gray-100",
s.footer,
)}
>
<span className="text-xs font-medium text-gray-400">:</span>
<button
onClick={() => handleClick("/product?tab=short")}
className={cn(
"text-blue-600 hover:underline whitespace-nowrap",
s.footerLink,
)}
>
</button>
<button
onClick={() => handleClick("/product?tab=global")}
className={cn(
"text-blue-600 hover:underline whitespace-nowrap",
s.footerLink,
)}
>
</button>
<button
onClick={() => handleClick("/product?tab=tunnel")}
className={cn(
"text-blue-600 hover:underline whitespace-nowrap",
s.footerLink,
)}
>
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/product")}
className={cn(
"text-orange-500 hover:underline font-medium whitespace-nowrap",
s.footerLink,
)}
>
</button>
</div>
</div>
)
}
function ProductCard({
size,
title,
desc,
tag,
onClick,
}: {
size: (typeof SIZE_STYLES)[keyof typeof SIZE_STYLES]
title: string
desc: string
tag?: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"group flex items-start rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full",
size.card,
)}
>
<div className="flex-1 min-w-0">
<div className={cn("flex items-center flex-wrap", size.titleGap)}>
<span
className={cn(
"font-medium text-gray-800 group-hover:text-blue-600 transition-colors whitespace-nowrap",
size.title,
)}
>
{title}
</span>
{tag && (
<span
className={cn(
"text-[10px] bg-blue-100 text-blue-600 rounded-full shrink-0",
size.tag,
)}
>
{tag}
</span>
)}
</div>
<p
className={cn(
"text-gray-500 leading-relaxed line-clamp-2",
size.desc,
)}
>
{desc}
</p>
</div>
</button>
)
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import { logout } from "@/api/auth"
import { isAuthed } from "@/api/token"
export default function ProfileOrLogin() {
const [authed, setAuthed] = useState(isAuthed)
const navigate = useNavigate()
useEffect(() => {
const update = () => setAuthed(isAuthed())
window.addEventListener("focus", update)
window.addEventListener("auth-change", update)
return () => {
window.removeEventListener("focus", update)
window.removeEventListener("auth-change", update)
}
}, [])
const handleLogout = async () => {
await logout()
}
if (authed) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => navigate("/admin")}
className="w-24 h-12 flex items-center justify-center lg:text-lg text-slate-700 hover:text-blue-500 transition-colors"
>
<span></span>
</button>
<button
onClick={handleLogout}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span>退</span>
</button>
</div>
)
}
return (
<div className="flex items-center">
<NavLink
to="/login"
state={{ tab: "login" }}
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</NavLink>
<NavLink
to="/login"
state={{ tab: "register" }}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span></span>
</NavLink>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui" import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import type * as React from "react"
import { cn } from "@/lib/utils/index" import { cn } from "@/lib/utils/index"
@@ -25,7 +25,7 @@ function RadioGroupItem({
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary", "group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className className,
)} )}
{...props} {...props}
> >

View File

@@ -1,72 +1,10 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react" import { useRef, useState, useSyncExternalStore } from "react"
import { NavLink, useNavigate } from "react-router-dom" import { NavLink } from "react-router-dom"
import { logout } from "@/api/auth" import ProductDropdown from "@/components/productDropdown"
import ProfileOrLogin from "@/components/profileOrLogin"
import Wrap from "@/components/wrap" import Wrap from "@/components/wrap"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function ProfileOrLogin() {
const [authed, setAuthed] = useState(
() => !!localStorage.getItem("auth_token"),
)
const navigate = useNavigate()
useEffect(() => {
const onFocus = () => setAuthed(!!localStorage.getItem("auth_token"))
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [])
const handleLogout = async () => {
await logout()
}
if (authed) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => navigate("/admin")}
className="w-24 h-12 flex items-center justify-center lg:text-lg text-slate-700 hover:text-blue-500 transition-colors"
>
<span></span>
</button>
<button
onClick={handleLogout}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span>退</span>
</button>
</div>
)
}
return (
<div className="flex items-center">
<NavLink
to="/login"
state={{ tab: "login" }}
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span></span>
</NavLink>
<NavLink
to="/login"
state={{ tab: "register" }}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span></span>
</NavLink>
</div>
)
}
export default function Header() { export default function Header() {
const scroll = useSyncExternalStore( const scroll = useSyncExternalStore(
callback => { callback => {
@@ -229,108 +167,3 @@ const MenuItem = ({
</li> </li>
) )
} }
const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
const navigate = useNavigate()
const handleClick = (path: string) => {
navigate(path)
onNavigate()
}
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<ProductCard
title="动静态IP"
desc="千万级动态短效IP自动去重高并发采集"
tag="热门"
onClick={() => handleClick("/product?tab=short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => window.open("https://lanhuip.com", "_blank")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/http")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP更易于调用与开发"
onClick={() => handleClick("/product/routeros")}
/>
<ProductCard
title="动态Vps"
desc="IP独享高带宽灵活控制存活周期"
onClick={() => window.open("http://vps.juip.com", "_blank")}
/>
</div>
<div className="flex items-center flex-wrap gap-3 pt-3 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">:</span>
<button
onClick={() => handleClick("/product?tab=short")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<button
onClick={() => handleClick("/product?tab=global")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<button
onClick={() => handleClick("/product?tab=tunnel")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/product")}
className="text-sm text-orange-500 hover:underline font-medium whitespace-nowrap"
>
</button>
</div>
</div>
)
}
const ProductCard = ({
title,
desc,
tag,
onClick,
}: {
title: string
desc: string
tag?: string
onClick: () => void
}) => (
<button
onClick={onClick}
className="group flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-sm whitespace-nowrap">
{title}
</span>
{tag && (
<span className="text-[10px] bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full shrink-0">
{tag}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-1 leading-relaxed line-clamp-2">
{desc}
</p>
</div>
</button>
)

View File

@@ -2,7 +2,7 @@ import { EyeIcon, EyeOffIcon } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Link, useLocation, useNavigate } from "react-router-dom" import { Link, useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { login } from "@/api/auth" import { apiLogin, apiRegister } from "@/api/auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
@@ -87,15 +87,17 @@ function LoginForm() {
} }
setSubmitting(true) setSubmitting(true)
try { try {
const resp = await login({ const resp = await apiLogin({
username: account, logincode: account,
password, password,
remember,
mode: "password",
}) })
console.log(resp, "resp")
if (resp.success) { if (resp.success) {
toast.success("登录成功") toast.success("登录成功", {
navigate("/admin") description: "欢迎回来!",
})
navigate("/")
} else { } else {
toast.error(resp.message || "登录失败") toast.error(resp.message || "登录失败")
} }
@@ -110,12 +112,12 @@ function LoginForm() {
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1"> <div className="space-y-1">
<label htmlFor="account" className="text-sm font-medium text-gray-700"> <label htmlFor="account" className="text-sm font-medium text-gray-700">
</label> </label>
<Input <Input
id="account" id="account"
type="text" type="text"
placeholder="请输入淘宝名/会员手机号码" placeholder="请输入会员号"
className="w-full" className="w-full"
value={account} value={account}
onChange={e => setAccount(e.target.value)} onChange={e => setAccount(e.target.value)}
@@ -129,7 +131,7 @@ function LoginForm() {
<Input <Input
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="请输入密码8-12位数字或字母组合" placeholder="请输入密码"
className="w-full pr-10" className="w-full pr-10"
value={password} value={password}
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
@@ -182,7 +184,6 @@ function LoginForm() {
} }
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) { function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
const navigate = useNavigate()
const [phone, setPhone] = useState("") const [phone, setPhone] = useState("")
const [smsCode, setSmsCode] = useState("") const [smsCode, setSmsCode] = useState("")
const [smsCountdown, setSmsCountdown] = useState(0) const [smsCountdown, setSmsCountdown] = useState(0)
@@ -237,12 +238,12 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
toast.error("请输入正确的手机号") toast.error("请输入正确的手机号")
return return
} }
if (smsCode.length < 4 || smsCode.length > 6) { if (smsCode.length < 3 || smsCode.length > 6) {
toast.error("请输入短信验证码") toast.error("请输入短信验证码")
return return
} }
if (!/^[a-zA-Z0-9]{8,12}$/.test(newPassword)) { if (!newPassword.trim()) {
toast.error("密码为8-12位数字或字母组合") toast.error("请输入密码")
return return
} }
if (!agreed) { if (!agreed) {
@@ -252,19 +253,17 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
setSubmitting(true) setSubmitting(true)
try { try {
const resp = await login({ const resp = await apiRegister({
username: phone, phone,
password: newPassword, password: newPassword,
code: smsCode, code: smsCode,
remember: false,
mode: "phone_code",
wx, wx,
qq, qq,
}) })
if (resp.success) { if (resp.success) {
toast.success("注册成功") toast.success("注册成功,请登录")
navigate("/admin") onSwitchToLogin()
} else { } else {
toast.error(resp.message || "注册失败") toast.error(resp.message || "注册失败")
} }
@@ -334,7 +333,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
<Input <Input
id="reg-password" id="reg-password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="请输入8-12位数字或字母组合" placeholder="请输入密码"
className="w-full pr-10" className="w-full pr-10"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}

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">410</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">110</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">
user210
<span className="text-blue-600 font-medium">
{" "}
user2user3user4....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">38</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">110</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 { useSearchParams } from "react-router-dom"
import { fetchProducts } from "@/api/product"
import Wrap from "@/components/wrap" 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 { cn } from "@/lib/utils"
import ProductGrid from "./productGrid" import ProductGrid from "./productGrid"
import ProductSidebar from "./productSidebar" import ProductSidebar from "./productSidebar"
@@ -18,45 +20,93 @@ const TAB_ALIAS: Record<string, string> = {
export default function ProductPage() { export default function ProductPage() {
const [searchParams, setSearchParams] = useSearchParams() 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 urlTab = searchParams.get("tab") || "dynamic"
const activeTabId = TAB_ALIAS[urlTab] || urlTab const activeTabId = TAB_ALIAS[urlTab] || urlTab
const activeBrandId = searchParams.get("brand") || ""
const currentTab = const currentTab = tabs.find(t => t.id === activeTabId) || tabs[0]
PRODUCT_DATA.find(t => t.id === activeTabId) || PRODUCT_DATA[0]
const currentBrand = 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 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] const firstBrand = targetTab?.brands[0]
setSearchParams({ tab: tabId, brand: firstBrand?.id || "" }) setActiveBrandId(firstBrand?.id || "")
setActiveVersionId("v1") setSelectedVersionId("")
} }
const handleBrandSelect = (brandId: string) => { const handleBrandSelect = (brandId: string) => {
setSearchParams({ tab: activeTabId, brand: brandId }) setActiveBrandId(brandId)
setActiveVersionId("v1") setSelectedVersionId("")
} }
const getDisplayProducts = () => { const displayProducts = (() => {
if (!currentBrand) return [] if (!currentBrand) return []
if (currentBrand.versions && currentBrand.versions.length > 0) { 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 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 ( return (
<Wrap className="flex flex-col gap-16 pt-30 bg-white"> <Wrap className="flex flex-col gap-16 pt-30 bg-white">
<div className="flex flex-col"> <div className="flex flex-col">
<ProductTabs <ProductTabs
tabs={PRODUCT_DATA} tabs={tabs}
activeId={activeTabId} activeId={activeTabId}
onTabChange={handleTabChange} onTabChange={handleTabChange}
/> />
@@ -73,7 +123,6 @@ export default function ProductPage() {
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{!isPurchaseInfo ? ( {!isPurchaseInfo ? (
<> <>
{" "}
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed"> <div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
<ul className="space-y-2"> <ul className="space-y-2">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
@@ -105,10 +154,10 @@ export default function ProductPage() {
{currentBrand.versions.map(v => ( {currentBrand.versions.map(v => (
<button <button
key={v.id} key={v.id}
onClick={() => setActiveVersionId(v.id)} onClick={() => setSelectedVersionId(v.id)}
className={cn( className={cn(
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center", "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-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
: "bg-gray-100 text-gray-600 hover:bg-gray-200", : "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 { Button } from "@/components/ui/button"
import type { Product } from "@/lib/models/product" import type { Product } from "@/lib/models/product"
import vector from "../_assets/vector.webp" import vector from "../_assets/vector.webp"
@@ -7,6 +8,8 @@ type ProductGridProps = {
} }
export default function ProductGrid({ products }: ProductGridProps) { export default function ProductGrid({ products }: ProductGridProps) {
const navigate = useNavigate()
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
{products.map(product => ( {products.map(product => (
@@ -72,6 +75,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
<Button <Button
variant="outline" variant="outline"
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50" className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
onClick={() => navigate("/product/buy", { state: { product } })}
> >
</Button> </Button>

View File

@@ -1,6 +1,5 @@
// 定义后端服务URL和OAuth2配置 // 定义后端服务URL和OAuth2配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""
if (!API_BASE_URL) throw new Error("VITE_API_BASE_URL is not set")
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set") if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set")
@@ -12,7 +11,7 @@ if (!CLIENT_SECRET) throw new Error("VITE_CLIENT_SECRET is not set")
type ApiResponse<T = undefined> = type ApiResponse<T = undefined> =
| { | {
success: false success: false
status: number status?: number
message: string message: string
} }
| { | {
@@ -20,32 +19,4 @@ type ApiResponse<T = undefined> =
data: T data: T
} }
type PageRecord<T = unknown> = { export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
total: number
page: number
size: number
list: T[]
}
// 工具类型:用于提取请求参数和返回数据
type ExtraReq<T extends (...args: never) => unknown> = T extends (
...args: infer P
) => unknown
? P[0]
: never
type ExtraResp<T extends (...args: never) => unknown> =
Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
// 预定义错误
const UnauthorizedError = new Error("未授权访问")
export {
API_BASE_URL,
type ApiResponse,
CLIENT_ID,
CLIENT_SECRET,
type ExtraReq,
type ExtraResp,
type PageRecord,
UnauthorizedError,
}

View File

@@ -5,6 +5,7 @@ export type Version = {
export type Product = { export type Product = {
id: string id: string
productId: number
title: string title: string
price: number price: number
desc?: string desc?: string
@@ -14,6 +15,7 @@ export type Product = {
duration?: string duration?: string
version?: string version?: string
features?: string[] features?: string[]
isTest?: boolean
} }
export type Brand = { export type Brand = {
@@ -29,267 +31,109 @@ export type Tab = {
brands: Brand[] brands: Brand[]
} }
export const PRODUCT_DATA: Tab[] = [ export type ProductItem = {
{ Product: {
id: "dynamic", Id: number
label: "动态独享IP", Name: string
brands: [ Image: string
{ Sort: number
id: "fox", Content: string
name: "极狐IP(推荐)", Profile: string
versions: [ TenantId: number
{ id: "v1", label: "尊享版-不限速" }, OnLine: number
{ id: "v2", label: "高级版-限速6M" }, ContentLine: string[]
{ id: "v3", label: "普通版-限速2M" }, }
], Packages: PackageItem[]
products: [ }
{
id: "1", export type PackageItem = {
title: "免费", Id: number
price: 0, TenantId: number
desc: "注册即送3次", ProductId: number
card: "测试卡", PackageType: number
duration: "1小时", Name: string
version: "v1", Title: string
}, Image: string
{ Status: number
id: "2", Profile: string
title: "首单低至", Price: number
price: 0.1, LinePrice: number
originalPrice: 0.1, DayPrice: number
// card: "测试卡", MinPrice: number
duration: "24小时", DayCount: number
version: "v1", OriginKey: string
}, OriginName: string
{ IsTest: number
id: "3", DeleteTag: number
title: "周卡套餐", }
price: 37.5,
tag: "8.5折", function extractTier(name: string): string {
originalPrice: 37.5, const match = name.match(/\((.+?)\)$/)
// card: "周卡", return match ? match[1] : "默认"
duration: "7天", }
version: "v1",
}, export function transformToTabs(data: ProductItem[]): Tab[] {
{ const activeProducts = data.filter(p => p.Product.OnLine === 1)
id: "4",
title: "月卡套餐", activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
price: 78, console.log(
tag: "8.5折", activeProducts,
originalPrice: 80.0, "activeProductsactiveProductsactiveProductsactiveProducts",
// card: "月卡", )
duration: "30天",
version: "v2", const brands: Brand[] = activeProducts.map(item => {
}, const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
{
id: "5", const tierMap = new Map<string, PackageItem[]>()
title: "季卡套餐", for (const pkg of activePackages) {
price: 208, const tier = extractTier(pkg.Name)
tag: "8.5折", if (!tierMap.has(tier)) tierMap.set(tier, [])
originalPrice: 220.0, tierMap.get(tier)?.push(pkg)
// card: "季卡", }
duration: "90天",
version: "v2", const tiers = Array.from(tierMap.entries())
},
{ const versions: Version[] | undefined =
id: "6", tiers.length > 1
title: "年卡套餐", ? tiers.map(([tier]) => ({ id: tier, label: tier }))
price: 770, : undefined
tag: "8.5折",
originalPrice: 800.0, const products: Product[] = []
card: "年卡", for (const [tier, packages] of tiers) {
duration: "365天", for (const pkg of packages) {
version: "v3", products.push({
}, id: pkg.Id.toString(),
], productId: item.Product.Id,
}, title: pkg.Name,
{ price: pkg.Price,
id: "aurora", originalPrice: pkg.LinePrice,
name: "极光IP(推荐)", duration: pkg.Profile,
versions: [ card: pkg.OriginName,
{ id: "v1", label: "尊享版-不限速" }, version: tier,
{ id: "v2", label: "高级版-限速6M" }, isTest: pkg.IsTest === 1,
{ id: "v3", label: "普通版-限速2M" }, })
], }
products: [ }
{
id: "7", return {
title: "周卡套餐", id: item.Product.Name,
price: 37.5, name: item.Product.Name,
originalPrice: 37.5, versions,
card: "周卡", products,
duration: "7天", }
version: "v1", })
},
{ brands.push({
id: "8", id: "info",
title: "月卡套餐", name: "购买须知",
price: 78, products: [],
tag: "8.5折", })
originalPrice: 80.0,
card: "月卡", return [
duration: "30天", {
version: "v2", id: "dynamic",
}, label: "动态独享IP",
{ brands,
id: "9", },
title: "季卡套餐", ]
price: 208, }
tag: "8.5折",
originalPrice: 220.0,
card: "季卡",
duration: "90天",
version: "v3",
},
],
},
{
id: "mushroom",
name: "蘑菇IP",
products: [
{
id: "10",
title: "免费",
price: 0,
desc: "注册即送3次",
card: "测试卡",
duration: "1小时",
},
{
id: "11",
title: "首单低至",
price: 0.1,
originalPrice: 0.1,
// card: "测试卡",
duration: "24小时",
},
{
id: "12",
title: "周卡套餐",
price: 37.5,
originalPrice: 37.5,
// card: "周卡",
duration: "7天",
},
],
},
{
id: "kylin",
name: "麒麟IP",
products: [
{
id: "13",
title: "测试卡",
price: 0.1,
card: "测试卡",
duration: "1小时",
},
{
id: "14",
title: "24小时套餐",
price: 37.5,
originalPrice: 37.5,
card: "24小时卡",
duration: "24小时",
},
{
id: "15",
title: "周卡套餐",
price: 37.5,
originalPrice: 37.5,
card: "周卡",
duration: "7天",
},
],
},
{
id: "cheetah",
name: "猎豹IP",
products: [
{
id: "16",
title: "月卡套餐",
price: 78,
tag: "8.5折",
originalPrice: 80.0,
card: "月卡",
duration: "30天",
},
{
id: "17",
title: "季卡套餐",
price: 208,
tag: "8.5折",
originalPrice: 220.0,
card: "季卡",
duration: "90天",
},
{
id: "18",
title: "年卡套餐",
price: 770,
tag: "8.5折",
originalPrice: 800.0,
card: "年卡",
duration: "365天",
},
],
},
{
id: "info",
name: "购买说明",
products: [],
},
],
},
{
id: "static",
label: "静态IP",
brands: [
{
id: "drip",
name: "水滴独享IP",
products: [
{
id: "19",
title: "纯住宅池",
price: 50,
desc: "分散式真实住宅IP",
// card: "住宅卡",
features: [
"带宽充足IP段优质分布广",
"适合对IP要求高的业务",
"IP 3-7天周期性变化",
"线路切换每天20次",
],
},
{
id: "20",
title: "多拨池",
price: 60,
desc: "集中拨号住宅,带宽较高",
// card: "拨号卡",
features: [
"IP段较多稳定性较高",
"IP 3-7天周期性变化",
"线路切换每天20次",
],
},
{
id: "21",
title: "固定池",
price: 70,
desc: "天翼云节点",
// card: "固定卡",
features: ["IP段多IP不变化稳定性高", "线路切换每日限制为20次"],
},
],
},
],
},
{
id: "single",
label: "单窗口单IP",
brands: [],
},
]

View File

@@ -14,6 +14,7 @@ import IpLinePage from "@/home/ipLine"
import LayoutPage from "@/home/layout" import LayoutPage from "@/home/layout"
import LoginPage from "@/home/login" import LoginPage from "@/home/login"
import ProductPage from "@/home/product" import ProductPage from "@/home/product"
import BuyPage from "@/home/product/buy"
import HttpPage from "@/home/product/http" import HttpPage from "@/home/product/http"
import RouterosPage from "@/home/product/routeros" import RouterosPage from "@/home/product/routeros"
import SoftDownloadPage from "@/home/softDownload" import SoftDownloadPage from "@/home/softDownload"
@@ -24,6 +25,7 @@ export const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <HomePage /> }, { index: true, element: <HomePage /> },
{ path: "product", element: <ProductPage /> }, { path: "product", element: <ProductPage /> },
{ path: "product/buy", element: <BuyPage /> },
{ path: "product/http", element: <HttpPage /> }, { path: "product/http", element: <HttpPage /> },
{ path: "product/routeros", element: <RouterosPage /> }, { path: "product/routeros", element: <RouterosPage /> },
{ path: "ipline", element: <IpLinePage /> }, { path: "ipline", element: <IpLinePage /> },

View File

@@ -11,4 +11,18 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: {
proxy: {
"/api": "http://192.168.3.6:5000",
"/user": "http://192.168.3.6:5000",
"/product": {
target: "http://192.168.3.6:5000",
bypass(req) {
if (req.headers?.accept?.includes("text/html")) {
return "/index.html"
}
},
},
},
},
}) })