更新登录功能
This commit is contained in:
2
.env
2
.env
@@ -1,3 +1,3 @@
|
||||
VITE_API_BASE_URL=http://192.168.3.42:8080
|
||||
VITE_API_BASE_URL=
|
||||
VITE_CLIENT_ID=web
|
||||
VITE_CLIENT_SECRET=web
|
||||
158
.github/copilot-instructions.md
vendored
Normal file
158
.github/copilot-instructions.md
vendored
Normal 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. 用户级认证 (读取会话 token,401 自动清会话)
|
||||
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 事件)
|
||||
@@ -1,8 +1,14 @@
|
||||
import { RouterProvider } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { router } from "./routers"
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" richColors expand />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -100,22 +101,12 @@ export function Header() {
|
||||
<AdminNavMenu />
|
||||
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||
{/* <Link
|
||||
to="/"
|
||||
className="flex-none h-16 flex items-center justify-center text-sm"
|
||||
>
|
||||
返回首页
|
||||
</Link> */}
|
||||
<UserCenter />
|
||||
<ProfileOrLogin />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function UserCenter() {
|
||||
return <div className="flex items-center gap-2">用户</div>
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const { navbar } = useLayout()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
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"
|
||||
|
||||
export function AdminNavMenu() {
|
||||
@@ -8,7 +9,7 @@ export function AdminNavMenu() {
|
||||
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
||||
<AdminNavItem to="/">首页</AdminNavItem>
|
||||
<AdminMenuItem text="产品购买">
|
||||
<ProductDropdown />
|
||||
<ProductDropdown size="sm" />
|
||||
</AdminMenuItem>
|
||||
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
||||
<AdminNavItem to="/softdownload">软件下载</AdminNavItem>
|
||||
@@ -138,110 +139,3 @@ function AdminMenuItem({
|
||||
</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="海量全球住宅优质IP,4G/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>
|
||||
)
|
||||
}
|
||||
|
||||
144
src/api/auth.ts
144
src/api/auth.ts
@@ -1,103 +1,63 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByDevice, callByUser } from "./base"
|
||||
import { callPublic } from "./base"
|
||||
import { clearSession, readCookie, setSession } from "./token"
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
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
|
||||
export async function apiLogin(props: {
|
||||
logincode: string
|
||||
password: string
|
||||
remember: boolean
|
||||
mode: LoginMode
|
||||
code?: string
|
||||
wx?: string
|
||||
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,
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const result = await callPublic("/user/ApiLogin", {
|
||||
Logincode: props.logincode,
|
||||
Password: props.password,
|
||||
})
|
||||
|
||||
if (!result.success) return result
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message || "用户名或者密码不正确" }
|
||||
}
|
||||
|
||||
const { access_token, refresh_token, expires_in } = result.data
|
||||
localStorage.setItem("auth_token", access_token)
|
||||
localStorage.setItem("auth_refresh", refresh_token)
|
||||
localStorage.setItem(
|
||||
"auth_expires_in",
|
||||
String(Date.now() + expires_in * 1000),
|
||||
)
|
||||
const token = readCookie("token")
|
||||
const userInfoRaw = readCookie("userInfo")
|
||||
|
||||
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() {
|
||||
const accessToken = localStorage.getItem("auth_token")
|
||||
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")
|
||||
|
||||
clearSession()
|
||||
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 }
|
||||
}
|
||||
|
||||
228
src/api/base.ts
228
src/api/base.ts
@@ -3,53 +3,32 @@ import {
|
||||
type ApiResponse,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
UnauthorizedError,
|
||||
} from "@/lib/api"
|
||||
import { clearSession, getUserToken } from "./token"
|
||||
|
||||
let deviceToken: string | null = null
|
||||
let deviceTokenExpire: Date | null = null
|
||||
type CallMode = "public" | "device" | "user"
|
||||
|
||||
async function call<T = undefined>(
|
||||
async function request<R = undefined>(
|
||||
endpoint: string,
|
||||
body?: string,
|
||||
auth?: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
init: {
|
||||
method: "GET" | "POST"
|
||||
body?: string
|
||||
auth?: string
|
||||
},
|
||||
): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
const headers: HeadersInit = {}
|
||||
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}`, {
|
||||
method: "POST",
|
||||
method: init.method,
|
||||
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"
|
||||
|
||||
if (contentType.includes("text/plain")) {
|
||||
@@ -61,11 +40,18 @@ async function call<T = undefined>(
|
||||
message: text || "请求失败",
|
||||
}
|
||||
}
|
||||
return { success: true, data: undefined as T }
|
||||
return { success: true, data: undefined as R }
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -73,6 +59,20 @@ async function call<T = undefined>(
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -87,72 +87,124 @@ async function call<T = undefined>(
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Public
|
||||
// ======================
|
||||
let deviceToken: string | null = null
|
||||
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>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
return call(endpoint, data ? JSON.stringify(data) : undefined)
|
||||
return call<R>("public", endpoint, data, method)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Device
|
||||
// ======================
|
||||
async function callByDevice<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
// 检查内存中的 Token 是否过期
|
||||
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}`,
|
||||
)
|
||||
return call<R>("device", endpoint, data, method)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// User
|
||||
// ======================
|
||||
async function callByUser<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
// 这里我们假设用户登录后的 Token 存在 localStorage 里
|
||||
// 如果你是用 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}`,
|
||||
)
|
||||
return call<R>("user", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callPublic }
|
||||
|
||||
@@ -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() {
|
||||
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
|
||||
}
|
||||
|
||||
// 校验验证码
|
||||
// 假设后端接口为 /api/captcha/verify,返回 { success: true/false }
|
||||
export async function verifyCaptcha(token: string, answer: string) {
|
||||
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
|
||||
}
|
||||
64
src/api/order.ts
Normal file
64
src/api/order.ts
Normal 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
11
src/api/product.ts
Normal 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
39
src/api/token.ts
Normal 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))
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByDevice } from "./base"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
|
||||
export async function sendSMS(
|
||||
phone: string,
|
||||
purpose: SMSPurpose = "User_Code",
|
||||
): Promise<ApiResponse> {
|
||||
return await callByDevice("/api/verify/sms", {
|
||||
phone,
|
||||
purpose,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callPublic("/user/SendPhonesCodevefy", {
|
||||
key: purpose,
|
||||
phone,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
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 }) {
|
||||
const location = useLocation()
|
||||
const [isValid, setIsValid] = useState<boolean | null>(null)
|
||||
|
||||
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>
|
||||
)
|
||||
const [isValid] = useState(isAuthed)
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
|
||||
184
src/components/productDropdown.tsx
Normal file
184
src/components/productDropdown.tsx
Normal 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="海量全球住宅优质IP,4G/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>
|
||||
)
|
||||
}
|
||||
69
src/components/profileOrLogin.tsx
Normal file
69
src/components/profileOrLogin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
@@ -25,7 +25,7 @@ function RadioGroupItem({
|
||||
data-slot="radio-group-item"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,72 +1,10 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { logout } from "@/api/auth"
|
||||
import { useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import ProductDropdown from "@/components/productDropdown"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import Wrap from "@/components/wrap"
|
||||
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() {
|
||||
const scroll = useSyncExternalStore(
|
||||
callback => {
|
||||
@@ -229,108 +167,3 @@ const MenuItem = ({
|
||||
</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="海量全球住宅优质IP,4G/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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EyeIcon, EyeOffIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { login } from "@/api/auth"
|
||||
import { apiLogin, apiRegister } from "@/api/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@@ -87,15 +87,17 @@ function LoginForm() {
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await login({
|
||||
username: account,
|
||||
const resp = await apiLogin({
|
||||
logincode: account,
|
||||
password,
|
||||
remember,
|
||||
mode: "password",
|
||||
})
|
||||
console.log(resp, "resp")
|
||||
|
||||
if (resp.success) {
|
||||
toast.success("登录成功")
|
||||
navigate("/admin")
|
||||
toast.success("登录成功", {
|
||||
description: "欢迎回来!",
|
||||
})
|
||||
navigate("/")
|
||||
} else {
|
||||
toast.error(resp.message || "登录失败")
|
||||
}
|
||||
@@ -110,12 +112,12 @@ function LoginForm() {
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
||||
账号
|
||||
会员号
|
||||
</label>
|
||||
<Input
|
||||
id="account"
|
||||
type="text"
|
||||
placeholder="请输入淘宝名/会员手机号码"
|
||||
placeholder="请输入会员号"
|
||||
className="w-full"
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
@@ -129,7 +131,7 @@ function LoginForm() {
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码(8-12位数字或字母组合)"
|
||||
placeholder="请输入密码"
|
||||
className="w-full pr-10"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
@@ -182,7 +184,6 @@ function LoginForm() {
|
||||
}
|
||||
|
||||
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const [phone, setPhone] = useState("")
|
||||
const [smsCode, setSmsCode] = useState("")
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
@@ -237,12 +238,12 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
toast.error("请输入正确的手机号")
|
||||
return
|
||||
}
|
||||
if (smsCode.length < 4 || smsCode.length > 6) {
|
||||
if (smsCode.length < 3 || smsCode.length > 6) {
|
||||
toast.error("请输入短信验证码")
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]{8,12}$/.test(newPassword)) {
|
||||
toast.error("密码为8-12位数字或字母组合")
|
||||
if (!newPassword.trim()) {
|
||||
toast.error("请输入密码")
|
||||
return
|
||||
}
|
||||
if (!agreed) {
|
||||
@@ -252,19 +253,17 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await login({
|
||||
username: phone,
|
||||
const resp = await apiRegister({
|
||||
phone,
|
||||
password: newPassword,
|
||||
code: smsCode,
|
||||
remember: false,
|
||||
mode: "phone_code",
|
||||
wx,
|
||||
qq,
|
||||
})
|
||||
|
||||
if (resp.success) {
|
||||
toast.success("注册成功")
|
||||
navigate("/admin")
|
||||
toast.success("注册成功,请登录")
|
||||
onSwitchToLogin()
|
||||
} else {
|
||||
toast.error(resp.message || "注册失败")
|
||||
}
|
||||
@@ -334,7 +333,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
<Input
|
||||
id="reg-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入8-12位数字或字母组合"
|
||||
placeholder="请输入密码"
|
||||
className="w-full pr-10"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
|
||||
935
src/home/product/buy.tsx
Normal file
935
src/home/product/buy.tsx
Normal file
@@ -0,0 +1,935 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import type { CreateOrderData } from "@/api/order"
|
||||
import { checkPayStatus, createOrder, createTestAccount } from "@/api/order"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import Wrap from "@/components/wrap"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ============ 工具函数 ============
|
||||
function randomChars(length: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz"
|
||||
let result = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function randomDigits(length: number): string {
|
||||
let result = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += Math.floor(Math.random() * 10).toString()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 类型定义 ============
|
||||
type PaymentState = {
|
||||
orderData: CreateOrderData
|
||||
polling: boolean
|
||||
}
|
||||
|
||||
// ============ 子组件 ============
|
||||
|
||||
// 价格卡片组件
|
||||
function PriceCard({ product, isTest }: { product: Product; isTest: boolean }) {
|
||||
return (
|
||||
<div className="bg-linear-to-r from-orange-50 to-amber-50 rounded-2xl p-5 mb-6 flex text-center border border-orange-100/60">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">套餐价格</span>
|
||||
{isTest ? (
|
||||
<span className="text-3xl font-bold text-green-500">免费</span>
|
||||
) : (
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-orange-500">
|
||||
¥{product.price.toFixed(2)}
|
||||
</span>
|
||||
{product.originalPrice !== undefined &&
|
||||
product.originalPrice > product.price && (
|
||||
<span className="text-sm text-gray-400 line-through mb-0.5">
|
||||
¥{product.originalPrice.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{product.duration && (
|
||||
<span className="text-sm text-gray-500 bg-white/60 px-4 py-1.5 rounded-full">
|
||||
{product.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 模式切换 Tab 组件
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 mb-6">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2.5 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "single"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("single")}
|
||||
>
|
||||
单个注册
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2.5 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "batch"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("batch")}
|
||||
>
|
||||
批量注册
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 连接数选择器组件
|
||||
function ConnectCountSelector({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
label: string
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full"
|
||||
disabled={value <= 1}
|
||||
onClick={() => onChange(Math.max(1, value - 1))}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 12H4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="w-12 text-center text-xl font-semibold text-gray-700">
|
||||
{value}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full"
|
||||
onClick={() => onChange(value + 1)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
{hint && <span className="text-xs text-gray-400 ml-1">{hint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 余额展示组件
|
||||
function BalanceDisplay({ balance }: { balance: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-5 bg-gray-50/80 rounded-xl border border-gray-100">
|
||||
<span className="text-sm text-gray-600">余额</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">
|
||||
¥{balance.toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
>
|
||||
去充值 →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 支付方式选择组件
|
||||
function PaymentMethodSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
支付方式
|
||||
</Label>
|
||||
<RadioGroup value={value} onValueChange={onChange} className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700"
|
||||
>
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 金额汇总组件
|
||||
function PriceSummary({ total }: { total: number }) {
|
||||
return (
|
||||
<div className="border-t border-gray-100 pt-4 mt-2 space-y-1.5">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">总金额</span>
|
||||
<span className="font-medium text-gray-800">¥{total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">应付款</span>
|
||||
<span className="text-2xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 表单操作按钮组件
|
||||
function FormActions({
|
||||
onBack,
|
||||
onSubmit,
|
||||
submitText,
|
||||
submitting,
|
||||
}: {
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
submitText: string
|
||||
submitting: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-12 rounded-xl text-base border-gray-200 hover:bg-gray-50"
|
||||
onClick={onBack}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 hover:shadow-lg hover:shadow-blue-200/50 transition-all"
|
||||
onClick={onSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : submitText}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 优惠券选择组件
|
||||
function CouponSelector() {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
选择优惠券
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="h-12 rounded-xl">
|
||||
<SelectValue placeholder="请选择优惠券" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">暂无可用优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 单个注册表单组件 ============
|
||||
function SingleRegisterForm({
|
||||
account,
|
||||
setAccount,
|
||||
pwd,
|
||||
setPwd,
|
||||
connectCount,
|
||||
setConnectCount,
|
||||
payType,
|
||||
setPayType,
|
||||
isTest,
|
||||
isDayCard,
|
||||
isNormalPackage,
|
||||
total,
|
||||
submitting,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
product: Product
|
||||
account: string
|
||||
setAccount: (v: string) => void
|
||||
pwd: string
|
||||
setPwd: (v: string) => void
|
||||
connectCount: number
|
||||
setConnectCount: (v: number) => void
|
||||
payType: string
|
||||
setPayType: (v: string) => void
|
||||
isTest: boolean
|
||||
isDayCard: boolean
|
||||
isNormalPackage: boolean
|
||||
total: number
|
||||
submitting: boolean
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 天卡提示 */}
|
||||
{isDayCard && (
|
||||
<div className="text-sm text-orange-500 text-center font-medium bg-orange-50 py-2.5 rounded-xl border border-orange-100">
|
||||
⚠️ 天卡不支持退款,请谨慎购买
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品账号
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">4至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{/* IP密码 */}
|
||||
<div className="flex">
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品密码
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">1至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{isNormalPackage && (
|
||||
<>
|
||||
<ConnectCountSelector
|
||||
value={connectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
/>
|
||||
|
||||
<CouponSelector />
|
||||
|
||||
<BalanceDisplay balance={10.0} />
|
||||
|
||||
<PaymentMethodSelector value={payType} onChange={setPayType} />
|
||||
|
||||
<PriceSummary total={total} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormActions
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
submitText={isTest ? "领取试用" : "确认支付"}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册表单组件 ============
|
||||
function BatchRegisterForm({
|
||||
batchAccount,
|
||||
setBatchAccount,
|
||||
batchStart,
|
||||
setBatchStart,
|
||||
batchCount,
|
||||
setBatchCount,
|
||||
batchPwd,
|
||||
setBatchPwd,
|
||||
batchConnectCount,
|
||||
setBatchConnectCount,
|
||||
batchPayType,
|
||||
setBatchPayType,
|
||||
batchPreview,
|
||||
total,
|
||||
submitting,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: {
|
||||
batchAccount: string
|
||||
setBatchAccount: (v: string) => void
|
||||
batchStart: number
|
||||
setBatchStart: (v: number) => void
|
||||
batchCount: number
|
||||
setBatchCount: (v: number) => void
|
||||
batchPwd: string
|
||||
setBatchPwd: (v: string) => void
|
||||
batchConnectCount: number
|
||||
setBatchConnectCount: (v: number) => void
|
||||
batchPayType: string
|
||||
setBatchPayType: (v: string) => void
|
||||
batchPreview: string
|
||||
total: number
|
||||
submitting: boolean
|
||||
onBack: () => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 批量说明 */}
|
||||
<div className="text-sm text-gray-600 leading-relaxed p-4 bg-blue-50/70 rounded-xl border border-blue-100">
|
||||
<p>
|
||||
批量注册的账号会使用【账号前缀】+【开始数】+【个数】顺序进行注册,
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
如:注册账号前缀为【user】开始数为【2】个数为【10】,则注册的账号为:
|
||||
<span className="text-blue-600 font-medium">
|
||||
{" "}
|
||||
user2,user3,user4,....user11
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP账号前缀
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">3至8位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
开始号
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">本批次账号的起始账号</p>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
注册个数
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">本批次的账号个数</p>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="text-sm text-gray-600 p-3.5 bg-gray-50/80 rounded-xl border border-gray-100">
|
||||
即将生成的账号:
|
||||
<span className="font-medium text-gray-800">{batchPreview}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP密码 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700 mb-1.5 block">
|
||||
IP产品密码
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-12 rounded-xl border-gray-200 focus:border-blue-400 focus:ring-blue-400/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1.5">1至10位字母或数字或组合</p>
|
||||
</div>
|
||||
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
|
||||
<CouponSelector />
|
||||
|
||||
<BalanceDisplay balance={10.0} />
|
||||
|
||||
<PaymentMethodSelector value={batchPayType} onChange={setBatchPayType} />
|
||||
|
||||
<PriceSummary total={total} />
|
||||
|
||||
<FormActions
|
||||
onBack={onBack}
|
||||
onSubmit={onSubmit}
|
||||
submitText="确认支付"
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 支付弹窗组件 ============
|
||||
function PaymentDialog({
|
||||
payment,
|
||||
onClose,
|
||||
}: {
|
||||
payment: PaymentState | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!payment) return null
|
||||
|
||||
const isWechat = payment.orderData.OrderInfo.PayType === 70
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center text-lg">
|
||||
{isWechat ? "微信支付" : "支付宝支付"} | 收银台
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex justify-center">
|
||||
{isWechat ? (
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(payment.orderData.PayData)}`}
|
||||
alt="微信支付二维码"
|
||||
className="w-50 h-50 rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: 支付宝表单由后端返回
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: payment.orderData.PayData,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-3 space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">收款方</span>
|
||||
<span className="font-medium">聚IP</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">金额</span>
|
||||
<span className="font-bold text-orange-500">
|
||||
¥{payment.orderData.OrderInfo.OtherPayAmount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">下单时间</span>
|
||||
<span className="text-gray-600">
|
||||
{payment.orderData.OrderInfo.CreateTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{payment.orderData.OrderInfo.OrderNo}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-gray-400">
|
||||
支付完成后将自动关闭此窗口
|
||||
</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BuyPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const product = location.state?.product as Product | undefined
|
||||
|
||||
// 状态
|
||||
const [mode, setMode] = useState<"single" | "batch">("single")
|
||||
const [account, setAccount] = useState("")
|
||||
const [pwd, setPwd] = useState("")
|
||||
const [connectCount, setConnectCount] = useState(1)
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [payment, setPayment] = useState<PaymentState | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
const [batchPayType, setBatchPayType] = useState("100")
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
if (!product) {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setConnectCount(1)
|
||||
setPayType("100")
|
||||
setMode("single")
|
||||
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchStart(1)
|
||||
setBatchCount(1)
|
||||
setBatchPwd(pw)
|
||||
setBatchConnectCount(1)
|
||||
setBatchPayType("100")
|
||||
}, [product, navigate])
|
||||
|
||||
// 清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startPolling = useCallback(
|
||||
(orderNo: string) => {
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
const result = await checkPayStatus(orderNo)
|
||||
if (result.success && result.data === 1) {
|
||||
stopPolling()
|
||||
setPayment(null)
|
||||
toast.success("支付成功!")
|
||||
}
|
||||
}, 3000)
|
||||
},
|
||||
[stopPolling],
|
||||
)
|
||||
|
||||
if (!product) return null
|
||||
|
||||
// 计算属性
|
||||
const price = product.price
|
||||
const packageId = Number(product.id)
|
||||
const isTest = product.isTest ?? false
|
||||
const isDayCard = product.card === "天"
|
||||
const showTabs = !isTest && !isDayCard
|
||||
const isNormalPackage = !isTest && !isDayCard
|
||||
|
||||
const singleTotal = price * connectCount
|
||||
const batchTotal = price * batchConnectCount * batchCount
|
||||
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 3) {
|
||||
const items: string[] = []
|
||||
for (let i = batchStart; i <= end; i++) {
|
||||
items.push(`${batchAccount}${i}`)
|
||||
}
|
||||
return items.join(", ")
|
||||
}
|
||||
return `${batchAccount}${batchStart}, ${batchAccount}${batchStart + 1}, ${batchAccount}${batchStart + 2}...${batchAccount}${end}`
|
||||
})()
|
||||
|
||||
// 支付处理函数
|
||||
const handleSinglePay = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oPayType = isTest ? 0 : Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
|
||||
const result = await createOrder({
|
||||
PackageId: packageId,
|
||||
OrderType: 1,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: connectCount,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
if (result.data === "00") {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
const orderData = result.data as CreateOrderData
|
||||
setPayment({ orderData, polling: true })
|
||||
startPolling(orderData.OrderInfo.OrderNo)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchPay = async () => {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oPayType = Number(batchPayType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
|
||||
const result = await createOrder({
|
||||
PackageId: packageId,
|
||||
OrderType: 2,
|
||||
Account: batchAccount.trim(),
|
||||
Pwd: batchPwd.trim(),
|
||||
ConnectCount: batchConnectCount,
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: price,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
if (result.data === "00") {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
const orderData = result.data as CreateOrderData
|
||||
setPayment({ orderData, polling: true })
|
||||
startPolling(orderData.OrderInfo.OrderNo)
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestClaim = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createTestAccount({
|
||||
ProductId: product.productId,
|
||||
PackageId: packageId,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
})
|
||||
if (result.success) {
|
||||
toast.success("领取成功")
|
||||
navigate(-1)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClosePayment = () => {
|
||||
stopPolling()
|
||||
setPayment(null)
|
||||
}
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 判断单个表单的提交处理
|
||||
const handleSingleSubmit = isTest ? handleTestClaim : handleSinglePay
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen">
|
||||
<div className="text-center mt-20">
|
||||
<h4 className="text-2xl font-bold text-gray-800">{product.title}</h4>
|
||||
<p className="text-sm text-amber-600 mb-6">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 价格卡片 */}
|
||||
<PriceCard product={product} isTest={isTest} />
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && <ModeTabs mode={mode} onModeChange={setMode} />}
|
||||
|
||||
{/* 表单内容 */}
|
||||
{!showTabs || mode === "single" ? (
|
||||
<SingleRegisterForm
|
||||
product={product}
|
||||
account={account}
|
||||
setAccount={setAccount}
|
||||
pwd={pwd}
|
||||
setPwd={setPwd}
|
||||
connectCount={connectCount}
|
||||
setConnectCount={setConnectCount}
|
||||
payType={payType}
|
||||
setPayType={setPayType}
|
||||
isTest={isTest}
|
||||
isDayCard={isDayCard}
|
||||
isNormalPackage={isNormalPackage}
|
||||
total={singleTotal}
|
||||
submitting={submitting}
|
||||
onBack={handleBack}
|
||||
onSubmit={handleSingleSubmit}
|
||||
/>
|
||||
) : (
|
||||
<BatchRegisterForm
|
||||
batchAccount={batchAccount}
|
||||
setBatchAccount={setBatchAccount}
|
||||
batchStart={batchStart}
|
||||
setBatchStart={setBatchStart}
|
||||
batchCount={batchCount}
|
||||
setBatchCount={setBatchCount}
|
||||
batchPwd={batchPwd}
|
||||
setBatchPwd={setBatchPwd}
|
||||
batchConnectCount={batchConnectCount}
|
||||
setBatchConnectCount={setBatchConnectCount}
|
||||
batchPayType={batchPayType}
|
||||
setBatchPayType={setBatchPayType}
|
||||
batchPreview={batchPreview}
|
||||
total={batchTotal}
|
||||
submitting={submitting}
|
||||
onBack={handleBack}
|
||||
onSubmit={handleBatchPay}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 支付弹窗 */}
|
||||
<PaymentDialog payment={payment} onClose={handleClosePayment} />
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { fetchProducts } from "@/api/product"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { PRODUCT_DATA } from "@/lib/models/product"
|
||||
import type { Tab } from "@/lib/models/product"
|
||||
import { transformToTabs } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
import ProductGrid from "./productGrid"
|
||||
import ProductSidebar from "./productSidebar"
|
||||
@@ -18,45 +20,93 @@ const TAB_ALIAS: Record<string, string> = {
|
||||
|
||||
export default function ProductPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [activeVersionId, setActiveVersionId] = useState<string>("v1")
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [activeBrandId, setActiveBrandId] = useState("")
|
||||
const [selectedVersionId, setSelectedVersionId] = useState("")
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts().then(result => {
|
||||
if (result.success) {
|
||||
const newTabs = transformToTabs(result.data)
|
||||
setTabs(newTabs)
|
||||
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
||||
setActiveBrandId(newTabs[0].brands[0].id)
|
||||
initializedRef.current = true
|
||||
}
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const urlTab = searchParams.get("tab") || "dynamic"
|
||||
const activeTabId = TAB_ALIAS[urlTab] || urlTab
|
||||
const activeBrandId = searchParams.get("brand") || ""
|
||||
|
||||
const currentTab =
|
||||
PRODUCT_DATA.find(t => t.id === activeTabId) || PRODUCT_DATA[0]
|
||||
const currentTab = tabs.find(t => t.id === activeTabId) || tabs[0]
|
||||
const currentBrand =
|
||||
currentTab.brands.find(b => b.id === activeBrandId) || currentTab.brands[0]
|
||||
currentTab?.brands.find(b => b.id === activeBrandId) ||
|
||||
currentTab?.brands[0]
|
||||
|
||||
const effectiveVersionId =
|
||||
selectedVersionId || currentBrand?.versions?.[0]?.id || ""
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
const targetTab = PRODUCT_DATA.find(t => t.id === tabId)
|
||||
setSearchParams({ tab: tabId })
|
||||
const targetTab = tabs.find(t => t.id === tabId)
|
||||
const firstBrand = targetTab?.brands[0]
|
||||
setSearchParams({ tab: tabId, brand: firstBrand?.id || "" })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(firstBrand?.id || "")
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const handleBrandSelect = (brandId: string) => {
|
||||
setSearchParams({ tab: activeTabId, brand: brandId })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(brandId)
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const getDisplayProducts = () => {
|
||||
const displayProducts = (() => {
|
||||
if (!currentBrand) return []
|
||||
|
||||
if (currentBrand.versions && currentBrand.versions.length > 0) {
|
||||
return currentBrand.products.filter(p => p.version === activeVersionId)
|
||||
return currentBrand.products.filter(p => p.version === effectiveVersionId)
|
||||
}
|
||||
return currentBrand.products
|
||||
})()
|
||||
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">加载中...</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-red-400">加载失败:{error}</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">暂无产品数据</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const displayProducts = getDisplayProducts()
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-16 pt-30 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<ProductTabs
|
||||
tabs={PRODUCT_DATA}
|
||||
tabs={tabs}
|
||||
activeId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
@@ -73,7 +123,6 @@ export default function ProductPage() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{!isPurchaseInfo ? (
|
||||
<>
|
||||
{" "}
|
||||
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
@@ -105,10 +154,10 @@ export default function ProductPage() {
|
||||
{currentBrand.versions.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setActiveVersionId(v.id)}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||
activeVersionId === v.id
|
||||
effectiveVersionId === v.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import vector from "../_assets/vector.webp"
|
||||
@@ -7,6 +8,8 @@ type ProductGridProps = {
|
||||
}
|
||||
|
||||
export default function ProductGrid({ products }: ProductGridProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||
{products.map(product => (
|
||||
@@ -72,6 +75,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => navigate("/product/buy", { state: { product } })}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 定义后端服务URL和OAuth2配置
|
||||
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 API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""
|
||||
|
||||
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
|
||||
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> =
|
||||
| {
|
||||
success: false
|
||||
status: number
|
||||
status?: number
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
@@ -20,32 +19,4 @@ type ApiResponse<T = undefined> =
|
||||
data: T
|
||||
}
|
||||
|
||||
type PageRecord<T = unknown> = {
|
||||
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,
|
||||
}
|
||||
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||
|
||||
@@ -5,6 +5,7 @@ export type Version = {
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
productId: number
|
||||
title: string
|
||||
price: number
|
||||
desc?: string
|
||||
@@ -14,6 +15,7 @@ export type Product = {
|
||||
duration?: string
|
||||
version?: string
|
||||
features?: string[]
|
||||
isTest?: boolean
|
||||
}
|
||||
|
||||
export type Brand = {
|
||||
@@ -29,267 +31,109 @@ export type Tab = {
|
||||
brands: Brand[]
|
||||
}
|
||||
|
||||
export const PRODUCT_DATA: Tab[] = [
|
||||
{
|
||||
id: "dynamic",
|
||||
label: "动态独享IP",
|
||||
brands: [
|
||||
{
|
||||
id: "fox",
|
||||
name: "极狐IP(推荐)",
|
||||
versions: [
|
||||
{ id: "v1", label: "尊享版-不限速" },
|
||||
{ id: "v2", label: "高级版-限速6M" },
|
||||
{ id: "v3", label: "普通版-限速2M" },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
id: "1",
|
||||
title: "免费",
|
||||
price: 0,
|
||||
desc: "注册即送3次",
|
||||
card: "测试卡",
|
||||
duration: "1小时",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "首单低至",
|
||||
price: 0.1,
|
||||
originalPrice: 0.1,
|
||||
// card: "测试卡",
|
||||
duration: "24小时",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
tag: "8.5折",
|
||||
originalPrice: 37.5,
|
||||
// card: "周卡",
|
||||
duration: "7天",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "月卡套餐",
|
||||
price: 78,
|
||||
tag: "8.5折",
|
||||
originalPrice: 80.0,
|
||||
// card: "月卡",
|
||||
duration: "30天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "季卡套餐",
|
||||
price: 208,
|
||||
tag: "8.5折",
|
||||
originalPrice: 220.0,
|
||||
// card: "季卡",
|
||||
duration: "90天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "年卡套餐",
|
||||
price: 770,
|
||||
tag: "8.5折",
|
||||
originalPrice: 800.0,
|
||||
card: "年卡",
|
||||
duration: "365天",
|
||||
version: "v3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "aurora",
|
||||
name: "极光IP(推荐)",
|
||||
versions: [
|
||||
{ id: "v1", label: "尊享版-不限速" },
|
||||
{ id: "v2", label: "高级版-限速6M" },
|
||||
{ id: "v3", label: "普通版-限速2M" },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
id: "7",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
originalPrice: 37.5,
|
||||
card: "周卡",
|
||||
duration: "7天",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
title: "月卡套餐",
|
||||
price: 78,
|
||||
tag: "8.5折",
|
||||
originalPrice: 80.0,
|
||||
card: "月卡",
|
||||
duration: "30天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
]
|
||||
export type ProductItem = {
|
||||
Product: {
|
||||
Id: number
|
||||
Name: string
|
||||
Image: string
|
||||
Sort: number
|
||||
Content: string
|
||||
Profile: string
|
||||
TenantId: number
|
||||
OnLine: number
|
||||
ContentLine: string[]
|
||||
}
|
||||
Packages: PackageItem[]
|
||||
}
|
||||
|
||||
export type PackageItem = {
|
||||
Id: number
|
||||
TenantId: number
|
||||
ProductId: number
|
||||
PackageType: number
|
||||
Name: string
|
||||
Title: string
|
||||
Image: string
|
||||
Status: number
|
||||
Profile: string
|
||||
Price: number
|
||||
LinePrice: number
|
||||
DayPrice: number
|
||||
MinPrice: number
|
||||
DayCount: number
|
||||
OriginKey: string
|
||||
OriginName: string
|
||||
IsTest: number
|
||||
DeleteTag: number
|
||||
}
|
||||
|
||||
function extractTier(name: string): string {
|
||||
const match = name.match(/\((.+?)\)$/)
|
||||
return match ? match[1] : "默认"
|
||||
}
|
||||
|
||||
export function transformToTabs(data: ProductItem[]): Tab[] {
|
||||
const activeProducts = data.filter(p => p.Product.OnLine === 1)
|
||||
|
||||
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
||||
console.log(
|
||||
activeProducts,
|
||||
"activeProductsactiveProductsactiveProductsactiveProducts",
|
||||
)
|
||||
|
||||
const brands: Brand[] = activeProducts.map(item => {
|
||||
const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
|
||||
|
||||
const tierMap = new Map<string, PackageItem[]>()
|
||||
for (const pkg of activePackages) {
|
||||
const tier = extractTier(pkg.Name)
|
||||
if (!tierMap.has(tier)) tierMap.set(tier, [])
|
||||
tierMap.get(tier)?.push(pkg)
|
||||
}
|
||||
|
||||
const tiers = Array.from(tierMap.entries())
|
||||
|
||||
const versions: Version[] | undefined =
|
||||
tiers.length > 1
|
||||
? tiers.map(([tier]) => ({ id: tier, label: tier }))
|
||||
: undefined
|
||||
|
||||
const products: Product[] = []
|
||||
for (const [tier, packages] of tiers) {
|
||||
for (const pkg of packages) {
|
||||
products.push({
|
||||
id: pkg.Id.toString(),
|
||||
productId: item.Product.Id,
|
||||
title: pkg.Name,
|
||||
price: pkg.Price,
|
||||
originalPrice: pkg.LinePrice,
|
||||
duration: pkg.Profile,
|
||||
card: pkg.OriginName,
|
||||
version: tier,
|
||||
isTest: pkg.IsTest === 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.Product.Name,
|
||||
name: item.Product.Name,
|
||||
versions,
|
||||
products,
|
||||
}
|
||||
})
|
||||
|
||||
brands.push({
|
||||
id: "info",
|
||||
name: "购买须知",
|
||||
products: [],
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: "dynamic",
|
||||
label: "动态独享IP",
|
||||
brands,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import IpLinePage from "@/home/ipLine"
|
||||
import LayoutPage from "@/home/layout"
|
||||
import LoginPage from "@/home/login"
|
||||
import ProductPage from "@/home/product"
|
||||
import BuyPage from "@/home/product/buy"
|
||||
import HttpPage from "@/home/product/http"
|
||||
import RouterosPage from "@/home/product/routeros"
|
||||
import SoftDownloadPage from "@/home/softDownload"
|
||||
@@ -24,6 +25,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: "product", element: <ProductPage /> },
|
||||
{ path: "product/buy", element: <BuyPage /> },
|
||||
{ path: "product/http", element: <HttpPage /> },
|
||||
{ path: "product/routeros", element: <RouterosPage /> },
|
||||
{ path: "ipline", element: <IpLinePage /> },
|
||||
|
||||
@@ -11,4 +11,18 @@ export default defineConfig({
|
||||
"@": 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"
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user