更新登录功能
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_ID=web
|
||||||
VITE_CLIENT_SECRET=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 { RouterProvider } from "react-router-dom"
|
||||||
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { router } from "./routers"
|
import { router } from "./routers"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <RouterProvider router={router} />
|
return (
|
||||||
|
<>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster position="top-center" richColors expand />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { type ReactNode, useState } from "react"
|
import { type ReactNode, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -100,22 +101,12 @@ export function Header() {
|
|||||||
<AdminNavMenu />
|
<AdminNavMenu />
|
||||||
|
|
||||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||||
{/* <Link
|
<ProfileOrLogin />
|
||||||
to="/"
|
|
||||||
className="flex-none h-16 flex items-center justify-center text-sm"
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Link> */}
|
|
||||||
<UserCenter />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserCenter() {
|
|
||||||
return <div className="flex items-center gap-2">用户</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { navbar } = useLayout()
|
const { navbar } = useLayout()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useRef, useState } from "react"
|
import { useCallback, useRef, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import { NavLink, useNavigate } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
|
import ProductDropdown from "@/components/productDropdown"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export function AdminNavMenu() {
|
export function AdminNavMenu() {
|
||||||
@@ -8,7 +9,7 @@ export function AdminNavMenu() {
|
|||||||
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
||||||
<AdminNavItem to="/">首页</AdminNavItem>
|
<AdminNavItem to="/">首页</AdminNavItem>
|
||||||
<AdminMenuItem text="产品购买">
|
<AdminMenuItem text="产品购买">
|
||||||
<ProductDropdown />
|
<ProductDropdown size="sm" />
|
||||||
</AdminMenuItem>
|
</AdminMenuItem>
|
||||||
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
||||||
<AdminNavItem to="/softdownload">软件下载</AdminNavItem>
|
<AdminNavItem to="/softdownload">软件下载</AdminNavItem>
|
||||||
@@ -138,110 +139,3 @@ function AdminMenuItem({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProductDropdown() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleClick = (path: string) => {
|
|
||||||
navigate(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<ProductCard
|
|
||||||
title="动静态IP"
|
|
||||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
|
||||||
tag="热门"
|
|
||||||
onClick={() => handleClick("/product?tab=short")}
|
|
||||||
/>
|
|
||||||
<ProductCard
|
|
||||||
title="新HTTP/S5"
|
|
||||||
desc="IP可用时长从数小时到365天全覆盖"
|
|
||||||
tag="稳定"
|
|
||||||
onClick={() => window.open("https://lanhuip.com", "_blank")}
|
|
||||||
/>
|
|
||||||
<ProductCard
|
|
||||||
title="HTTP/S5"
|
|
||||||
desc="海量全球住宅优质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 { callPublic } from "./base"
|
||||||
import { callByDevice, callByUser } from "./base"
|
import { clearSession, readCookie, setSession } from "./token"
|
||||||
|
|
||||||
export interface User {
|
export async function apiLogin(props: {
|
||||||
id: number
|
logincode: string
|
||||||
username: string
|
|
||||||
loginCode: string
|
|
||||||
phone: string
|
|
||||||
name: string
|
|
||||||
wx: string
|
|
||||||
qq: string
|
|
||||||
taobao: string
|
|
||||||
email: string
|
|
||||||
restAmount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LoginMode = "phone_code" | "password"
|
|
||||||
|
|
||||||
export async function login(props: {
|
|
||||||
username: string
|
|
||||||
password: string
|
password: string
|
||||||
remember: boolean
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
mode: LoginMode
|
const result = await callPublic("/user/ApiLogin", {
|
||||||
code?: string
|
Logincode: props.logincode,
|
||||||
wx?: string
|
Password: props.password,
|
||||||
qq?: string
|
|
||||||
}): Promise<ApiResponse> {
|
|
||||||
const result = await callByDevice<{
|
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
expires_in: number
|
|
||||||
}>("/api/auth/token", {
|
|
||||||
...props,
|
|
||||||
grant_type: "password",
|
|
||||||
login_type: props.mode,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) return result
|
if (!result.success) {
|
||||||
|
return { success: false, message: result.message || "用户名或者密码不正确" }
|
||||||
|
}
|
||||||
|
|
||||||
const { access_token, refresh_token, expires_in } = result.data
|
const token = readCookie("token")
|
||||||
localStorage.setItem("auth_token", access_token)
|
const userInfoRaw = readCookie("userInfo")
|
||||||
localStorage.setItem("auth_refresh", refresh_token)
|
|
||||||
localStorage.setItem(
|
|
||||||
"auth_expires_in",
|
|
||||||
String(Date.now() + expires_in * 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { success: true, data: undefined }
|
if (!token) {
|
||||||
|
return { success: false, message: "登录成功但未获取到 token" }
|
||||||
|
}
|
||||||
|
|
||||||
|
let userInfo: unknown = userInfoRaw
|
||||||
|
if (userInfoRaw) {
|
||||||
|
try {
|
||||||
|
userInfo = JSON.parse(userInfoRaw)
|
||||||
|
} catch {
|
||||||
|
userInfo = userInfoRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(token, userInfo)
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRegister(props: {
|
||||||
|
phone: string
|
||||||
|
password: string
|
||||||
|
code: string
|
||||||
|
wx?: string
|
||||||
|
qq?: string
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const result = await callPublic("/user/ApiRegist", {
|
||||||
|
Code: props.code,
|
||||||
|
Phone: props.phone,
|
||||||
|
Pwd: props.password,
|
||||||
|
QQ: props.qq ?? "",
|
||||||
|
Wx: props.wx ?? "",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, message: result.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
const accessToken = localStorage.getItem("auth_token")
|
clearSession()
|
||||||
const refreshToken = localStorage.getItem("auth_refresh")
|
|
||||||
|
|
||||||
if (accessToken && refreshToken) {
|
|
||||||
await callByUser("/api/auth/revoke", {
|
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.removeItem("auth_token")
|
|
||||||
localStorage.removeItem("auth_refresh")
|
|
||||||
localStorage.removeItem("auth_expires_in")
|
|
||||||
|
|
||||||
window.location.href = "/"
|
window.location.href = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile() {
|
|
||||||
return await callByUser<User>("/api/auth/introspect")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refreshAuth(): Promise<{
|
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
} | null> {
|
|
||||||
const refreshToken = localStorage.getItem("auth_refresh")
|
|
||||||
if (!refreshToken) return null
|
|
||||||
|
|
||||||
const resp = await callByDevice<{
|
|
||||||
access_token: string
|
|
||||||
refresh_token: string
|
|
||||||
expires_in: number
|
|
||||||
}>(`/api/auth/token`, {
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!resp.success) {
|
|
||||||
localStorage.removeItem("auth_refresh")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { access_token, refresh_token, expires_in } = resp.data
|
|
||||||
localStorage.setItem("auth_token", access_token)
|
|
||||||
localStorage.setItem("auth_refresh", refresh_token)
|
|
||||||
localStorage.setItem(
|
|
||||||
"auth_expires_in",
|
|
||||||
String(Date.now() + expires_in * 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { access_token, refresh_token }
|
|
||||||
}
|
|
||||||
|
|||||||
228
src/api/base.ts
228
src/api/base.ts
@@ -3,53 +3,32 @@ import {
|
|||||||
type ApiResponse,
|
type ApiResponse,
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
CLIENT_SECRET,
|
CLIENT_SECRET,
|
||||||
UnauthorizedError,
|
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import { clearSession, getUserToken } from "./token"
|
||||||
|
|
||||||
let deviceToken: string | null = null
|
type CallMode = "public" | "device" | "user"
|
||||||
let deviceTokenExpire: Date | null = null
|
|
||||||
|
|
||||||
async function call<T = undefined>(
|
async function request<R = undefined>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
body?: string,
|
init: {
|
||||||
auth?: string,
|
method: "GET" | "POST"
|
||||||
): Promise<ApiResponse<T>> {
|
body?: string
|
||||||
|
auth?: string
|
||||||
|
},
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
try {
|
try {
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {}
|
||||||
"Content-Type": "application/json",
|
if (init.body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
}
|
}
|
||||||
if (auth) headers["Authorization"] = auth
|
if (init.auth) headers.Authorization = init.auth
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method: "POST",
|
method: init.method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body: init.body,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
// 如果是 刷新Token 的接口本身报 401,说明 RefreshToken 彻底失效,直接抛出错误
|
|
||||||
if (
|
|
||||||
endpoint.includes("/auth/token") ||
|
|
||||||
endpoint.includes("/auth/revoke")
|
|
||||||
) {
|
|
||||||
throw UnauthorizedError
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态引入 auth.ts 里的刷新函数,避免循环引用报错
|
|
||||||
const { refreshAuth } = await import("./auth")
|
|
||||||
|
|
||||||
// 尝试刷新 Token
|
|
||||||
const newTokens = await refreshAuth()
|
|
||||||
|
|
||||||
if (newTokens) {
|
|
||||||
// 刷新成功,用新 Token 重试当前请求(递归调用)
|
|
||||||
return call(endpoint, body, `Bearer ${newTokens.access_token}`)
|
|
||||||
} else {
|
|
||||||
// 刷新失败,抛出 401 错误,让上层处理跳转
|
|
||||||
throw UnauthorizedError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||||
|
|
||||||
if (contentType.includes("text/plain")) {
|
if (contentType.includes("text/plain")) {
|
||||||
@@ -61,11 +40,18 @@ async function call<T = undefined>(
|
|||||||
message: text || "请求失败",
|
message: text || "请求失败",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true, data: undefined as T }
|
return { success: true, data: undefined as R }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
const json = await response.json()
|
const json = (await response.json()) as R & {
|
||||||
|
message?: string
|
||||||
|
error_description?: string
|
||||||
|
Code?: number
|
||||||
|
Message?: string
|
||||||
|
Data?: unknown
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -73,6 +59,20 @@ async function call<T = undefined>(
|
|||||||
message: json.message || json.error_description || "请求失败",
|
message: json.message || json.error_description || "请求失败",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof json.Code === "number") {
|
||||||
|
if (json.Code === 10000) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: (json.Data ?? json.data ?? undefined) as R,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: json.Message || "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, data: json }
|
return { success: true, data: json }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,72 +87,124 @@ async function call<T = undefined>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================
|
let deviceToken: string | null = null
|
||||||
// Public
|
let deviceTokenExpire = 0
|
||||||
// ======================
|
let deviceTokenPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
|
async function acquireDeviceToken(): Promise<string | null> {
|
||||||
|
if (deviceToken && Date.now() < deviceTokenExpire) return deviceToken
|
||||||
|
if (!deviceTokenPromise) {
|
||||||
|
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
|
||||||
|
deviceTokenPromise = request<{
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
}>("/api/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ grant_type: "client_credentials" }),
|
||||||
|
auth: `Basic ${basic}`,
|
||||||
|
})
|
||||||
|
.then(resp => {
|
||||||
|
if (!resp.success) {
|
||||||
|
deviceToken = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
deviceToken = resp.data.access_token
|
||||||
|
deviceTokenExpire = Date.now() + (resp.data.expires_in - 60) * 1000
|
||||||
|
return resp.data.access_token
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
deviceTokenPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return deviceTokenPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<ApiResponse<unknown>>>()
|
||||||
|
|
||||||
|
function dedupGet<R>(
|
||||||
|
endpoint: string,
|
||||||
|
run: () => Promise<ApiResponse<R>>,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
const existing = inflight.get(endpoint)
|
||||||
|
if (existing) return existing as Promise<ApiResponse<R>>
|
||||||
|
const promise = run().finally(() => inflight.delete(endpoint))
|
||||||
|
inflight.set(endpoint, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<R = undefined>(
|
||||||
|
mode: CallMode,
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
method: "GET" | "POST" = "POST",
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
const body = data === undefined ? undefined : JSON.stringify(data)
|
||||||
|
|
||||||
|
const run = async (): Promise<ApiResponse<R>> => {
|
||||||
|
let auth: string | undefined
|
||||||
|
if (mode === "user") {
|
||||||
|
const token = getUserToken()
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 401,
|
||||||
|
message: "未登录或会话已过期",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth = `Bearer ${token}`
|
||||||
|
} else if (mode === "device") {
|
||||||
|
const token = await acquireDeviceToken()
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 401,
|
||||||
|
message: "设备凭证获取失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await request<R>(endpoint, { method, body, auth })
|
||||||
|
|
||||||
|
if (!resp.success && resp.status === 401) {
|
||||||
|
if (mode === "user") {
|
||||||
|
clearSession()
|
||||||
|
} else if (mode === "device") {
|
||||||
|
deviceToken = null
|
||||||
|
deviceTokenExpire = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && data === undefined) {
|
||||||
|
return dedupGet<R>(endpoint, run)
|
||||||
|
}
|
||||||
|
return run()
|
||||||
|
}
|
||||||
|
|
||||||
async function callPublic<R = undefined>(
|
async function callPublic<R = undefined>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
|
method: "GET" | "POST" = "POST",
|
||||||
): Promise<ApiResponse<R>> {
|
): Promise<ApiResponse<R>> {
|
||||||
return call(endpoint, data ? JSON.stringify(data) : undefined)
|
return call<R>("public", endpoint, data, method)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================
|
|
||||||
// Device
|
|
||||||
// ======================
|
|
||||||
async function callByDevice<R = undefined>(
|
async function callByDevice<R = undefined>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
|
method: "GET" | "POST" = "POST",
|
||||||
): Promise<ApiResponse<R>> {
|
): Promise<ApiResponse<R>> {
|
||||||
// 检查内存中的 Token 是否过期
|
return call<R>("device", endpoint, data, method)
|
||||||
if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) {
|
|
||||||
// 用 btoa 生成 Basic 认证头
|
|
||||||
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
|
|
||||||
const resp = await call<{
|
|
||||||
access_token: string
|
|
||||||
expires_in: number
|
|
||||||
}>(
|
|
||||||
"/api/auth/token",
|
|
||||||
JSON.stringify({ grant_type: "client_credentials" }),
|
|
||||||
`Basic ${basic}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!resp.success) {
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
deviceToken = resp.data.access_token
|
|
||||||
// 提前 60 秒过期,防止临界点失效
|
|
||||||
deviceTokenExpire = new Date(
|
|
||||||
Date.now() + (resp.data.expires_in - 60) * 1000,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return call(
|
|
||||||
endpoint,
|
|
||||||
data ? JSON.stringify(data) : undefined,
|
|
||||||
`Bearer ${deviceToken}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================
|
|
||||||
// User
|
|
||||||
// ======================
|
|
||||||
async function callByUser<R = undefined>(
|
async function callByUser<R = undefined>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data?: unknown,
|
data?: unknown,
|
||||||
|
method: "GET" | "POST" = "POST",
|
||||||
): Promise<ApiResponse<R>> {
|
): Promise<ApiResponse<R>> {
|
||||||
// 这里我们假设用户登录后的 Token 存在 localStorage 里
|
return call<R>("user", endpoint, data, method)
|
||||||
// 如果你是用 Cookie 存 Token,这里用 js-cookie 的 get 方法替换即可
|
|
||||||
const token = localStorage.getItem("auth_token")
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw UnauthorizedError
|
|
||||||
}
|
|
||||||
return call(
|
|
||||||
endpoint,
|
|
||||||
data ? JSON.stringify(data) : undefined,
|
|
||||||
`Bearer ${token}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { callByDevice, callByUser, callPublic }
|
export { callByDevice, callByUser, callPublic }
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { callPublic } from "@/api/base"
|
import { callPublic } from "./base"
|
||||||
|
|
||||||
// 获取验证码
|
|
||||||
// 假设你的后端接口为 /api/captcha/get,返回 { token: 'abc', base64: 'data:image/png...' }
|
|
||||||
export async function getCaptcha() {
|
export async function getCaptcha() {
|
||||||
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
|
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验验证码
|
|
||||||
// 假设后端接口为 /api/captcha/verify,返回 { success: true/false }
|
|
||||||
export async function verifyCaptcha(token: string, answer: string) {
|
export async function verifyCaptcha(token: string, answer: string) {
|
||||||
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
|
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
|
||||||
}
|
}
|
||||||
64
src/api/order.ts
Normal file
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 type { ApiResponse } from "@/lib/api"
|
||||||
import { callByDevice } from "./base"
|
import { callPublic } from "./base"
|
||||||
|
|
||||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||||
|
|
||||||
export async function sendSMS(
|
export async function sendSMS(
|
||||||
phone: string,
|
phone: string,
|
||||||
purpose: SMSPurpose = "User_Code",
|
purpose: SMSPurpose = "User_Code",
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse<undefined>> {
|
||||||
return await callByDevice("/api/verify/sms", {
|
return await callPublic("/user/SendPhonesCodevefy", {
|
||||||
phone,
|
|
||||||
purpose,
|
|
||||||
key: purpose,
|
key: purpose,
|
||||||
|
phone,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,10 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useState } from "react"
|
||||||
import { Navigate, useLocation } from "react-router-dom"
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
import { getProfile, refreshAuth } from "@/api/auth"
|
import { isAuthed } from "@/api/token"
|
||||||
|
|
||||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [isValid, setIsValid] = useState<boolean | null>(null)
|
const [isValid] = useState(isAuthed)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkAuth = async () => {
|
|
||||||
try {
|
|
||||||
const accessToken = localStorage.getItem("auth_token")
|
|
||||||
const refreshToken = localStorage.getItem("auth_refresh")
|
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
|
||||||
setIsValid(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileResp = await getProfile()
|
|
||||||
if (profileResp.success) {
|
|
||||||
setIsValid(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshToken) {
|
|
||||||
const newTokens = await refreshAuth()
|
|
||||||
if (newTokens) {
|
|
||||||
setIsValid(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.removeItem("auth_token")
|
|
||||||
localStorage.removeItem("auth_refresh")
|
|
||||||
localStorage.removeItem("auth_expires_in")
|
|
||||||
setIsValid(false)
|
|
||||||
} catch {
|
|
||||||
localStorage.removeItem("auth_token")
|
|
||||||
localStorage.removeItem("auth_refresh")
|
|
||||||
localStorage.removeItem("auth_expires_in")
|
|
||||||
setIsValid(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAuth()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (isValid === null)
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-screen">
|
|
||||||
验证身份中...
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
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 { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/index"
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ function RadioGroupItem({
|
|||||||
data-slot="radio-group-item"
|
data-slot="radio-group-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,72 +1,10 @@
|
|||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
import { useRef, useState, useSyncExternalStore } from "react"
|
||||||
import { NavLink, useNavigate } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
import { logout } from "@/api/auth"
|
import ProductDropdown from "@/components/productDropdown"
|
||||||
|
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function ProfileOrLogin() {
|
|
||||||
const [authed, setAuthed] = useState(
|
|
||||||
() => !!localStorage.getItem("auth_token"),
|
|
||||||
)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onFocus = () => setAuthed(!!localStorage.getItem("auth_token"))
|
|
||||||
window.addEventListener("focus", onFocus)
|
|
||||||
return () => window.removeEventListener("focus", onFocus)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authed) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => navigate("/admin")}
|
|
||||||
className="w-24 h-12 flex items-center justify-center lg:text-lg text-slate-700 hover:text-blue-500 transition-colors"
|
|
||||||
>
|
|
||||||
<span>个人中心</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className={[
|
|
||||||
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
|
|
||||||
"transition-colors duration-200 ease-in-out",
|
|
||||||
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span>退出登录</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<NavLink
|
|
||||||
to="/login"
|
|
||||||
state={{ tab: "login" }}
|
|
||||||
className="w-24 h-12 flex items-center justify-center lg:text-lg"
|
|
||||||
>
|
|
||||||
<span>登录</span>
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
to="/login"
|
|
||||||
state={{ tab: "register" }}
|
|
||||||
className={[
|
|
||||||
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
|
|
||||||
"transition-colors duration-200 ease-in-out",
|
|
||||||
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<span>注册</span>
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const scroll = useSyncExternalStore(
|
const scroll = useSyncExternalStore(
|
||||||
callback => {
|
callback => {
|
||||||
@@ -229,108 +167,3 @@ const MenuItem = ({
|
|||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleClick = (path: string) => {
|
|
||||||
navigate(path)
|
|
||||||
onNavigate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<ProductCard
|
|
||||||
title="动静态IP"
|
|
||||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
|
||||||
tag="热门"
|
|
||||||
onClick={() => handleClick("/product?tab=short")}
|
|
||||||
/>
|
|
||||||
<ProductCard
|
|
||||||
title="新HTTP/S5"
|
|
||||||
desc="IP可用时长从数小时到365天全覆盖"
|
|
||||||
tag="稳定"
|
|
||||||
onClick={() => window.open("https://lanhuip.com", "_blank")}
|
|
||||||
/>
|
|
||||||
<ProductCard
|
|
||||||
title="HTTP/S5"
|
|
||||||
desc="海量全球住宅优质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 { useEffect, useState } from "react"
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { login } from "@/api/auth"
|
import { apiLogin, apiRegister } from "@/api/auth"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
@@ -87,15 +87,17 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const resp = await login({
|
const resp = await apiLogin({
|
||||||
username: account,
|
logincode: account,
|
||||||
password,
|
password,
|
||||||
remember,
|
|
||||||
mode: "password",
|
|
||||||
})
|
})
|
||||||
|
console.log(resp, "resp")
|
||||||
|
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
toast.success("登录成功")
|
toast.success("登录成功", {
|
||||||
navigate("/admin")
|
description: "欢迎回来!",
|
||||||
|
})
|
||||||
|
navigate("/")
|
||||||
} else {
|
} else {
|
||||||
toast.error(resp.message || "登录失败")
|
toast.error(resp.message || "登录失败")
|
||||||
}
|
}
|
||||||
@@ -110,12 +112,12 @@ function LoginForm() {
|
|||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
||||||
账号
|
会员号
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="account"
|
id="account"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入淘宝名/会员手机号码"
|
placeholder="请输入会员号"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
value={account}
|
value={account}
|
||||||
onChange={e => setAccount(e.target.value)}
|
onChange={e => setAccount(e.target.value)}
|
||||||
@@ -129,7 +131,7 @@ function LoginForm() {
|
|||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="请输入密码(8-12位数字或字母组合)"
|
placeholder="请输入密码"
|
||||||
className="w-full pr-10"
|
className="w-full pr-10"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
@@ -182,7 +184,6 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||||
const navigate = useNavigate()
|
|
||||||
const [phone, setPhone] = useState("")
|
const [phone, setPhone] = useState("")
|
||||||
const [smsCode, setSmsCode] = useState("")
|
const [smsCode, setSmsCode] = useState("")
|
||||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||||
@@ -237,12 +238,12 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
toast.error("请输入正确的手机号")
|
toast.error("请输入正确的手机号")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (smsCode.length < 4 || smsCode.length > 6) {
|
if (smsCode.length < 3 || smsCode.length > 6) {
|
||||||
toast.error("请输入短信验证码")
|
toast.error("请输入短信验证码")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z0-9]{8,12}$/.test(newPassword)) {
|
if (!newPassword.trim()) {
|
||||||
toast.error("密码为8-12位数字或字母组合")
|
toast.error("请输入密码")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
@@ -252,19 +253,17 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const resp = await login({
|
const resp = await apiRegister({
|
||||||
username: phone,
|
phone,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
code: smsCode,
|
code: smsCode,
|
||||||
remember: false,
|
|
||||||
mode: "phone_code",
|
|
||||||
wx,
|
wx,
|
||||||
qq,
|
qq,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
toast.success("注册成功")
|
toast.success("注册成功,请登录")
|
||||||
navigate("/admin")
|
onSwitchToLogin()
|
||||||
} else {
|
} else {
|
||||||
toast.error(resp.message || "注册失败")
|
toast.error(resp.message || "注册失败")
|
||||||
}
|
}
|
||||||
@@ -334,7 +333,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
<Input
|
<Input
|
||||||
id="reg-password"
|
id="reg-password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="请输入8-12位数字或字母组合"
|
placeholder="请输入密码"
|
||||||
className="w-full pr-10"
|
className="w-full pr-10"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
|||||||
935
src/home/product/buy.tsx
Normal file
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 { useSearchParams } from "react-router-dom"
|
||||||
|
import { fetchProducts } from "@/api/product"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import { PRODUCT_DATA } from "@/lib/models/product"
|
import type { Tab } from "@/lib/models/product"
|
||||||
|
import { transformToTabs } from "@/lib/models/product"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import ProductGrid from "./productGrid"
|
import ProductGrid from "./productGrid"
|
||||||
import ProductSidebar from "./productSidebar"
|
import ProductSidebar from "./productSidebar"
|
||||||
@@ -18,45 +20,93 @@ const TAB_ALIAS: Record<string, string> = {
|
|||||||
|
|
||||||
export default function ProductPage() {
|
export default function ProductPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [activeVersionId, setActiveVersionId] = useState<string>("v1")
|
const [tabs, setTabs] = useState<Tab[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [activeBrandId, setActiveBrandId] = useState("")
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState("")
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts().then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
const newTabs = transformToTabs(result.data)
|
||||||
|
setTabs(newTabs)
|
||||||
|
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
||||||
|
setActiveBrandId(newTabs[0].brands[0].id)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.message)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const urlTab = searchParams.get("tab") || "dynamic"
|
const urlTab = searchParams.get("tab") || "dynamic"
|
||||||
const activeTabId = TAB_ALIAS[urlTab] || urlTab
|
const activeTabId = TAB_ALIAS[urlTab] || urlTab
|
||||||
const activeBrandId = searchParams.get("brand") || ""
|
|
||||||
|
|
||||||
const currentTab =
|
const currentTab = tabs.find(t => t.id === activeTabId) || tabs[0]
|
||||||
PRODUCT_DATA.find(t => t.id === activeTabId) || PRODUCT_DATA[0]
|
|
||||||
const currentBrand =
|
const currentBrand =
|
||||||
currentTab.brands.find(b => b.id === activeBrandId) || currentTab.brands[0]
|
currentTab?.brands.find(b => b.id === activeBrandId) ||
|
||||||
|
currentTab?.brands[0]
|
||||||
|
|
||||||
|
const effectiveVersionId =
|
||||||
|
selectedVersionId || currentBrand?.versions?.[0]?.id || ""
|
||||||
|
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
const targetTab = PRODUCT_DATA.find(t => t.id === tabId)
|
setSearchParams({ tab: tabId })
|
||||||
|
const targetTab = tabs.find(t => t.id === tabId)
|
||||||
const firstBrand = targetTab?.brands[0]
|
const firstBrand = targetTab?.brands[0]
|
||||||
setSearchParams({ tab: tabId, brand: firstBrand?.id || "" })
|
setActiveBrandId(firstBrand?.id || "")
|
||||||
setActiveVersionId("v1")
|
setSelectedVersionId("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBrandSelect = (brandId: string) => {
|
const handleBrandSelect = (brandId: string) => {
|
||||||
setSearchParams({ tab: activeTabId, brand: brandId })
|
setActiveBrandId(brandId)
|
||||||
setActiveVersionId("v1")
|
setSelectedVersionId("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDisplayProducts = () => {
|
const displayProducts = (() => {
|
||||||
if (!currentBrand) return []
|
if (!currentBrand) return []
|
||||||
|
|
||||||
if (currentBrand.versions && currentBrand.versions.length > 0) {
|
if (currentBrand.versions && currentBrand.versions.length > 0) {
|
||||||
return currentBrand.products.filter(p => p.version === activeVersionId)
|
return currentBrand.products.filter(p => p.version === effectiveVersionId)
|
||||||
}
|
}
|
||||||
return currentBrand.products
|
return currentBrand.products
|
||||||
|
})()
|
||||||
|
|
||||||
|
const isPurchaseInfo = currentBrand?.id === "info"
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||||
|
<div className="text-gray-400">加载中...</div>
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||||
|
<div className="text-red-400">加载失败:{error}</div>
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||||
|
<div className="text-gray-400">暂无产品数据</div>
|
||||||
|
</Wrap>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayProducts = getDisplayProducts()
|
|
||||||
const isPurchaseInfo = currentBrand?.id === "info"
|
|
||||||
return (
|
return (
|
||||||
<Wrap className="flex flex-col gap-16 pt-30 bg-white">
|
<Wrap className="flex flex-col gap-16 pt-30 bg-white">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<ProductTabs
|
<ProductTabs
|
||||||
tabs={PRODUCT_DATA}
|
tabs={tabs}
|
||||||
activeId={activeTabId}
|
activeId={activeTabId}
|
||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
/>
|
/>
|
||||||
@@ -73,7 +123,6 @@ export default function ProductPage() {
|
|||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
{!isPurchaseInfo ? (
|
{!isPurchaseInfo ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
|
||||||
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
|
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
@@ -105,10 +154,10 @@ export default function ProductPage() {
|
|||||||
{currentBrand.versions.map(v => (
|
{currentBrand.versions.map(v => (
|
||||||
<button
|
<button
|
||||||
key={v.id}
|
key={v.id}
|
||||||
onClick={() => setActiveVersionId(v.id)}
|
onClick={() => setSelectedVersionId(v.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||||
activeVersionId === v.id
|
effectiveVersionId === v.id
|
||||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { Product } from "@/lib/models/product"
|
import type { Product } from "@/lib/models/product"
|
||||||
import vector from "../_assets/vector.webp"
|
import vector from "../_assets/vector.webp"
|
||||||
@@ -7,6 +8,8 @@ type ProductGridProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductGrid({ products }: ProductGridProps) {
|
export default function ProductGrid({ products }: ProductGridProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||||
{products.map(product => (
|
{products.map(product => (
|
||||||
@@ -72,6 +75,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||||
|
onClick={() => navigate("/product/buy", { state: { product } })}
|
||||||
>
|
>
|
||||||
立即购买
|
立即购买
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// 定义后端服务URL和OAuth2配置
|
// 定义后端服务URL和OAuth2配置
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""
|
||||||
if (!API_BASE_URL) throw new Error("VITE_API_BASE_URL is not set")
|
|
||||||
|
|
||||||
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
|
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
|
||||||
if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set")
|
if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set")
|
||||||
@@ -12,7 +11,7 @@ if (!CLIENT_SECRET) throw new Error("VITE_CLIENT_SECRET is not set")
|
|||||||
type ApiResponse<T = undefined> =
|
type ApiResponse<T = undefined> =
|
||||||
| {
|
| {
|
||||||
success: false
|
success: false
|
||||||
status: number
|
status?: number
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -20,32 +19,4 @@ type ApiResponse<T = undefined> =
|
|||||||
data: T
|
data: T
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageRecord<T = unknown> = {
|
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
size: number
|
|
||||||
list: T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具类型:用于提取请求参数和返回数据
|
|
||||||
type ExtraReq<T extends (...args: never) => unknown> = T extends (
|
|
||||||
...args: infer P
|
|
||||||
) => unknown
|
|
||||||
? P[0]
|
|
||||||
: never
|
|
||||||
type ExtraResp<T extends (...args: never) => unknown> =
|
|
||||||
Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
|
|
||||||
|
|
||||||
// 预定义错误
|
|
||||||
const UnauthorizedError = new Error("未授权访问")
|
|
||||||
|
|
||||||
export {
|
|
||||||
API_BASE_URL,
|
|
||||||
type ApiResponse,
|
|
||||||
CLIENT_ID,
|
|
||||||
CLIENT_SECRET,
|
|
||||||
type ExtraReq,
|
|
||||||
type ExtraResp,
|
|
||||||
type PageRecord,
|
|
||||||
UnauthorizedError,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type Version = {
|
|||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: string
|
id: string
|
||||||
|
productId: number
|
||||||
title: string
|
title: string
|
||||||
price: number
|
price: number
|
||||||
desc?: string
|
desc?: string
|
||||||
@@ -14,6 +15,7 @@ export type Product = {
|
|||||||
duration?: string
|
duration?: string
|
||||||
version?: string
|
version?: string
|
||||||
features?: string[]
|
features?: string[]
|
||||||
|
isTest?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Brand = {
|
export type Brand = {
|
||||||
@@ -29,267 +31,109 @@ export type Tab = {
|
|||||||
brands: Brand[]
|
brands: Brand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PRODUCT_DATA: Tab[] = [
|
export type ProductItem = {
|
||||||
{
|
Product: {
|
||||||
id: "dynamic",
|
Id: number
|
||||||
label: "动态独享IP",
|
Name: string
|
||||||
brands: [
|
Image: string
|
||||||
{
|
Sort: number
|
||||||
id: "fox",
|
Content: string
|
||||||
name: "极狐IP(推荐)",
|
Profile: string
|
||||||
versions: [
|
TenantId: number
|
||||||
{ id: "v1", label: "尊享版-不限速" },
|
OnLine: number
|
||||||
{ id: "v2", label: "高级版-限速6M" },
|
ContentLine: string[]
|
||||||
{ id: "v3", label: "普通版-限速2M" },
|
}
|
||||||
],
|
Packages: PackageItem[]
|
||||||
products: [
|
}
|
||||||
{
|
|
||||||
id: "1",
|
export type PackageItem = {
|
||||||
title: "免费",
|
Id: number
|
||||||
price: 0,
|
TenantId: number
|
||||||
desc: "注册即送3次",
|
ProductId: number
|
||||||
card: "测试卡",
|
PackageType: number
|
||||||
duration: "1小时",
|
Name: string
|
||||||
version: "v1",
|
Title: string
|
||||||
},
|
Image: string
|
||||||
{
|
Status: number
|
||||||
id: "2",
|
Profile: string
|
||||||
title: "首单低至",
|
Price: number
|
||||||
price: 0.1,
|
LinePrice: number
|
||||||
originalPrice: 0.1,
|
DayPrice: number
|
||||||
// card: "测试卡",
|
MinPrice: number
|
||||||
duration: "24小时",
|
DayCount: number
|
||||||
version: "v1",
|
OriginKey: string
|
||||||
},
|
OriginName: string
|
||||||
{
|
IsTest: number
|
||||||
id: "3",
|
DeleteTag: number
|
||||||
title: "周卡套餐",
|
}
|
||||||
price: 37.5,
|
|
||||||
tag: "8.5折",
|
function extractTier(name: string): string {
|
||||||
originalPrice: 37.5,
|
const match = name.match(/\((.+?)\)$/)
|
||||||
// card: "周卡",
|
return match ? match[1] : "默认"
|
||||||
duration: "7天",
|
}
|
||||||
version: "v1",
|
|
||||||
},
|
export function transformToTabs(data: ProductItem[]): Tab[] {
|
||||||
{
|
const activeProducts = data.filter(p => p.Product.OnLine === 1)
|
||||||
id: "4",
|
|
||||||
title: "月卡套餐",
|
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
||||||
price: 78,
|
console.log(
|
||||||
tag: "8.5折",
|
activeProducts,
|
||||||
originalPrice: 80.0,
|
"activeProductsactiveProductsactiveProductsactiveProducts",
|
||||||
// card: "月卡",
|
)
|
||||||
duration: "30天",
|
|
||||||
version: "v2",
|
const brands: Brand[] = activeProducts.map(item => {
|
||||||
},
|
const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
|
||||||
{
|
|
||||||
id: "5",
|
const tierMap = new Map<string, PackageItem[]>()
|
||||||
title: "季卡套餐",
|
for (const pkg of activePackages) {
|
||||||
price: 208,
|
const tier = extractTier(pkg.Name)
|
||||||
tag: "8.5折",
|
if (!tierMap.has(tier)) tierMap.set(tier, [])
|
||||||
originalPrice: 220.0,
|
tierMap.get(tier)?.push(pkg)
|
||||||
// card: "季卡",
|
}
|
||||||
duration: "90天",
|
|
||||||
version: "v2",
|
const tiers = Array.from(tierMap.entries())
|
||||||
},
|
|
||||||
{
|
const versions: Version[] | undefined =
|
||||||
id: "6",
|
tiers.length > 1
|
||||||
title: "年卡套餐",
|
? tiers.map(([tier]) => ({ id: tier, label: tier }))
|
||||||
price: 770,
|
: undefined
|
||||||
tag: "8.5折",
|
|
||||||
originalPrice: 800.0,
|
const products: Product[] = []
|
||||||
card: "年卡",
|
for (const [tier, packages] of tiers) {
|
||||||
duration: "365天",
|
for (const pkg of packages) {
|
||||||
version: "v3",
|
products.push({
|
||||||
},
|
id: pkg.Id.toString(),
|
||||||
],
|
productId: item.Product.Id,
|
||||||
},
|
title: pkg.Name,
|
||||||
{
|
price: pkg.Price,
|
||||||
id: "aurora",
|
originalPrice: pkg.LinePrice,
|
||||||
name: "极光IP(推荐)",
|
duration: pkg.Profile,
|
||||||
versions: [
|
card: pkg.OriginName,
|
||||||
{ id: "v1", label: "尊享版-不限速" },
|
version: tier,
|
||||||
{ id: "v2", label: "高级版-限速6M" },
|
isTest: pkg.IsTest === 1,
|
||||||
{ id: "v3", label: "普通版-限速2M" },
|
})
|
||||||
],
|
}
|
||||||
products: [
|
}
|
||||||
{
|
|
||||||
id: "7",
|
return {
|
||||||
title: "周卡套餐",
|
id: item.Product.Name,
|
||||||
price: 37.5,
|
name: item.Product.Name,
|
||||||
originalPrice: 37.5,
|
versions,
|
||||||
card: "周卡",
|
products,
|
||||||
duration: "7天",
|
}
|
||||||
version: "v1",
|
})
|
||||||
},
|
|
||||||
{
|
brands.push({
|
||||||
id: "8",
|
id: "info",
|
||||||
title: "月卡套餐",
|
name: "购买须知",
|
||||||
price: 78,
|
products: [],
|
||||||
tag: "8.5折",
|
})
|
||||||
originalPrice: 80.0,
|
|
||||||
card: "月卡",
|
return [
|
||||||
duration: "30天",
|
{
|
||||||
version: "v2",
|
id: "dynamic",
|
||||||
},
|
label: "动态独享IP",
|
||||||
{
|
brands,
|
||||||
id: "9",
|
},
|
||||||
title: "季卡套餐",
|
]
|
||||||
price: 208,
|
}
|
||||||
tag: "8.5折",
|
|
||||||
originalPrice: 220.0,
|
|
||||||
card: "季卡",
|
|
||||||
duration: "90天",
|
|
||||||
version: "v3",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mushroom",
|
|
||||||
name: "蘑菇IP",
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: "10",
|
|
||||||
title: "免费",
|
|
||||||
price: 0,
|
|
||||||
desc: "注册即送3次",
|
|
||||||
card: "测试卡",
|
|
||||||
duration: "1小时",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11",
|
|
||||||
title: "首单低至",
|
|
||||||
price: 0.1,
|
|
||||||
originalPrice: 0.1,
|
|
||||||
// card: "测试卡",
|
|
||||||
duration: "24小时",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "12",
|
|
||||||
title: "周卡套餐",
|
|
||||||
price: 37.5,
|
|
||||||
originalPrice: 37.5,
|
|
||||||
// card: "周卡",
|
|
||||||
duration: "7天",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "kylin",
|
|
||||||
name: "麒麟IP",
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: "13",
|
|
||||||
title: "测试卡",
|
|
||||||
price: 0.1,
|
|
||||||
card: "测试卡",
|
|
||||||
duration: "1小时",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "14",
|
|
||||||
title: "24小时套餐",
|
|
||||||
price: 37.5,
|
|
||||||
originalPrice: 37.5,
|
|
||||||
card: "24小时卡",
|
|
||||||
duration: "24小时",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "15",
|
|
||||||
title: "周卡套餐",
|
|
||||||
price: 37.5,
|
|
||||||
originalPrice: 37.5,
|
|
||||||
card: "周卡",
|
|
||||||
duration: "7天",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cheetah",
|
|
||||||
name: "猎豹IP",
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: "16",
|
|
||||||
title: "月卡套餐",
|
|
||||||
price: 78,
|
|
||||||
tag: "8.5折",
|
|
||||||
originalPrice: 80.0,
|
|
||||||
card: "月卡",
|
|
||||||
duration: "30天",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "17",
|
|
||||||
title: "季卡套餐",
|
|
||||||
price: 208,
|
|
||||||
tag: "8.5折",
|
|
||||||
originalPrice: 220.0,
|
|
||||||
card: "季卡",
|
|
||||||
duration: "90天",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "18",
|
|
||||||
title: "年卡套餐",
|
|
||||||
price: 770,
|
|
||||||
tag: "8.5折",
|
|
||||||
originalPrice: 800.0,
|
|
||||||
card: "年卡",
|
|
||||||
duration: "365天",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "info",
|
|
||||||
name: "购买说明",
|
|
||||||
products: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "static",
|
|
||||||
label: "静态IP",
|
|
||||||
brands: [
|
|
||||||
{
|
|
||||||
id: "drip",
|
|
||||||
name: "水滴独享IP",
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: "19",
|
|
||||||
title: "纯住宅池",
|
|
||||||
price: 50,
|
|
||||||
desc: "分散式真实住宅IP",
|
|
||||||
// card: "住宅卡",
|
|
||||||
features: [
|
|
||||||
"带宽充足,IP段优质分布广",
|
|
||||||
"适合对IP要求高的业务",
|
|
||||||
"IP 3-7天周期性变化",
|
|
||||||
"线路切换每天20次",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "20",
|
|
||||||
title: "多拨池",
|
|
||||||
price: 60,
|
|
||||||
desc: "集中拨号住宅,带宽较高",
|
|
||||||
// card: "拨号卡",
|
|
||||||
features: [
|
|
||||||
"IP段较多,稳定性较高",
|
|
||||||
"IP 3-7天周期性变化",
|
|
||||||
"线路切换每天20次",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "21",
|
|
||||||
title: "固定池",
|
|
||||||
price: 70,
|
|
||||||
desc: "天翼云节点",
|
|
||||||
// card: "固定卡",
|
|
||||||
features: ["IP段多IP不变化,稳定性高", "线路切换每日限制为20次"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "single",
|
|
||||||
label: "单窗口单IP",
|
|
||||||
brands: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import IpLinePage from "@/home/ipLine"
|
|||||||
import LayoutPage from "@/home/layout"
|
import LayoutPage from "@/home/layout"
|
||||||
import LoginPage from "@/home/login"
|
import LoginPage from "@/home/login"
|
||||||
import ProductPage from "@/home/product"
|
import ProductPage from "@/home/product"
|
||||||
|
import BuyPage from "@/home/product/buy"
|
||||||
import HttpPage from "@/home/product/http"
|
import HttpPage from "@/home/product/http"
|
||||||
import RouterosPage from "@/home/product/routeros"
|
import RouterosPage from "@/home/product/routeros"
|
||||||
import SoftDownloadPage from "@/home/softDownload"
|
import SoftDownloadPage from "@/home/softDownload"
|
||||||
@@ -24,6 +25,7 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <HomePage /> },
|
{ index: true, element: <HomePage /> },
|
||||||
{ path: "product", element: <ProductPage /> },
|
{ path: "product", element: <ProductPage /> },
|
||||||
|
{ path: "product/buy", element: <BuyPage /> },
|
||||||
{ path: "product/http", element: <HttpPage /> },
|
{ path: "product/http", element: <HttpPage /> },
|
||||||
{ path: "product/routeros", element: <RouterosPage /> },
|
{ path: "product/routeros", element: <RouterosPage /> },
|
||||||
{ path: "ipline", element: <IpLinePage /> },
|
{ path: "ipline", element: <IpLinePage /> },
|
||||||
|
|||||||
@@ -11,4 +11,18 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://192.168.3.6:5000",
|
||||||
|
"/user": "http://192.168.3.6:5000",
|
||||||
|
"/product": {
|
||||||
|
target: "http://192.168.3.6:5000",
|
||||||
|
bypass(req) {
|
||||||
|
if (req.headers?.accept?.includes("text/html")) {
|
||||||
|
return "/index.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user