Compare commits
7 Commits
4c9953ab57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f48004e1 | ||
|
|
0a5e75c40c | ||
|
|
623e3d527f | ||
|
|
fc034dc971 | ||
|
|
1336a550fe | ||
|
|
4235458bea | ||
|
|
115ee3bfeb |
3
.env
3
.env
@@ -1,3 +1,4 @@
|
||||
VITE_API_BASE_URL=http://192.168.3.42:8080
|
||||
VITE_API_BASE_URL=
|
||||
VITE_PHP_API_BASE_URL=https://php-api.juip.com
|
||||
VITE_CLIENT_ID=web
|
||||
VITE_CLIENT_SECRET=web
|
||||
158
.github/copilot-instructions.md
vendored
Normal file
158
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,158 @@
|
||||
# juip-web - AI 开发助手指南
|
||||
|
||||
## 项目概述
|
||||
|
||||
Vite + React 19 纯 SPA 代理服务站点,包含公开营销页与用户管理后台。使用 Bun 运行时、react-router-dom 7 路由、Tailwind CSS 4、shadcn/ui (radix-nova 风格)、sonner 提示、biome 代码规范。
|
||||
|
||||
**核心业务**: IP 代理产品展示与购买、订单支付、短信验证码注册登录、后台资源/通道/白名单管理
|
||||
|
||||
## 架构模式
|
||||
|
||||
### 三层数据流架构
|
||||
|
||||
1. **API 层** (`src/api/*.ts`) - 所有后端调用统一入口
|
||||
- 每个文件对应一个业务域 (auth, order, product, verify, captcha)
|
||||
- 通过 `base.ts` 的三种调用模式访问后端:
|
||||
- `callPublic()` - 无需认证的公开接口
|
||||
- `callByDevice()` - 使用 CLIENT_ID/CLIENT_SECRET 的设备级认证
|
||||
- `callByUser()` - 使用用户会话 token 的用户级认证
|
||||
- 所有调用返回统一的 `ApiResponse<T>` 类型
|
||||
- 后端存在两套响应约定,`base.ts` 自动识别:
|
||||
- 裸 JSON (OAuth2 接口、`/script/linedata/*.php` 等) - 原样返回
|
||||
- 会员接口信封 `{Code, Message, Data}` - 顶层有 `Code` 字段时自动解包: `Code === 10000` 成功返回 `Data`/`data`,否则转 `success: false` + `Message`
|
||||
|
||||
2. **会话状态** (`src/api/token.ts`) - 令牌存取唯一入口
|
||||
- 登录后 token/userInfo 由后端 Set-Cookie 下发,经 `apiLogin` 镜像到 localStorage
|
||||
- `setSession()` / `clearSession()` 统一派发 `auth-change` 事件驱动 UI 重渲染
|
||||
- UI 组件 (header/authGuard) 通过 `isAuthed()` 读取登录态,禁止直接读 localStorage
|
||||
- 401 时 user 模式自动 `clearSession()`,device 模式自动清缓存重取设备令牌
|
||||
|
||||
3. **UI 层** - 业务域 API 函数供组件直接调用
|
||||
- 公开页组件位于 `src/home/*`,后台位于 `src/admin/*`
|
||||
- `/admin` 路由由 `AuthGuard` 保护,未登录重定向 `/login?redirect=`
|
||||
|
||||
### API 层使用示例
|
||||
|
||||
```typescript
|
||||
// 业务域文件: src/api/xxx.ts
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByUser, callPublic } from "./base"
|
||||
|
||||
// 1. 公开接口 (GET 自动并发去重)
|
||||
const resp = await callPublic<{ token: string; base64: string }>(
|
||||
"/api/captcha/get",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
|
||||
// 2. 设备级认证 (client_credentials 令牌模块内单飞缓存,提前 60s 过期)
|
||||
const resp2 = await callByDevice<{ access_token: string }>(
|
||||
"/api/auth/token",
|
||||
{ grant_type: "client_credentials" },
|
||||
)
|
||||
|
||||
// 3. 用户级认证 (读取会话 token,401 自动清会话)
|
||||
const resp3 = await callByUser<CreateOrderData>("/product/CreateOrder", data)
|
||||
const resp4 = await callByUser<number>(
|
||||
`/product/IsPay?orderNo=${orderNo}`,
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
|
||||
// 4. Code 信封接口 (顶层有 Code 字段自动解包,Code === 10000 成功)
|
||||
const resp5 = await callPublic("/user/ApiLogin", {
|
||||
Logincode: "xxx",
|
||||
Password: "xxx",
|
||||
})
|
||||
const resp6 = await callPublic<ProductItem[]>(
|
||||
"/product/ApiProductActive",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
|
||||
// 5. 统一响应处理
|
||||
if (resp.success) {
|
||||
console.log(resp.data) // 强类型 T
|
||||
} else {
|
||||
console.error(resp.status, resp.message)
|
||||
}
|
||||
```
|
||||
|
||||
### 组件调用示例
|
||||
|
||||
```typescript
|
||||
import { fetchProducts } from "@/api/product"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const result = await fetchProducts()
|
||||
if (result.success) {
|
||||
setProducts(result.data)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
```
|
||||
|
||||
## 路由组织
|
||||
|
||||
- `/` - 公开站点 (LayoutPage 统一头部/底部): 首页、`/product`(+`/buy`/`/http`/`/routeros`)、`/ipline`、`/softdownload`、`/help`、`/client`
|
||||
- `/login` - 登录/注册 (tab 状态经 `location.state.tab` 切换)
|
||||
- `/admin` - 用户后台 (AuthGuard 保护): dashboard、channels、whitelist、funds、product/long、product/short
|
||||
|
||||
路由定义集中在 `src/routers/index.tsx`。
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 命令速查
|
||||
|
||||
```bash
|
||||
bun install # 安装依赖
|
||||
bun dev # 开发服务器 (vite --host)
|
||||
bun run build # 生产构建 (tsc -b && vite build)
|
||||
bun run lint # biome 检查并自动修复
|
||||
```
|
||||
|
||||
### 代码规范 (biome)
|
||||
|
||||
- 无分号、双引号、2 空格缩进、单参数箭头函数省略括号
|
||||
- import 自动排序 (organizeImports)
|
||||
- `noNonNullAssertion` 为 error,禁用 `!` 断言
|
||||
- 提交前运行 `bun run lint`
|
||||
|
||||
### 组件开发模式
|
||||
|
||||
**shadcn/ui 组件** (`src/components/ui/*.tsx`)
|
||||
- 通过 CLI 添加: `bunx shadcn@latest add <component>`
|
||||
- 类名合并使用 `cn()` (来自 `@/lib/utils`)
|
||||
|
||||
**业务组件**
|
||||
- `src/components/composites/*` - 复杂对话框等
|
||||
- `src/components/*` - 通用组件 (wrap 布局容器、authGuard 路由守卫、data-table、page)
|
||||
|
||||
**表单**: react-hook-form + zod 校验
|
||||
|
||||
**提示**: 统一使用 sonner (`toast.success / toast.error`)
|
||||
|
||||
## 项目特定约定
|
||||
|
||||
### 类型定义
|
||||
|
||||
- 后端响应统一为 `ApiResponse<T>` (定义于 `src/lib/api.ts`):
|
||||
- 成功: `{success: true, data: T}`
|
||||
- 失败: `{success: false, status?: number, message: string}`
|
||||
- 请求/响应业务类型定义在对应业务域 API 文件中 (如 `src/api/order.ts` 的 `CreateOrderRequest`)
|
||||
- UI 展示模型位于 `src/lib/models/*`
|
||||
|
||||
### 环境变量 (见 `.env`)
|
||||
|
||||
- `VITE_API_BASE_URL` - 后端地址 (留空表示同源,登录 Cookie 自动携带)
|
||||
- `VITE_CLIENT_ID` / `VITE_CLIENT_SECRET` - OAuth2 设备认证凭据 (当前均为 `web`)
|
||||
|
||||
## 关键文件参考
|
||||
|
||||
- [src/api/base.ts](src/api/base.ts) - API 调用核心 (三模式 + Code 信封适配 + GET 去重 + 设备令牌缓存)
|
||||
- [src/api/token.ts](src/api/token.ts) - 会话状态存取与 auth-change 事件
|
||||
- [src/api/auth.ts](src/api/auth.ts) - 登录/注册/退出业务域
|
||||
- [src/lib/api.ts](src/lib/api.ts) - ApiResponse 类型与后端配置
|
||||
- [src/routers/index.tsx](src/routers/index.tsx) - 全部路由定义
|
||||
- [src/components/authGuard.tsx](src/components/authGuard.tsx) - 后台路由守卫
|
||||
- [src/home/header.tsx](src/home/header.tsx) - 登录态响应式更新 (auth-change/focus 事件)
|
||||
3
bun.lock
3
bun.lock
@@ -12,6 +12,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.5.0",
|
||||
@@ -547,6 +548,8 @@
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"date-fns": ["date-fns@4.4.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.5.0",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { RouterProvider } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { router } from "./routers"
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-center" richColors expand />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -100,22 +101,12 @@ export function Header() {
|
||||
<AdminNavMenu />
|
||||
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||
{/* <Link
|
||||
to="/"
|
||||
className="flex-none h-16 flex items-center justify-center text-sm"
|
||||
>
|
||||
返回首页
|
||||
</Link> */}
|
||||
<UserCenter />
|
||||
<ProfileOrLogin />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function UserCenter() {
|
||||
return <div className="flex items-center gap-2">用户</div>
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
const { navbar } = useLayout()
|
||||
|
||||
@@ -165,13 +156,13 @@ export function Navbar() {
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/orders"
|
||||
icon={<ShoppingBag size={20} />}
|
||||
label="订单管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/refundOrders"
|
||||
icon={<ClipboardList size={20} />}
|
||||
label="退货管理"
|
||||
expand={navbar}
|
||||
@@ -189,31 +180,31 @@ export function Navbar() {
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/record"
|
||||
href="/admin/httpRecharge"
|
||||
icon={<Archive size={20} />}
|
||||
label="充值记录"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/resources_short"
|
||||
href="/admin/resources/short"
|
||||
icon={<Clock1 size={20} />}
|
||||
label="短效套餐"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/resources_long"
|
||||
href="/admin/resources/long"
|
||||
icon={<CalendarClock size={20} />}
|
||||
label="长效管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/rosorder"
|
||||
icon={<Router size={20} />}
|
||||
label="软路由"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/record"
|
||||
icon={<ClipboardClock size={20} />}
|
||||
label="使用记录"
|
||||
expand={navbar}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog"
|
||||
import { EditInfoDialog, type EditInfoValues } from "@/components/composites/dialogs/editInfoDialog"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -90,7 +90,7 @@ export default function DashboardPage() {
|
||||
yearConsumption: 2365,
|
||||
}
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const handleSaveInfo = (data: any) => {
|
||||
const handleSaveInfo = (data: EditInfoValues) => {
|
||||
console.log("保存的信息:", data)
|
||||
// 这里调 API 保存数据
|
||||
}
|
||||
@@ -300,11 +300,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* 账号信息 */}
|
||||
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col">
|
||||
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col min-h-0">
|
||||
<h3 className="font-semibold text-lg shrink-0">账号信息</h3>
|
||||
<div className="mt-4 flex justify-between items-start gap-3 flex-auto">
|
||||
<div className="flex flex-col items-center gap-4 shrink-0">
|
||||
<span className="text-base font-medium">{accountInfo.phone}</span>
|
||||
<div className="mt-4 flex flex-col sm:flex-row justify-between items-start gap-4 flex-1 min-h-0">
|
||||
{/* 头像/二维码区域 */}
|
||||
<div className="flex flex-col items-center gap-3 shrink-0 w-full sm:w-auto">
|
||||
<span className="text-base font-medium truncate w-full text-center sm:text-left">
|
||||
{accountInfo.phone}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -312,49 +315,51 @@ export default function DashboardPage() {
|
||||
>
|
||||
去实名
|
||||
</Button>
|
||||
<div className="w-20 h-20 border border-gray-200 rounded">
|
||||
<div className="w-20 h-20 border border-gray-200 rounded shrink-0 overflow-hidden">
|
||||
<img
|
||||
src="/qrcode-placeholder.png"
|
||||
alt="账号二维码"
|
||||
className="w-full h-full object-contain"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-sm flex-1">
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||
<span className="text-gray-400 w-20 shrink-0">密码</span>
|
||||
<span className="text-gray-700 font-mono tracking-wide">
|
||||
{/* 信息列表 */}
|
||||
<div className="flex flex-col gap-2 text-sm flex-1 min-w-0 w-full">
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||
<span className="text-gray-400 w-14 shrink-0">密码</span>
|
||||
<span className="text-gray-700 font-mono tracking-wide truncate">
|
||||
********
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||
<span className="text-gray-400 w-20 shrink-0">QQ</span>
|
||||
<span className="text-gray-700">
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||
<span className="text-gray-400 w-14 shrink-0">QQ</span>
|
||||
<span className="text-gray-700 truncate">
|
||||
{accountInfo.qq || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||
<span className="text-gray-400 w-20 shrink-0">微信号</span>
|
||||
<span className="text-gray-700">
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||
<span className="text-gray-400 w-14 shrink-0">微信号</span>
|
||||
<span className="text-gray-700 truncate">
|
||||
{accountInfo.wechat || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||
<span className="text-gray-400 w-20 shrink-0">淘宝会员</span>
|
||||
<span className="text-gray-700">
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||
<span className="text-gray-400 w-14 shrink-0">淘宝</span>
|
||||
<span className="text-gray-700 truncate">
|
||||
{accountInfo.taobao || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||
<span className="text-gray-400 w-20 shrink-0">邮箱</span>
|
||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||
<span className="text-gray-400 w-14 shrink-0">邮箱</span>
|
||||
<span className="text-gray-700 truncate">
|
||||
{accountInfo.email || "未绑定"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end shrink-0">
|
||||
|
||||
<div className="mt-4 flex justify-end shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
|
||||
export default function FundsPage() {
|
||||
return <Page>资金明细</Page>
|
||||
return (
|
||||
<Page>
|
||||
<DataTable
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{ accessorKey: "date", header: "交易时间" },
|
||||
{ accessorKey: "product", header: "用户" },
|
||||
{ accessorKey: "plan", header: "资金去向" },
|
||||
{
|
||||
accessorKey: "price",
|
||||
header: "金额",
|
||||
},
|
||||
{ accessorKey: "connections", header: "操作前约(元)" },
|
||||
{ accessorKey: "account", header: "备注" },
|
||||
]}
|
||||
pagination={{
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: 0,
|
||||
}}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/admin/httpRecharge/page.tsx
Normal file
32
src/admin/httpRecharge/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
|
||||
export default function HttpRechargePage() {
|
||||
return (
|
||||
<Page>
|
||||
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||
<DataTable
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "交易时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "ju_money",
|
||||
header: "充值H币",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_type",
|
||||
header: "充值渠道",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_money",
|
||||
header: "充值金额",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import ProductDropdown from "@/components/productDropdown"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AdminNavMenu() {
|
||||
@@ -8,7 +9,7 @@ export function AdminNavMenu() {
|
||||
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
||||
<AdminNavItem to="/">首页</AdminNavItem>
|
||||
<AdminMenuItem text="产品购买">
|
||||
<ProductDropdown />
|
||||
<ProductDropdown size="sm" />
|
||||
</AdminMenuItem>
|
||||
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
||||
<AdminNavItem to="/softdownload">软件下载</AdminNavItem>
|
||||
@@ -116,7 +117,7 @@ function AdminMenuItem({
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"fixed transition-all duration-200 z-[9999]",
|
||||
"fixed transition-all duration-200 z-9999",
|
||||
isOpen
|
||||
? "opacity-100 visible translate-y-0"
|
||||
: "opacity-0 invisible -translate-y-2 pointer-events-none",
|
||||
@@ -129,7 +130,7 @@ function AdminMenuItem({
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-4 w-[560px]">
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-4 w-140">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
@@ -138,115 +139,3 @@ function AdminMenuItem({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductDropdown() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProductCard
|
||||
title="动静态IP"
|
||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||
tag="热门"
|
||||
onClick={() => handleClick("/product?tab=short")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="新HTTP/S5"
|
||||
desc="IP可用时长从数小时到365天全覆盖"
|
||||
tag="稳定"
|
||||
onClick={() => handleClick("/product?tab=long")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="HTTP/S5"
|
||||
desc="海量全球住宅优质IP,4G/5G真实手机IP"
|
||||
tag="全球"
|
||||
onClick={() => handleClick("/product?tab=global")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="软路由成本价"
|
||||
desc="云端自动切换IP,更易于调用与开发"
|
||||
onClick={() => handleClick("/product?tab=tunnel")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="动态Vps"
|
||||
desc="IP独享,高带宽,灵活控制存活周期"
|
||||
onClick={() => handleClick("/product?tab=exclusive")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="静态独享IP"
|
||||
desc="纯净IP,更符合跨境卖家需求的云主机"
|
||||
onClick={() => handleClick("/product?tab=static")}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
401
src/admin/orders/mock.ts
Normal file
401
src/admin/orders/mock.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type { OrderItem } from "@/lib/models/order"
|
||||
|
||||
export const MOCK_ORDERS: OrderItem[] = [
|
||||
{
|
||||
Id: 23,
|
||||
OrderNo: "JU202508150001",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1234",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-15 10:23:45",
|
||||
UpdateTime: "2025-08-15 10:24:01",
|
||||
},
|
||||
{
|
||||
Id: 22,
|
||||
OrderNo: "JU202508150002",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1235",
|
||||
OrderAmount: 18,
|
||||
CouponAmount: 2,
|
||||
PaymentAmount: 16,
|
||||
CreateTime: "2025-08-15 09:12:33",
|
||||
UpdateTime: "2025-08-15 09:12:50",
|
||||
},
|
||||
{
|
||||
Id: 21,
|
||||
OrderNo: "JU202508140003",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1236",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-08-14 16:45:21",
|
||||
UpdateTime: "2025-08-14 16:45:39",
|
||||
},
|
||||
{
|
||||
Id: 20,
|
||||
OrderNo: "JU202508140004",
|
||||
OrderType: 3,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 5,
|
||||
AccountCount: 3,
|
||||
Accounts: "https-001,https-002,https-003",
|
||||
OrderAmount: 264,
|
||||
CouponAmount: 10,
|
||||
PaymentAmount: 254,
|
||||
CreateTime: "2025-08-14 14:02:17",
|
||||
UpdateTime: "2025-08-14 14:02:55",
|
||||
},
|
||||
{
|
||||
Id: 19,
|
||||
OrderNo: "JU202508140005",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-001",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-14 11:30:08",
|
||||
UpdateTime: "2025-08-14 11:30:26",
|
||||
},
|
||||
{
|
||||
Id: 18,
|
||||
OrderNo: "JU202508130006",
|
||||
OrderType: 4,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 10,
|
||||
AccountCount: 5,
|
||||
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
|
||||
OrderAmount: 3440,
|
||||
CouponAmount: 100,
|
||||
PaymentAmount: 3340,
|
||||
CreateTime: "2025-08-13 20:15:44",
|
||||
UpdateTime: "2025-08-13 20:16:02",
|
||||
},
|
||||
{
|
||||
Id: 17,
|
||||
OrderNo: "JU202508130007",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 0.5,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1237",
|
||||
OrderAmount: 0.5,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 0.5,
|
||||
CreateTime: "2025-08-13 18:03:12",
|
||||
UpdateTime: "2025-08-13 18:03:28",
|
||||
},
|
||||
{
|
||||
Id: 16,
|
||||
OrderNo: "JU202508120008",
|
||||
OrderType: 2,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 128,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-004",
|
||||
OrderAmount: 128,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 128,
|
||||
CreateTime: "2025-08-12 15:22:37",
|
||||
UpdateTime: "2025-08-12 15:22:59",
|
||||
},
|
||||
{
|
||||
Id: 15,
|
||||
OrderNo: "JU202508120009",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-002",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 5,
|
||||
PaymentAmount: 93,
|
||||
CreateTime: "2025-08-12 13:40:55",
|
||||
UpdateTime: "2025-08-12 13:41:13",
|
||||
},
|
||||
{
|
||||
Id: 14,
|
||||
OrderNo: "JU202508110010",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1238",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-11 10:05:29",
|
||||
UpdateTime: "2025-08-11 10:05:44",
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
OrderNo: "JU202508110011",
|
||||
OrderType: 3,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 4,
|
||||
AccountCount: 4,
|
||||
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
|
||||
OrderAmount: 72,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 72,
|
||||
CreateTime: "2025-08-11 08:56:48",
|
||||
UpdateTime: "2025-08-11 08:57:06",
|
||||
},
|
||||
{
|
||||
Id: 12,
|
||||
OrderNo: "JU202508100012",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1239",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-10 19:33:20",
|
||||
UpdateTime: "2025-08-10 19:33:41",
|
||||
},
|
||||
{
|
||||
Id: 11,
|
||||
OrderNo: "JU202508090013",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 880,
|
||||
ConnectCount: 20,
|
||||
AccountCount: 2,
|
||||
Accounts: "https-005,https-006",
|
||||
OrderAmount: 1760,
|
||||
CouponAmount: 50,
|
||||
PaymentAmount: 1710,
|
||||
CreateTime: "2025-08-09 21:18:07",
|
||||
UpdateTime: "2025-08-09 21:18:30",
|
||||
},
|
||||
{
|
||||
Id: 10,
|
||||
OrderNo: "JU202508090014",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 1,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-003",
|
||||
OrderAmount: 1,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 1,
|
||||
CreateTime: "2025-08-09 17:47:52",
|
||||
UpdateTime: "2025-08-09 17:48:09",
|
||||
},
|
||||
{
|
||||
Id: 9,
|
||||
OrderNo: "JU202508080015",
|
||||
OrderType: 4,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 6,
|
||||
AccountCount: 6,
|
||||
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
|
||||
OrderAmount: 528,
|
||||
CouponAmount: 20,
|
||||
PaymentAmount: 508,
|
||||
CreateTime: "2025-08-08 12:26:34",
|
||||
UpdateTime: "2025-08-08 12:26:58",
|
||||
},
|
||||
{
|
||||
Id: 8,
|
||||
OrderNo: "JU202508070016",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 58,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1240",
|
||||
OrderAmount: 58,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 58,
|
||||
CreateTime: "2025-08-07 09:11:26",
|
||||
UpdateTime: "2025-08-07 09:11:43",
|
||||
},
|
||||
{
|
||||
Id: 7,
|
||||
OrderNo: "JU202508060017",
|
||||
OrderType: 2,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 268,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-004",
|
||||
OrderAmount: 268,
|
||||
CouponAmount: 8,
|
||||
PaymentAmount: 260,
|
||||
CreateTime: "2025-08-06 22:05:11",
|
||||
UpdateTime: "2025-08-06 22:05:35",
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
OrderNo: "JU202508050018",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1241",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-05 14:38:49",
|
||||
UpdateTime: "2025-08-05 14:39:05",
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
OrderNo: "JU202508040019",
|
||||
OrderType: 3,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 3,
|
||||
Accounts: "global-b1,global-b2,global-b3",
|
||||
OrderAmount: 294,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 294,
|
||||
CreateTime: "2025-08-04 10:52:18",
|
||||
UpdateTime: "2025-08-04 10:52:37",
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
OrderNo: "JU202508030020",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1242",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 98,
|
||||
CreateTime: "2025-08-03 16:07:23",
|
||||
UpdateTime: "2025-08-03 16:07:44",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
OrderNo: "JU202508020021",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1243",
|
||||
OrderAmount: 688,
|
||||
CouponAmount: 30,
|
||||
PaymentAmount: 658,
|
||||
CreateTime: "2025-08-02 20:29:56",
|
||||
UpdateTime: "2025-08-02 20:30:14",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
OrderNo: "JU202508010022",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 25,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-007",
|
||||
OrderAmount: 25,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 25,
|
||||
CreateTime: "2025-08-01 11:44:02",
|
||||
UpdateTime: "2025-08-01 11:44:21",
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
OrderNo: "JU202507310023",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1244",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-07-31 09:58:37",
|
||||
UpdateTime: "2025-07-31 09:58:55",
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_PRODUCTS = [
|
||||
{ id: 1, name: "动静态IP" },
|
||||
{ id: 2, name: "新HTTP/S5" },
|
||||
{ id: 3, name: "HTTP/S5" },
|
||||
]
|
||||
290
src/admin/orders/page.tsx
Normal file
290
src/admin/orders/page.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatOrderType,
|
||||
ORDER_TYPE_LABELS,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
accessorKey: "CreateTime",
|
||||
header: "日期",
|
||||
cell: ({ row }) => formatDate(row.original.CreateTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderNo",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderType",
|
||||
header: "类型",
|
||||
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||
},
|
||||
{
|
||||
accessorKey: "ProductName",
|
||||
header: "产品",
|
||||
},
|
||||
{
|
||||
accessorKey: "PackageName",
|
||||
header: "套餐",
|
||||
},
|
||||
{
|
||||
accessorKey: "DayPrice",
|
||||
header: "单价",
|
||||
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||
},
|
||||
{
|
||||
accessorKey: "ConnectCount",
|
||||
header: "总连接数",
|
||||
cell: ({ row }) => totalConnections(row.original),
|
||||
},
|
||||
{
|
||||
accessorKey: "Accounts",
|
||||
header: "账号",
|
||||
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderAmount",
|
||||
header: "订单金额",
|
||||
cell: ({ row }) => formatMoney(row.original.OrderAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "CouponAmount",
|
||||
header: "优惠券",
|
||||
cell: ({ row }) => formatMoney(row.original.CouponAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "PaymentAmount",
|
||||
header: "实付金额",
|
||||
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
日期筛选:
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.bTime}
|
||||
onChange={e => patch({ bTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="开始时间"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">至</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.eTime}
|
||||
onChange={e => patch({ eTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="结束时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filters.orderType}
|
||||
onValueChange={value => patch({ orderType: value })}
|
||||
>
|
||||
<SelectTrigger className="w-30">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部类型</SelectItem>
|
||||
{Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.productId}
|
||||
onValueChange={value => patch({ productId: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{MOCK_PRODUCTS.map(product => (
|
||||
<SelectItem key={product.id} value={String(product.id)}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => patch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filters.keyword}
|
||||
onChange={e => patch({ keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page: page - 1,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function LongPage() {
|
||||
return <div>LongPage</div>
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function ShortPage() {
|
||||
return <div>ShortPage</div>
|
||||
}
|
||||
40
src/admin/record/mock.ts
Normal file
40
src/admin/record/mock.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
|
||||
|
||||
export const MOCK_USED_SUMMARY: HttpUsedSummary = {
|
||||
todayShortPackIpUsed: 3,
|
||||
shortPackIpUsed: 45,
|
||||
}
|
||||
|
||||
const PACK_IP_PREFIX = [
|
||||
{ packType: 21, prefix: "211.90.13" },
|
||||
{ packType: 22, prefix: "112.65.28" },
|
||||
]
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, "0")
|
||||
}
|
||||
|
||||
function buildLogs(count: number): HttpUsedLogItem[] {
|
||||
const now = new Date()
|
||||
const logs: HttpUsedLogItem[] = []
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const source = PACK_IP_PREFIX[i % PACK_IP_PREFIX.length]
|
||||
const time = new Date(now.getTime() - i * 37 * 60 * 1000)
|
||||
const createTime =
|
||||
`${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ` +
|
||||
`${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`
|
||||
|
||||
logs.push({
|
||||
packType: source.packType,
|
||||
userIp: `113.118.${(i % 200) + 10}.${(i % 250) + 3}`,
|
||||
ip: `${source.prefix}.${(i % 240) + 10}`,
|
||||
port: 6000 + (i % 300),
|
||||
createTime,
|
||||
})
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
export const MOCK_USED_LOGS = buildLogs(56)
|
||||
136
src/admin/record/page.tsx
Normal file
136
src/admin/record/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
formatPackType,
|
||||
HTTP_USE_PACK_TYPE_OPTIONS,
|
||||
type HttpUsedLogItem,
|
||||
} from "@/lib/models/record"
|
||||
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||
|
||||
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
||||
{
|
||||
accessorKey: "packType",
|
||||
header: "使用类型",
|
||||
cell: ({ row }) => formatPackType(row.original.packType),
|
||||
},
|
||||
{
|
||||
accessorKey: "userIp",
|
||||
header: "客户端IP",
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "使用IP",
|
||||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: "端口号",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "使用时间",
|
||||
},
|
||||
]
|
||||
|
||||
function StatCard(props: {
|
||||
label: string
|
||||
value: number
|
||||
accent: "amber" | "blue"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 flex flex-col gap-1 ${
|
||||
props.accent === "amber" ? "bg-amber-100" : "bg-blue-100"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-gray-600">{props.label}</span>
|
||||
<span className="text-2xl font-semibold">{props.value} 个</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RecordPage() {
|
||||
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => MOCK_USED_LOGS.filter(log => log.packType === packType),
|
||||
[packType],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const handlePackTypeChange = (value: string) => {
|
||||
setPackType(Number(value))
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">包天包量使用概况</h3>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="当日使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.todayShortPackIpUsed}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.shortPackIpUsed}
|
||||
accent="blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-orange-600">
|
||||
明细 最多允许查看72小时内记录(短效无限量套餐暂无记录)
|
||||
</p>
|
||||
|
||||
<Select value={String(packType)} onValueChange={handlePackTypeChange}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_USE_PACK_TYPE_OPTIONS.map(type => (
|
||||
<SelectItem key={type} value={String(type)}>
|
||||
{formatPackType(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
401
src/admin/refundOrders/mock.ts
Normal file
401
src/admin/refundOrders/mock.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type { OrderItem } from "@/lib/models/order"
|
||||
|
||||
export const MOCK_ORDERS: OrderItem[] = [
|
||||
{
|
||||
Id: 23,
|
||||
OrderNo: "JU202508150001",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1234",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-15 10:23:45",
|
||||
UpdateTime: "2025-08-15 10:24:01",
|
||||
},
|
||||
{
|
||||
Id: 22,
|
||||
OrderNo: "JU202508150002",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1235",
|
||||
OrderAmount: 18,
|
||||
CouponAmount: 2,
|
||||
PaymentAmount: 16,
|
||||
CreateTime: "2025-08-15 09:12:33",
|
||||
UpdateTime: "2025-08-15 09:12:50",
|
||||
},
|
||||
{
|
||||
Id: 21,
|
||||
OrderNo: "JU202508140003",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1236",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-08-14 16:45:21",
|
||||
UpdateTime: "2025-08-14 16:45:39",
|
||||
},
|
||||
{
|
||||
Id: 20,
|
||||
OrderNo: "JU202508140004",
|
||||
OrderType: 3,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 5,
|
||||
AccountCount: 3,
|
||||
Accounts: "https-001,https-002,https-003",
|
||||
OrderAmount: 264,
|
||||
CouponAmount: 10,
|
||||
PaymentAmount: 254,
|
||||
CreateTime: "2025-08-14 14:02:17",
|
||||
UpdateTime: "2025-08-14 14:02:55",
|
||||
},
|
||||
{
|
||||
Id: 19,
|
||||
OrderNo: "JU202508140005",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-001",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-14 11:30:08",
|
||||
UpdateTime: "2025-08-14 11:30:26",
|
||||
},
|
||||
{
|
||||
Id: 18,
|
||||
OrderNo: "JU202508130006",
|
||||
OrderType: 4,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 10,
|
||||
AccountCount: 5,
|
||||
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
|
||||
OrderAmount: 3440,
|
||||
CouponAmount: 100,
|
||||
PaymentAmount: 3340,
|
||||
CreateTime: "2025-08-13 20:15:44",
|
||||
UpdateTime: "2025-08-13 20:16:02",
|
||||
},
|
||||
{
|
||||
Id: 17,
|
||||
OrderNo: "JU202508130007",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 0.5,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1237",
|
||||
OrderAmount: 0.5,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 0.5,
|
||||
CreateTime: "2025-08-13 18:03:12",
|
||||
UpdateTime: "2025-08-13 18:03:28",
|
||||
},
|
||||
{
|
||||
Id: 16,
|
||||
OrderNo: "JU202508120008",
|
||||
OrderType: 2,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 128,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-004",
|
||||
OrderAmount: 128,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 128,
|
||||
CreateTime: "2025-08-12 15:22:37",
|
||||
UpdateTime: "2025-08-12 15:22:59",
|
||||
},
|
||||
{
|
||||
Id: 15,
|
||||
OrderNo: "JU202508120009",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-002",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 5,
|
||||
PaymentAmount: 93,
|
||||
CreateTime: "2025-08-12 13:40:55",
|
||||
UpdateTime: "2025-08-12 13:41:13",
|
||||
},
|
||||
{
|
||||
Id: 14,
|
||||
OrderNo: "JU202508110010",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1238",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-11 10:05:29",
|
||||
UpdateTime: "2025-08-11 10:05:44",
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
OrderNo: "JU202508110011",
|
||||
OrderType: 3,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 4,
|
||||
AccountCount: 4,
|
||||
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
|
||||
OrderAmount: 72,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 72,
|
||||
CreateTime: "2025-08-11 08:56:48",
|
||||
UpdateTime: "2025-08-11 08:57:06",
|
||||
},
|
||||
{
|
||||
Id: 12,
|
||||
OrderNo: "JU202508100012",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1239",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-10 19:33:20",
|
||||
UpdateTime: "2025-08-10 19:33:41",
|
||||
},
|
||||
{
|
||||
Id: 11,
|
||||
OrderNo: "JU202508090013",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 880,
|
||||
ConnectCount: 20,
|
||||
AccountCount: 2,
|
||||
Accounts: "https-005,https-006",
|
||||
OrderAmount: 1760,
|
||||
CouponAmount: 50,
|
||||
PaymentAmount: 1710,
|
||||
CreateTime: "2025-08-09 21:18:07",
|
||||
UpdateTime: "2025-08-09 21:18:30",
|
||||
},
|
||||
{
|
||||
Id: 10,
|
||||
OrderNo: "JU202508090014",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 1,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-003",
|
||||
OrderAmount: 1,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 1,
|
||||
CreateTime: "2025-08-09 17:47:52",
|
||||
UpdateTime: "2025-08-09 17:48:09",
|
||||
},
|
||||
{
|
||||
Id: 9,
|
||||
OrderNo: "JU202508080015",
|
||||
OrderType: 4,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 6,
|
||||
AccountCount: 6,
|
||||
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
|
||||
OrderAmount: 528,
|
||||
CouponAmount: 20,
|
||||
PaymentAmount: 508,
|
||||
CreateTime: "2025-08-08 12:26:34",
|
||||
UpdateTime: "2025-08-08 12:26:58",
|
||||
},
|
||||
{
|
||||
Id: 8,
|
||||
OrderNo: "JU202508070016",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 58,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1240",
|
||||
OrderAmount: 58,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 58,
|
||||
CreateTime: "2025-08-07 09:11:26",
|
||||
UpdateTime: "2025-08-07 09:11:43",
|
||||
},
|
||||
{
|
||||
Id: 7,
|
||||
OrderNo: "JU202508060017",
|
||||
OrderType: 2,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 268,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-004",
|
||||
OrderAmount: 268,
|
||||
CouponAmount: 8,
|
||||
PaymentAmount: 260,
|
||||
CreateTime: "2025-08-06 22:05:11",
|
||||
UpdateTime: "2025-08-06 22:05:35",
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
OrderNo: "JU202508050018",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1241",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-05 14:38:49",
|
||||
UpdateTime: "2025-08-05 14:39:05",
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
OrderNo: "JU202508040019",
|
||||
OrderType: 3,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 3,
|
||||
Accounts: "global-b1,global-b2,global-b3",
|
||||
OrderAmount: 294,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 294,
|
||||
CreateTime: "2025-08-04 10:52:18",
|
||||
UpdateTime: "2025-08-04 10:52:37",
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
OrderNo: "JU202508030020",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1242",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 98,
|
||||
CreateTime: "2025-08-03 16:07:23",
|
||||
UpdateTime: "2025-08-03 16:07:44",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
OrderNo: "JU202508020021",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1243",
|
||||
OrderAmount: 688,
|
||||
CouponAmount: 30,
|
||||
PaymentAmount: 658,
|
||||
CreateTime: "2025-08-02 20:29:56",
|
||||
UpdateTime: "2025-08-02 20:30:14",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
OrderNo: "JU202508010022",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 25,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-007",
|
||||
OrderAmount: 25,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 25,
|
||||
CreateTime: "2025-08-01 11:44:02",
|
||||
UpdateTime: "2025-08-01 11:44:21",
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
OrderNo: "JU202507310023",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1244",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-07-31 09:58:37",
|
||||
UpdateTime: "2025-07-31 09:58:55",
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_PRODUCTS = [
|
||||
{ id: 1, name: "动静态IP" },
|
||||
{ id: 2, name: "新HTTP/S5" },
|
||||
{ id: 3, name: "HTTP/S5" },
|
||||
]
|
||||
272
src/admin/refundOrders/page.tsx
Normal file
272
src/admin/refundOrders/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatOrderType,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
accessorKey: "CreateTime",
|
||||
header: "日期",
|
||||
cell: ({ row }) => formatDate(row.original.CreateTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderNo",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderType",
|
||||
header: "类型",
|
||||
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||
},
|
||||
{
|
||||
accessorKey: "ProductName",
|
||||
header: "产品",
|
||||
},
|
||||
{
|
||||
accessorKey: "PackageName",
|
||||
header: "套餐",
|
||||
},
|
||||
{
|
||||
accessorKey: "Accounts",
|
||||
header: "账号",
|
||||
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "ConnectCount",
|
||||
header: "连接数",
|
||||
cell: ({ row }) => totalConnections(row.original),
|
||||
},
|
||||
{
|
||||
accessorKey: "DayPrice",
|
||||
header: "退款时长",
|
||||
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||
},
|
||||
{
|
||||
accessorKey: "DayPrice",
|
||||
header: "退款单价",
|
||||
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||
},
|
||||
{
|
||||
accessorKey: "PaymentAmount",
|
||||
header: "实付金额",
|
||||
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "CouponAmount",
|
||||
header: "退款金额",
|
||||
cell: ({ row }) => formatMoney(row.original.CouponAmount),
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function RefundOrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
日期筛选:
|
||||
</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.bTime}
|
||||
onChange={e => patch({ bTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="开始时间"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">至</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.eTime}
|
||||
onChange={e => patch({ eTime: e.target.value })}
|
||||
className="w-36"
|
||||
aria-label="结束时间"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={filters.productId}
|
||||
onValueChange={value => patch({ productId: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{MOCK_PRODUCTS.map(product => (
|
||||
<SelectItem key={product.id} value={String(product.id)}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => patch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={filters.keyword}
|
||||
onChange={e => patch({ keyword: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="账号"
|
||||
className="w-40"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page: page - 1,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
5
src/admin/resources/long.tsx
Normal file
5
src/admin/resources/long.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Page from "@/components/page"
|
||||
|
||||
export default function LongPage() {
|
||||
return <Page>长效管理</Page>
|
||||
}
|
||||
5
src/admin/resources/short.tsx
Normal file
5
src/admin/resources/short.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Page from "@/components/page"
|
||||
|
||||
export default function ShortPage() {
|
||||
return <Page>短效套餐</Page>
|
||||
}
|
||||
5
src/admin/rosorder/page.tsx
Normal file
5
src/admin/rosorder/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Page from "@/components/page"
|
||||
|
||||
export default function RosorderPage() {
|
||||
return <Page>软路由页面</Page>
|
||||
}
|
||||
149
src/api/auth.ts
149
src/api/auth.ts
@@ -1,103 +1,72 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByDevice, callByUser } from "./base"
|
||||
import { callPublic } from "./base"
|
||||
import { clearCookie, clearSession, readCookie, setSession } from "./token"
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
loginCode: string
|
||||
phone: string
|
||||
name: string
|
||||
wx: string
|
||||
qq: string
|
||||
taobao: string
|
||||
email: string
|
||||
restAmount: number
|
||||
function parseUserInfo(raw: string | null): unknown {
|
||||
if (!raw) return null
|
||||
let decoded = raw
|
||||
try {
|
||||
decoded = decodeURIComponent(raw)
|
||||
} catch {
|
||||
decoded = raw
|
||||
}
|
||||
try {
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginMode = "phone_code" | "password"
|
||||
|
||||
export async function login(props: {
|
||||
username: string
|
||||
export async function apiLogin(props: {
|
||||
logincode: string
|
||||
password: string
|
||||
remember: boolean
|
||||
mode: LoginMode
|
||||
code?: string
|
||||
wx?: string
|
||||
qq?: string
|
||||
}): Promise<ApiResponse> {
|
||||
const result = await callByDevice<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
}>("/api/auth/token", {
|
||||
...props,
|
||||
grant_type: "password",
|
||||
login_type: props.mode,
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const result = await callPublic<unknown>("/user/ApiLogin", {
|
||||
Logincode: props.logincode,
|
||||
Password: props.password,
|
||||
})
|
||||
|
||||
if (!result.success) return result
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message || "用户名或者密码不正确" }
|
||||
}
|
||||
|
||||
const { access_token, refresh_token, expires_in } = result.data
|
||||
localStorage.setItem("auth_token", access_token)
|
||||
localStorage.setItem("auth_refresh", refresh_token)
|
||||
localStorage.setItem(
|
||||
"auth_expires_in",
|
||||
String(Date.now() + expires_in * 1000),
|
||||
)
|
||||
// 通过响应头下发,优先读响应头,cookie 兜底
|
||||
const token = result.headers?.token ?? readCookie("token")
|
||||
const userInfoRaw = result.headers?.userinfo ?? readCookie("userInfo")
|
||||
|
||||
return { success: true, data: undefined }
|
||||
if (!token) {
|
||||
return { success: false, message: "登录成功但未获取到 token" }
|
||||
}
|
||||
|
||||
setSession(token, parseUserInfo(userInfoRaw))
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function apiRegister(props: {
|
||||
phone: string
|
||||
password: string
|
||||
code: string
|
||||
wx?: string
|
||||
qq?: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const result = await callPublic("/user/ApiRegist", {
|
||||
Code: props.code,
|
||||
Phone: props.phone,
|
||||
Pwd: props.password,
|
||||
QQ: props.qq ?? "",
|
||||
Wx: props.wx ?? "",
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const accessToken = localStorage.getItem("auth_token")
|
||||
const refreshToken = localStorage.getItem("auth_refresh")
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
await callByUser("/api/auth/revoke", {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
localStorage.removeItem("auth_token")
|
||||
localStorage.removeItem("auth_refresh")
|
||||
localStorage.removeItem("auth_expires_in")
|
||||
|
||||
clearSession()
|
||||
clearCookie("token")
|
||||
clearCookie("userInfo")
|
||||
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 }
|
||||
}
|
||||
|
||||
276
src/api/base.ts
276
src/api/base.ts
@@ -3,53 +3,51 @@ import {
|
||||
type ApiResponse,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
UnauthorizedError,
|
||||
} from "@/lib/api"
|
||||
import { clearSession, getUserToken } from "./token"
|
||||
|
||||
let deviceToken: string | null = null
|
||||
let deviceTokenExpire: Date | null = null
|
||||
type CallMode = "public" | "device" | "user"
|
||||
|
||||
async function call<T = undefined>(
|
||||
const DEFAULT_TIMEOUT = 15_000
|
||||
|
||||
function captureHeaders(response: Response): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value
|
||||
})
|
||||
return headers
|
||||
}
|
||||
|
||||
async function request<R = undefined>(
|
||||
endpoint: string,
|
||||
body?: string,
|
||||
auth?: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
init: {
|
||||
method: "GET" | "POST"
|
||||
body?: string
|
||||
auth?: string
|
||||
timeout?: number
|
||||
},
|
||||
): Promise<ApiResponse<R>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
init.timeout ?? DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
const headers: HeadersInit = {}
|
||||
if (init.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
if (auth) headers["Authorization"] = auth
|
||||
if (init.auth) headers.Authorization = init.auth
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
method: init.method,
|
||||
headers,
|
||||
body,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
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 responseHeaders = captureHeaders(response)
|
||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||
|
||||
if (contentType.includes("text/plain")) {
|
||||
@@ -61,11 +59,18 @@ async function call<T = undefined>(
|
||||
message: text || "请求失败",
|
||||
}
|
||||
}
|
||||
return { success: true, data: undefined as T }
|
||||
return { success: true, data: undefined as R, headers: responseHeaders }
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const json = await response.json()
|
||||
const json = (await response.json()) as R & {
|
||||
message?: string
|
||||
error_description?: string
|
||||
Code?: number
|
||||
Message?: string
|
||||
Data?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -73,86 +78,179 @@ async function call<T = undefined>(
|
||||
message: json.message || json.error_description || "请求失败",
|
||||
}
|
||||
}
|
||||
return { success: true, data: json }
|
||||
|
||||
if (typeof json.Code === "number") {
|
||||
if (json.Code === 10000) {
|
||||
return {
|
||||
success: true,
|
||||
data: (json.Data ?? json.data ?? undefined) as R,
|
||||
headers: responseHeaders,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.Message || "请求失败",
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: json, headers: responseHeaders }
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
status: 408,
|
||||
message: "请求超时,请稍后重试",
|
||||
}
|
||||
}
|
||||
console.error("后端请求异常:", e)
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
message: (e as Error).message || "网络错误",
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Public
|
||||
// ======================
|
||||
let deviceToken: string | null = null
|
||||
let deviceTokenExpire = 0
|
||||
let deviceTokenPromise: Promise<string | null> | null = null
|
||||
|
||||
async function acquireDeviceToken(): Promise<string | null> {
|
||||
if (deviceToken && Date.now() < deviceTokenExpire) return deviceToken
|
||||
if (!deviceTokenPromise) {
|
||||
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
|
||||
deviceTokenPromise = request<{
|
||||
access_token: string
|
||||
expires_in: number
|
||||
}>("/api/auth/token", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ grant_type: "client_credentials" }),
|
||||
auth: `Basic ${basic}`,
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.success) {
|
||||
deviceToken = null
|
||||
return null
|
||||
}
|
||||
deviceToken = resp.data.access_token
|
||||
deviceTokenExpire = Date.now() + (resp.data.expires_in - 60) * 1000
|
||||
return resp.data.access_token
|
||||
})
|
||||
.finally(() => {
|
||||
deviceTokenPromise = null
|
||||
})
|
||||
}
|
||||
return deviceTokenPromise
|
||||
}
|
||||
|
||||
const inflight = new Map<string, Promise<ApiResponse<unknown>>>()
|
||||
|
||||
function dedupGet<R>(
|
||||
endpoint: string,
|
||||
run: () => Promise<ApiResponse<R>>,
|
||||
): Promise<ApiResponse<R>> {
|
||||
const existing = inflight.get(endpoint)
|
||||
if (existing) return existing as Promise<ApiResponse<R>>
|
||||
const promise = run().finally(() => inflight.delete(endpoint))
|
||||
inflight.set(endpoint, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function call<R = undefined>(
|
||||
mode: CallMode,
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
const body = data === undefined ? undefined : JSON.stringify(data)
|
||||
|
||||
const run = async (): Promise<ApiResponse<R>> => {
|
||||
const maxAttempts = mode === "device" ? 2 : 1
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
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") {
|
||||
if (attempt > 1) {
|
||||
// 上次请求 401,强制作废旧 token 再重新获取
|
||||
deviceToken = null
|
||||
deviceTokenExpire = 0
|
||||
}
|
||||
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()
|
||||
return resp
|
||||
}
|
||||
if (mode === "device" && attempt < maxAttempts) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "请求未授权",
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "GET" && data === undefined) {
|
||||
return dedupGet<R>(endpoint, run)
|
||||
}
|
||||
return run()
|
||||
}
|
||||
|
||||
async function callPublic<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
return call(endpoint, data ? JSON.stringify(data) : undefined)
|
||||
return call<R>("public", endpoint, data, method)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Device
|
||||
// ======================
|
||||
async function callByDevice<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
// 检查内存中的 Token 是否过期
|
||||
if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) {
|
||||
// 用 btoa 生成 Basic 认证头
|
||||
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
|
||||
const resp = await call<{
|
||||
access_token: string
|
||||
expires_in: number
|
||||
}>(
|
||||
"/api/auth/token",
|
||||
JSON.stringify({ grant_type: "client_credentials" }),
|
||||
`Basic ${basic}`,
|
||||
)
|
||||
|
||||
if (!resp.success) {
|
||||
return resp
|
||||
}
|
||||
deviceToken = resp.data.access_token
|
||||
// 提前 60 秒过期,防止临界点失效
|
||||
deviceTokenExpire = new Date(
|
||||
Date.now() + (resp.data.expires_in - 60) * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
return call(
|
||||
endpoint,
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
`Bearer ${deviceToken}`,
|
||||
)
|
||||
return call<R>("device", endpoint, data, method)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// User
|
||||
// ======================
|
||||
async function callByUser<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
// 这里我们假设用户登录后的 Token 存在 localStorage 里
|
||||
// 如果你是用 Cookie 存 Token,这里用 js-cookie 的 get 方法替换即可
|
||||
const token = localStorage.getItem("auth_token")
|
||||
|
||||
if (!token) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
return call(
|
||||
endpoint,
|
||||
data ? JSON.stringify(data) : undefined,
|
||||
`Bearer ${token}`,
|
||||
)
|
||||
return call<R>("user", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callPublic }
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { callPublic } from "@/api/base"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
// 获取验证码
|
||||
// 假设你的后端接口为 /api/captcha/get,返回 { token: 'abc', base64: 'data:image/png...' }
|
||||
export async function getCaptcha() {
|
||||
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
|
||||
}
|
||||
|
||||
// 校验验证码
|
||||
// 假设后端接口为 /api/captcha/verify,返回 { success: true/false }
|
||||
export async function verifyCaptcha(token: string, answer: string) {
|
||||
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
|
||||
}
|
||||
176
src/api/http.ts
Normal file
176
src/api/http.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export type GameOrderData = {
|
||||
isAbroad: number
|
||||
isRelayed: number
|
||||
shareType: number
|
||||
gameId: number
|
||||
lineType: number
|
||||
bandwidth: number
|
||||
cityCode: number
|
||||
isp: number
|
||||
ipAmount: number
|
||||
periodType: number
|
||||
periodAmount: number
|
||||
}
|
||||
|
||||
export type GameCity = {
|
||||
code: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type GameItem = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type HttpOrderInfo = {
|
||||
order_type: number
|
||||
money: number
|
||||
pay_type: number
|
||||
data: GameOrderData
|
||||
}
|
||||
|
||||
export type CreateOrderResult = {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: string
|
||||
}
|
||||
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
async function postJson<T = RawHttpResponse>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: "请求失败",
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as T
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
status: 408,
|
||||
message: "请求超时,请稍后重试",
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
message: (e as Error).message || "网络错误",
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
||||
const d = raw?.d
|
||||
if (d && typeof d === "object" && !Array.isArray(d)) {
|
||||
return d as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export async function fetchGameCities(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/city", game)
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(cities) ? (cities as GameCity[]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameList(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameItem[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/game", game)
|
||||
if (!res.success) return res
|
||||
const games = unwrapD(res.data).games
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(games) ? (games as GameItem[]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameLineCount(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/linecount", game)
|
||||
if (!res.success) return res
|
||||
const count = unwrapD(res.data).count
|
||||
return { success: true, data: Number(count) || 0 }
|
||||
}
|
||||
|
||||
export async function calcGamePrice(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
orderInfo,
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const priceValue =
|
||||
typeof raw.price === "number" ? raw.price : unwrapD(raw).price
|
||||
return {
|
||||
success: true,
|
||||
data: typeof priceValue === "number" ? priceValue : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHttpOrder(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<CreateOrderResult>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/order/create_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: orderInfo,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: Number(res.data?.code) || 0,
|
||||
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
|
||||
data: typeof res.data?.data === "string" ? res.data.data : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse | number>(
|
||||
"/http/user/get_balance",
|
||||
{
|
||||
cookie: document.cookie,
|
||||
},
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (typeof res.data === "number") return { success: true, data: res.data }
|
||||
const raw = res.data ?? {}
|
||||
const balance = raw.d ?? raw.data ?? raw.balance
|
||||
return { success: true, data: Number(balance) || 0 }
|
||||
}
|
||||
73
src/api/linedata.ts
Normal file
73
src/api/linedata.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export type LineItem = {
|
||||
city: string | number
|
||||
daikuan: string | number
|
||||
name: string | number
|
||||
nasname: string | number
|
||||
online: string | number
|
||||
supply: string | number
|
||||
}
|
||||
|
||||
export type LineData = {
|
||||
count: number | string
|
||||
use_count: number | string
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export type LineSearchResult = {
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: "请求失败",
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as T
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
status: 408,
|
||||
message: "请求超时,请稍后重试",
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
message: (e as Error).message || "网络错误",
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
||||
return getJson<LineData>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function searchLineData(
|
||||
productid: number,
|
||||
info: string,
|
||||
): Promise<ApiResponse<LineSearchResult>> {
|
||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||
return getJson<LineSearchResult>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
|
||||
)
|
||||
}
|
||||
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",
|
||||
)
|
||||
}
|
||||
31
src/api/product.ts
Normal file
31
src/api/product.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchStaticProducts(): Promise<
|
||||
ApiResponse<ProductItem[]>
|
||||
> {
|
||||
return await callPublic<ProductItem[]>(
|
||||
"/product/ApiProductStatic",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchWindowProducts(): Promise<
|
||||
ApiResponse<ProductItem[]>
|
||||
> {
|
||||
return await callPublic<ProductItem[]>(
|
||||
"/product/ApiProductWindow",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
44
src/api/token.ts
Normal file
44
src/api/token.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export 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 clearCookie(name: string) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API 兼容性不足,标准做法是覆盖写入
|
||||
document.cookie = `${name}=; max-age=0; path=/`
|
||||
}
|
||||
|
||||
export function getUserToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getUserInfo<T = unknown>(): T | null {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthed(): boolean {
|
||||
return getUserToken() !== null
|
||||
}
|
||||
|
||||
export function setSession(token: string, userInfo: unknown) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userInfo))
|
||||
window.dispatchEvent(new Event(AUTH_EVENT))
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
window.dispatchEvent(new Event(AUTH_EVENT))
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callByDevice } from "./base"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
|
||||
export async function sendSMS(
|
||||
phone: string,
|
||||
purpose: SMSPurpose = "User_Code",
|
||||
): Promise<ApiResponse> {
|
||||
return await callByDevice("/api/verify/sms", {
|
||||
phone,
|
||||
purpose,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callPublic("/user/SendPhonesCodevefy", {
|
||||
key: purpose,
|
||||
phone,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,58 +1,17 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { getProfile, refreshAuth } from "@/api/auth"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const [isValid, setIsValid] = useState<boolean | null>(null)
|
||||
const [isValid, setIsValid] = 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()
|
||||
const update = () => setIsValid(isAuthed())
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => window.removeEventListener(AUTH_EVENT, update)
|
||||
}, [])
|
||||
|
||||
if (isValid === null)
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
验证身份中...
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
<Navigate
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -7,142 +10,199 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export type EditInfoValues = {
|
||||
username: string
|
||||
taobao: string
|
||||
password: string
|
||||
email: string
|
||||
wechat: string
|
||||
qq: string
|
||||
}
|
||||
|
||||
const editInfoSchema = z.object({
|
||||
username: z.string().trim().min(1, "请输入用户名"),
|
||||
taobao: z.string(),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
email: z
|
||||
.string()
|
||||
.refine(
|
||||
v => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||
"请输入正确的邮箱",
|
||||
),
|
||||
wechat: z.string(),
|
||||
qq: z.string(),
|
||||
})
|
||||
|
||||
interface EditInfoDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialData?: {
|
||||
username: string
|
||||
taobao: string
|
||||
password: string
|
||||
email: string
|
||||
wechat: string
|
||||
qq: string
|
||||
}
|
||||
onSave?: (data: any) => void
|
||||
initialData?: EditInfoValues
|
||||
onSave?: (data: EditInfoValues) => void
|
||||
}
|
||||
|
||||
const DEFAULT_INITIAL_DATA: EditInfoValues = {
|
||||
username: "191****1234",
|
||||
taobao: "191****1234",
|
||||
password: "********",
|
||||
email: "123456123@qq.com",
|
||||
wechat: "",
|
||||
qq: "",
|
||||
}
|
||||
|
||||
export function EditInfoDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialData = {
|
||||
username: "191****1234",
|
||||
taobao: "191****1234",
|
||||
password: "********",
|
||||
email: "123456123@qq.com",
|
||||
wechat: "",
|
||||
qq: "",
|
||||
},
|
||||
initialData = DEFAULT_INITIAL_DATA,
|
||||
onSave,
|
||||
}: EditInfoDialogProps) {
|
||||
const [formData, setFormData] = useState(initialData)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
const form = useForm<EditInfoValues>({
|
||||
resolver: zodResolver(editInfoSchema),
|
||||
defaultValues: initialData,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave?.(formData)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(initialData)
|
||||
}
|
||||
}, [open, initialData, form])
|
||||
|
||||
const onSubmit = (data: EditInfoValues) => {
|
||||
onSave?.(data)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px] p-6">
|
||||
<DialogContent className="sm:max-w-120 p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-center">
|
||||
编辑信息
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
{/* 用户名 */}
|
||||
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">用户名</Label>
|
||||
<Input
|
||||
value={formData.username}
|
||||
onChange={e => handleChange("username", e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
<Controller
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淘宝会员名 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">淘宝会员名</Label>
|
||||
<Input
|
||||
value={formData.taobao}
|
||||
onChange={e => handleChange("taobao", e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
<Controller
|
||||
name="taobao"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={e => handleChange("password", e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...field}
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 邮箱 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">邮箱</Label>
|
||||
<Input
|
||||
value={formData.email}
|
||||
onChange={e => handleChange("email", e.target.value)}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
{...field}
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 微信 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">微信</Label>
|
||||
<Input
|
||||
value={formData.wechat}
|
||||
onChange={e => handleChange("wechat", e.target.value)}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
<Controller
|
||||
name="wechat"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QQ */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-gray-600">QQ</Label>
|
||||
<Input
|
||||
value={formData.qq}
|
||||
onChange={e => handleChange("qq", e.target.value)}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
<Controller
|
||||
name="qq"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="请输入"
|
||||
className="h-11 bg-gray-50 border-gray-200"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -27,14 +27,12 @@ export type DataTableProps<T> = {
|
||||
headRow?: string
|
||||
dataRow?: string
|
||||
}
|
||||
/** 是否开启行选择复选框 */
|
||||
enableSelection?: boolean
|
||||
/** 受控选中状态 */
|
||||
rowSelection?: Record<string, boolean>
|
||||
/** 选中状态变化回调 */
|
||||
onSelectionChange?: (selection: Record<string, boolean>) => void
|
||||
/** 行唯一ID生成函数 */
|
||||
getRowId?: (row: T, index: number) => string
|
||||
stickyHeader?: boolean
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>(
|
||||
@@ -112,8 +110,18 @@ export default function DataTable<T extends Record<string, unknown>>(
|
||||
<>
|
||||
{/* 数据表 */}
|
||||
<div className="rounded-md relative bg-card">
|
||||
<TableRoot>
|
||||
<TableHeader>
|
||||
<TableRoot
|
||||
containerClassName={
|
||||
props.stickyHeader
|
||||
? cn("overflow-y-auto", props.maxHeight ?? "max-h-[600px]")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TableHeader
|
||||
className={
|
||||
props.stickyHeader ? "sticky top-0 z-10 bg-card" : undefined
|
||||
}
|
||||
>
|
||||
{table.getHeaderGroups().map(group => (
|
||||
<TableRow key={group.id} className={props.classNames?.headRow}>
|
||||
{group.headers.map(header => (
|
||||
|
||||
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")}
|
||||
/>
|
||||
<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")}
|
||||
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 { AUTH_EVENT, 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_EVENT, update)
|
||||
return () => {
|
||||
window.removeEventListener("focus", update)
|
||||
window.removeEventListener(AUTH_EVENT, 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>
|
||||
)
|
||||
}
|
||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -2,11 +2,15 @@ import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
function Table({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
|
||||
10
src/home/_assets/alipay.svg
Normal file
10
src/home/_assets/alipay.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_98_223)">
|
||||
<path d="M20.001 13.692V3.845C20.0005 2.82523 19.5951 1.8474 18.8739 1.12641C18.1527 0.405415 17.1748 0.000264944 16.155 0L3.845 0C2.82541 0.000529935 1.84772 0.405797 1.12676 1.12676C0.405797 1.84772 0.000529935 2.82541 0 3.845V16.155C0.000265092 17.1747 0.405447 18.1525 1.12647 18.8735C1.84749 19.5946 2.82532 19.9997 3.845 20H16.155C17.057 19.9994 17.9301 19.682 18.622 19.1034C19.3139 18.5248 19.7808 17.7216 19.941 16.834C18.921 16.392 14.501 14.484 12.198 13.384C10.446 15.507 8.61 16.781 5.844 16.781C3.078 16.781 1.231 15.077 1.453 12.991C1.599 11.623 2.538 9.386 6.615 9.769C8.765 9.971 9.748 10.372 11.501 10.951C11.954 10.119 12.331 9.204 12.617 8.231H4.845V7.461H8.691V6.077H4V5.23H8.69V3.235C8.69 3.235 8.732 2.923 9.077 2.923H11V5.23H16V6.078H11V7.46H15.079C14.7272 8.91647 14.1692 10.3152 13.422 11.614C14.607 12.044 20 13.692 20 13.692H20.001ZM5.538 15.461C2.615 15.461 2.153 13.616 2.308 12.845C2.461 12.077 3.308 11.075 4.933 11.075C6.8 11.075 8.473 11.553 10.481 12.531C9.071 14.367 7.338 15.461 5.538 15.461Z" fill="#00A5F1"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_98_223">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
src/home/_assets/balance.svg
Normal file
4
src/home/_assets/balance.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.99996 0C4.4771 0 0 4.47714 0 10C0 15.5229 4.47712 20 9.99996 20C15.5228 20 19.9999 15.5228 19.9999 10C19.9999 4.47716 15.5228 0 9.99996 0ZM7.05159 4.29727C7.78297 3.68016 9.40572 4.98294 9.97711 4.93723C10.5485 4.98294 12.1713 3.68016 12.9026 4.29727C13.634 4.91437 12.017 6.325 12.017 6.325H7.93688C7.93688 6.325 6.32021 4.91437 7.05159 4.29727ZM12.1713 7.05639C12.1713 7.25852 12.0077 7.42208 11.8056 7.42208H8.14866C7.9465 7.42208 7.78297 7.25852 7.78297 7.05639C7.78297 6.85425 7.9465 6.6907 8.14866 6.6907H11.8056C12.0077 6.6907 12.1713 6.85425 12.1713 7.05639ZM9.97711 15.8251C7.14941 15.8251 4.85744 16.0801 4.85744 13.6881C4.85744 11.9096 6.12522 9.10908 7.93688 7.78775H12.017C13.829 9.10911 15.0968 11.9096 15.0968 13.6881C15.0968 16.0801 12.8048 15.8251 9.97711 15.8251Z" fill="#FF6B00"/>
|
||||
<path d="M10.0331 10.4641C10.1948 10.4641 10.321 10.5147 10.4125 10.6152C10.504 10.7158 10.5501 10.8605 10.5501 11.0487H11.5651C11.5651 10.7314 11.457 10.4759 11.2417 10.2807C11.0263 10.0854 10.7292 9.96605 10.3514 9.92196V9.375H9.82754V9.91174C9.43225 9.94399 9.11936 10.0511 8.88808 10.2334C8.65681 10.4157 8.54116 10.6486 8.54116 10.9315C8.54116 11.2434 8.6553 11.4833 8.88429 11.6511C9.11333 11.8189 9.46855 11.969 9.94998 12.1013C10.1714 12.1846 10.3249 12.268 10.4103 12.3519C10.4956 12.4358 10.5387 12.5498 10.5387 12.6939C10.5387 12.8349 10.4972 12.9473 10.4126 13.0322C10.3279 13.1167 10.1971 13.1592 10.0188 13.1592C9.82683 13.1592 9.6726 13.1113 9.55846 13.0167C9.44432 12.922 9.38689 12.7634 9.38689 12.5412H8.39678L8.38921 12.5541C8.37939 12.915 8.50335 13.1888 8.7611 13.3755C9.01882 13.5621 9.35064 13.6712 9.75495 13.7035V14.2043H10.2825V13.7009C10.6778 13.6686 10.9892 13.5653 11.2167 13.3894C11.4442 13.2136 11.5583 12.9801 11.5583 12.6887C11.5583 12.3783 11.4411 12.1352 11.2076 11.9588C10.974 11.7824 10.6234 11.6334 10.1563 11.5113C9.92274 11.4183 9.76476 11.3317 9.68312 11.2521C9.60152 11.1725 9.56073 11.0665 9.55995 10.9348C9.55995 10.7922 9.597 10.6781 9.6703 10.5927C9.74364 10.5072 9.86457 10.4641 10.0331 10.4641Z" fill="#FF6B00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
16
src/home/_assets/wechat.svg
Normal file
16
src/home/_assets/wechat.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_98_228)">
|
||||
<path d="M3.6 0H16.4C18.8 0 20 1.2 20 3.6V16.4C20 18.8 18.8 20 16.4 20H3.6C1.2 20 0 18.8 0 16.4V3.6C0 1.2 1.2 0 3.6 0Z" fill="#28D846"/>
|
||||
<path d="M12.9534 7.94922C10.4386 7.94922 8.39999 9.65422 8.39999 11.7574C8.39999 13.8606 10.4386 15.5656 12.9534 15.5656C13.5434 15.5658 14.1294 15.4683 14.6876 15.2772L15.9912 15.9716L15.8158 14.7192C16.3212 14.4045 16.7403 13.969 17.0355 13.452C17.3307 12.9349 17.4927 12.3526 17.5068 11.7574C17.5066 9.65422 15.468 7.94922 12.9534 7.94922Z" fill="white"/>
|
||||
<path d="M7.89999 3.80078C10.576 3.80078 12.7884 5.44078 13.2938 7.61478C12.3402 7.53638 7.39379 8.06938 8.17939 12.9148C7.46839 12.9168 6.49939 12.8856 5.80539 12.6518L4.23059 13.4912L4.44259 11.9784C3.21699 11.1678 2.39999 9.86418 2.39999 8.40078C2.39999 5.86078 4.86239 3.80078 7.89999 3.80078Z" fill="white"/>
|
||||
<path d="M5.39999 6.88016C5.39999 7.07111 5.47585 7.25425 5.61088 7.38927C5.7459 7.5243 5.92904 7.60016 6.11999 7.60016C6.31095 7.60016 6.49408 7.5243 6.62911 7.38927C6.76414 7.25425 6.83999 7.07111 6.83999 6.88016C6.83999 6.6892 6.76414 6.50607 6.62911 6.37104C6.49408 6.23601 6.31095 6.16016 6.11999 6.16016C5.92904 6.16016 5.7459 6.23601 5.61088 6.37104C5.47585 6.50607 5.39999 6.6892 5.39999 6.88016Z" fill="#28D846"/>
|
||||
<path d="M9 6.88016C9 7.07111 9.07586 7.25425 9.21088 7.38927C9.34591 7.5243 9.52904 7.60016 9.72 7.60016C9.91096 7.60016 10.0941 7.5243 10.2291 7.38927C10.3641 7.25425 10.44 7.07111 10.44 6.88016C10.44 6.6892 10.3641 6.50607 10.2291 6.37104C10.0941 6.23601 9.91096 6.16016 9.72 6.16016C9.52904 6.16016 9.34591 6.23601 9.21088 6.37104C9.07586 6.50607 9 6.6892 9 6.88016Z" fill="#28D846"/>
|
||||
<path d="M10.8 10.64C10.8 10.8097 10.8674 10.9725 10.9874 11.0925C11.1075 11.2126 11.2702 11.28 11.44 11.28C11.6097 11.28 11.7725 11.2126 11.8925 11.0925C12.0126 10.9725 12.08 10.8097 12.08 10.64C12.08 10.4703 12.0126 10.3075 11.8925 10.1875C11.7725 10.0674 11.6097 10 11.44 10C11.2702 10 11.1075 10.0674 10.9874 10.1875C10.8674 10.3075 10.8 10.4703 10.8 10.64Z" fill="#28D846"/>
|
||||
<path d="M13.84 10.64C13.84 10.8097 13.9074 10.9725 14.0274 11.0925C14.1475 11.2126 14.3103 11.28 14.48 11.28C14.6497 11.28 14.8125 11.2126 14.9325 11.0925C15.0526 10.9725 15.12 10.8097 15.12 10.64C15.12 10.4703 15.0526 10.3075 14.9325 10.1875C14.8125 10.0674 14.6497 10 14.48 10C14.3103 10 14.1475 10.0674 14.0274 10.1875C13.9074 10.3075 13.84 10.4703 13.84 10.64Z" fill="#28D846"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_98_228">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,72 +1,10 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { logout } from "@/api/auth"
|
||||
import { useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import ProductDropdown from "@/components/productDropdown"
|
||||
import ProfileOrLogin from "@/components/profileOrLogin"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ProfileOrLogin() {
|
||||
const [authed, setAuthed] = useState(
|
||||
() => !!localStorage.getItem("auth_token"),
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => setAuthed(!!localStorage.getItem("auth_token"))
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
if (authed) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate("/admin")}
|
||||
className="w-24 h-12 flex items-center justify-center lg:text-lg text-slate-700 hover:text-blue-500 transition-colors"
|
||||
>
|
||||
<span>个人中心</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={[
|
||||
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
|
||||
"transition-colors duration-200 ease-in-out",
|
||||
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<NavLink
|
||||
to="/login"
|
||||
state={{ tab: "login" }}
|
||||
className="w-24 h-12 flex items-center justify-center lg:text-lg"
|
||||
>
|
||||
<span>登录</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/login"
|
||||
state={{ tab: "register" }}
|
||||
className={[
|
||||
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
|
||||
"transition-colors duration-200 ease-in-out",
|
||||
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>注册</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const scroll = useSyncExternalStore(
|
||||
callback => {
|
||||
@@ -229,113 +167,3 @@ const MenuItem = ({
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigate(path)
|
||||
onNavigate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ProductCard
|
||||
title="动静态IP"
|
||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||
tag="热门"
|
||||
onClick={() => handleClick("/product?tab=short")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="新HTTP/S5"
|
||||
desc="IP可用时长从数小时到365天全覆盖"
|
||||
tag="稳定"
|
||||
onClick={() => handleClick("/product?tab=long")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="HTTP/S5"
|
||||
desc="海量全球住宅优质IP,4G/5G真实手机IP"
|
||||
tag="全球"
|
||||
onClick={() => handleClick("/product?tab=global")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="软路由成本价"
|
||||
desc="云端自动切换IP,更易于调用与开发"
|
||||
onClick={() => handleClick("/product?tab=tunnel")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="动态Vps"
|
||||
desc="IP独享,高带宽,灵活控制存活周期"
|
||||
onClick={() => handleClick("/product?tab=exclusive")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="静态独享IP"
|
||||
desc="纯净IP,更符合跨境卖家需求的云主机"
|
||||
onClick={() => handleClick("/product?tab=static")}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -138,8 +138,63 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex mt-10 lg:mt-20 items-center justify-center">
|
||||
<img src={s9} alt="图片" className="max-w-full h-auto" />
|
||||
<div className="flex flex-col mt-10 lg:mt-20">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<img src={s9} alt="图片" className="max-w-full h-auto" />
|
||||
<div className="absolute left-0 top-0 w-[30%] h-full flex flex-col justify-between">
|
||||
<div className="text-left pr-4 pt-0">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
爬虫抓取
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
3000W高匿名IP,可以轻松抓取企业信息、分类信息、房地产信息、电商信息
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left pr-4">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
数据采集
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
用于大数据采集的多样化利用场景,快速采集SEO数据优化、金融理财、地域信息数据
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-left pr-4 pb-0">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
营销推广
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
论坛发帖、问答推广、网上购物、投票点赞等,根据渠道操作要求,定制IP完成营销推广
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 w-[30%] h-full flex flex-col justify-between">
|
||||
<div className="text-right pl-4 pt-0">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
电子商务
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
可帮助用户切换网络,模拟多个独立账号,有助于用户参与限时促销活动,避免购物效率低下的困扰
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right pl-4">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
游戏工作室
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
保证IP稳定,不掉线,在有效时长内完成游戏试玩、升级、完成游戏工作室操作要求
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right pl-4 pb-0">
|
||||
<h4 className="text-sm lg:text-base font-semibold text-gray-800 mb-1">
|
||||
自媒体运营
|
||||
</h4>
|
||||
<p className="text-[10px] lg:text-xs text-gray-500 leading-relaxed">
|
||||
可帮助自媒体运营者维护账号独立性,为每个账号赋予独立IP,避免IP地址关联带来的负面影响
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-12 lg:pt-24 ">
|
||||
|
||||
@@ -1,72 +1,292 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import {
|
||||
fetchLineData,
|
||||
type LineItem,
|
||||
PHP_API_BASE_URL,
|
||||
searchLineData,
|
||||
} from "@/api/linedata"
|
||||
import DataTable from "@/components/data-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||
const PAGE_SIZE_OPTIONS = [100, 200, 500]
|
||||
|
||||
type ProductOption = {
|
||||
id: number
|
||||
name: string
|
||||
l2tp: string
|
||||
sstp: string
|
||||
}
|
||||
|
||||
const PRODUCTS: ProductOption[] = [
|
||||
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
|
||||
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
|
||||
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
|
||||
]
|
||||
|
||||
function toText(value: string | number): string {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
|
||||
function onlineClass(value: string | number): string {
|
||||
const text = toText(value)
|
||||
if (!text) return "text-gray-400"
|
||||
return text.includes("在线") || text.includes("正常")
|
||||
? "text-green-600"
|
||||
: "text-red-500"
|
||||
}
|
||||
|
||||
export default function IpLinePage() {
|
||||
const [productId, setProductId] = useState<number | null>(null)
|
||||
const [rows, setRows] = useState<LineItem[]>([])
|
||||
const [count, setCount] = useState<number | string>(0)
|
||||
const [useCount, setUseCount] = useState<number | string>(0)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(100)
|
||||
const requestSeq = useRef(0)
|
||||
|
||||
const loadDisplay = useCallback(async (pid: number) => {
|
||||
const seq = ++requestSeq.current
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await fetchLineData(pid)
|
||||
if (seq !== requestSeq.current) return
|
||||
if (result.success) {
|
||||
setRows(result.data.data ?? [])
|
||||
setCount(result.data.count ?? 0)
|
||||
setUseCount(result.data.use_count ?? 0)
|
||||
} else {
|
||||
setRows([])
|
||||
setCount(0)
|
||||
setUseCount(0)
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// 首次进入展示全部产品线路(product=0)
|
||||
useEffect(() => {
|
||||
loadDisplay(0)
|
||||
}, [loadDisplay])
|
||||
|
||||
// 每5分钟自动刷新(搜索状态除外)
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (!searching) loadDisplay(productId ?? 0)
|
||||
}, REFRESH_INTERVAL)
|
||||
return () => clearInterval(timer)
|
||||
}, [productId, searching, loadDisplay])
|
||||
|
||||
// 数据变化后修正越界页码
|
||||
useEffect(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
if (pageIndex >= totalPages) setPageIndex(totalPages - 1)
|
||||
}, [rows.length, pageIndex, pageSize])
|
||||
|
||||
const handleSelectProduct = (id: number) => {
|
||||
const next = productId === id ? null : id
|
||||
setProductId(next)
|
||||
setSearching(false)
|
||||
setKeyword("")
|
||||
setPageIndex(0)
|
||||
loadDisplay(next ?? 0)
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const info = keyword.trim()
|
||||
if (!info) {
|
||||
if (searching) {
|
||||
setSearching(false)
|
||||
setPageIndex(0)
|
||||
await loadDisplay(productId ?? 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setPageIndex(0)
|
||||
const seq = ++requestSeq.current
|
||||
const result = await searchLineData(productId ?? 0, info)
|
||||
console.log("搜索的result", result)
|
||||
|
||||
if (seq !== requestSeq.current) return
|
||||
if (result.success) {
|
||||
setRows(result.data.data ?? [])
|
||||
} else {
|
||||
setRows([])
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleCopy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success("复制成功")
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleExport = () => {
|
||||
if (rows.length === 0) {
|
||||
toast.error("暂无数据可导出")
|
||||
return
|
||||
}
|
||||
const header = [
|
||||
"产品",
|
||||
"城市",
|
||||
"运营商",
|
||||
"服务器域名",
|
||||
"带宽",
|
||||
"服务器状态",
|
||||
]
|
||||
const lines = [
|
||||
header,
|
||||
...rows.map(r => [
|
||||
toText(r.name),
|
||||
toText(r.city),
|
||||
toText(r.supply),
|
||||
toText(r.nasname),
|
||||
toText(r.daikuan),
|
||||
toText(r.online),
|
||||
]),
|
||||
]
|
||||
const csv = lines
|
||||
.map(line => line.map(v => `"${v.replaceAll('"', '""')}"`).join(","))
|
||||
.join("\r\n")
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${currentName || "IP线路表"}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("导出成功")
|
||||
}
|
||||
|
||||
const currentProduct = PRODUCTS.find(p => p.id === productId)
|
||||
const currentName = currentProduct?.name ?? "全部产品"
|
||||
|
||||
const pageRows = useMemo(() => {
|
||||
const start = pageIndex * pageSize
|
||||
return rows.slice(start, start + pageSize)
|
||||
}, [rows, pageIndex, pageSize])
|
||||
|
||||
const status = loading ? "load" : error ? "fail" : "done"
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="grid grid-cols-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
IP线路表
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
下载客户端
|
||||
</a>
|
||||
<a
|
||||
href="/help"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
教程&帮助
|
||||
</a>
|
||||
<>
|
||||
<Toaster position="top-center" richColors />
|
||||
<Wrap className="flex flex-col gap-4 mt-20 max-w-6xl mx-auto px-4 pb-20 bg-white">
|
||||
<div className="grid grid-cols-3 mt-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
IP线路表
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
下载客户端
|
||||
</a>
|
||||
<a
|
||||
href="/help"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
教程&帮助
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex justify-between gap-4 text-xs text-gray-600 px-2">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
线路表和账户必须为同一产品才能使用
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
请优先选择客户端连接
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
无对应客户端时,可通过线路表直连支持所有设备
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="flex justify-between gap-4 text-xs text-gray-600 px-2">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
线路表和账户必须为同一产品才能使用
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
请优先选择客户端连接
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
无对应客户端时,可通过线路表直连支持所有设备
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button className="px-6 py-1.5 rounded-full text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 font-medium">
|
||||
极狐IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
极光IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
蘑菇IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
麒麟IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
猎豹IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
水滴独享IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
火狐静态IP
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{PRODUCTS.map(p => (
|
||||
<Button
|
||||
key={p.id}
|
||||
variant={p.id === productId ? "default" : "outline"}
|
||||
onClick={() => handleSelectProduct(p.id)}
|
||||
className={cn(
|
||||
"px-6 py-1.5 rounded-full",
|
||||
p.id === productId
|
||||
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 hover:opacity-90"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<div className="relative w-1/2">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<div className="relative w-1/2">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="请输入线路搜索信息,如:混拨"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="px-8 rounded-full bg-linear-to-r from-cyan-400 to-blue-500 text-white shadow-md shadow-blue-200 hover:opacity-90"
|
||||
>
|
||||
搜索当前线路表
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
className="gap-1.5 px-4 rounded border-blue-400 text-blue-500 bg-white hover:bg-blue-50 hover:text-blue-500"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
@@ -77,270 +297,158 @@ export default function IpLinePage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>L2TP密钥:</span>
|
||||
<span className="text-orange-600">
|
||||
{currentProduct?.l2tp || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>STTP端口:</span>
|
||||
<span className="text-orange-600">
|
||||
{currentProduct?.sstp || ""}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="混拨"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button className="px-8 py-2 bg-linear-to-r from-cyan-400 to-blue-500 text-white rounded-full text-sm font-medium shadow-md shadow-blue-200">
|
||||
搜索当前线路表
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 border border-blue-400 text-blue-500 rounded text-sm bg-white hover:bg-blue-50 transition-colors">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>L2TP密钥:</span>
|
||||
<span className="text-orange-600">1234</span>
|
||||
<div className="flex items-center gap-6 text-xs text-gray-600 px-1">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
搜索结果:
|
||||
<span className="font-bold text-gray-800">{rows.length}条</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时总线路:
|
||||
<span className="font-bold text-gray-800">{count}条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时可用线路:
|
||||
<span className="font-bold text-gray-800">{useCount}条</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span className="text-gray-400 break-all">
|
||||
{currentName}- (每5分钟更新一次,禁止频繁访问!) :
|
||||
{PHP_API_BASE_URL}/script/linedata/display.php?product=
|
||||
{productId ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>STTP端口:</span>
|
||||
<span className="text-orange-600">4430</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-xs text-gray-600 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时总线路:<span className="font-bold text-gray-800">486条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时可用线路:<span className="font-bold text-gray-800">486条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span className="text-gray-400">
|
||||
极狐IP- (每5分钟更新一次,禁止频繁访问!) :{" "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center justify-center gap-3 bg-red-50 border border-red-100 text-red-500 rounded-lg py-2 px-4 text-sm">
|
||||
<span>加载失败:{error}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => loadDisplay(productId ?? 0)}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded overflow-hidden">
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
||||
<tr>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">产品</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-32">城市</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-48">
|
||||
运营商
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200">服务器域名</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-20">IP量</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
实时带宽
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
维护状态
|
||||
</th>
|
||||
<th className="py-3 px-4 w-24">负载状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-gray-500"
|
||||
rowSpan={10}
|
||||
>
|
||||
极狐
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={7}
|
||||
>
|
||||
全国
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb3.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb4.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb5.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hbdx2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hbdx1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={2}
|
||||
>
|
||||
北京
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>bj1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>bj2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={1}
|
||||
>
|
||||
上海
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>sh1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Wrap>
|
||||
<DataTable<LineItem>
|
||||
data={pageRows}
|
||||
status={status}
|
||||
columns={useMemo<ColumnDef<LineItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "产品",
|
||||
cell: ({ row }) => toText(row.original.name),
|
||||
},
|
||||
{
|
||||
accessorKey: "city",
|
||||
header: "城市",
|
||||
cell: ({ row }) => toText(row.original.city),
|
||||
},
|
||||
{
|
||||
accessorKey: "supply",
|
||||
header: "运营商",
|
||||
cell: ({ row }) => toText(row.original.supply),
|
||||
},
|
||||
{
|
||||
accessorKey: "nasname",
|
||||
header: "服务器域名",
|
||||
cell: ({ row }) => {
|
||||
const text = toText(row.original.nasname)
|
||||
if (!text) return null
|
||||
return (
|
||||
<span>
|
||||
{text}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleCopy(text)}
|
||||
className="ml-2 h-auto px-1 py-0 text-green-500 hover:bg-transparent hover:text-green-700 cursor-pointer"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "daikuan",
|
||||
header: "带宽",
|
||||
cell: ({ row }) => toText(row.original.daikuan),
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: "服务器状态",
|
||||
cell: ({ row }) => {
|
||||
const text = toText(row.original.online)
|
||||
return (
|
||||
<span className={onlineClass(row.original.online)}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleCopy],
|
||||
)}
|
||||
stickyHeader
|
||||
maxHeight="max-h-[640px]"
|
||||
classNames={{ headRow: "bg-gray-50 text-gray-600" }}
|
||||
pagination={
|
||||
rows.length > 0
|
||||
? {
|
||||
page: pageIndex + 1,
|
||||
size: pageSize,
|
||||
total: rows.length,
|
||||
sizeOptions: PAGE_SIZE_OPTIONS,
|
||||
onPageChange: page => setPageIndex(page - 1),
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPageIndex(0)
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Wrap>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { login } from "@/api/auth"
|
||||
import { z } from "zod"
|
||||
import { apiLogin, apiRegister } from "@/api/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import bg from "./_assets/bg.webp"
|
||||
|
||||
const loginSchema = z.object({
|
||||
account: z.string().trim().min(1, "请输入账号"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
remember: z.boolean(),
|
||||
})
|
||||
|
||||
type LoginValues = z.infer<typeof loginSchema>
|
||||
|
||||
const registerSchema = z.object({
|
||||
phone: z.string().regex(/^1\d{10}$/, "请输入正确的手机号"),
|
||||
smsCode: z.string().min(3, "请输入短信验证码").max(6, "请输入短信验证码"),
|
||||
password: z.string().trim().min(1, "请输入密码"),
|
||||
wx: z.string(),
|
||||
qq: z.string(),
|
||||
agreed: z.boolean().refine(v => v, "请先阅读并同意用户协议和隐私政策"),
|
||||
})
|
||||
|
||||
type RegisterValues = z.infer<typeof registerSchema>
|
||||
|
||||
export default function LoginPage() {
|
||||
const [tab, setTab] = useState<"login" | "register">("login")
|
||||
const location = useLocation()
|
||||
@@ -69,33 +92,31 @@ export default function LoginPage() {
|
||||
|
||||
function LoginForm() {
|
||||
const navigate = useNavigate()
|
||||
const [account, setAccount] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!account.trim()) {
|
||||
toast.error("请输入账号")
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
toast.error("请输入密码")
|
||||
return
|
||||
}
|
||||
const form = useForm<LoginValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
account: "",
|
||||
password: "",
|
||||
remember: false,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: LoginValues) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await login({
|
||||
username: account,
|
||||
password,
|
||||
remember,
|
||||
mode: "password",
|
||||
const resp = await apiLogin({
|
||||
logincode: data.account,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
if (resp.success) {
|
||||
toast.success("登录成功")
|
||||
navigate("/admin")
|
||||
toast.success("登录成功", {
|
||||
description: "欢迎回来!",
|
||||
})
|
||||
navigate("/")
|
||||
} else {
|
||||
toast.error(resp.message || "登录失败")
|
||||
}
|
||||
@@ -107,53 +128,75 @@ function LoginForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
||||
账号
|
||||
会员号
|
||||
</label>
|
||||
<Input
|
||||
id="account"
|
||||
type="text"
|
||||
placeholder="请输入淘宝名/会员手机号码"
|
||||
className="w-full"
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
<Controller
|
||||
name="account"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
id="account"
|
||||
type="text"
|
||||
placeholder="请输入会员号"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码(8-12位数字或字母组合)"
|
||||
className="w-full pr-10"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码"
|
||||
className="w-full pr-10"
|
||||
{...field}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={remember}
|
||||
onCheckedChange={v => setRemember(!!v)}
|
||||
<Controller
|
||||
name="remember"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id="remember"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label htmlFor="remember" className="text-gray-600 cursor-pointer">
|
||||
记住密码
|
||||
@@ -182,28 +225,31 @@ function LoginForm() {
|
||||
}
|
||||
|
||||
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const [phone, setPhone] = useState("")
|
||||
const [smsCode, setSmsCode] = useState("")
|
||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||
const [smsLoading, setSmsLoading] = useState(false)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [wx, setWx] = useState("")
|
||||
const [qq, setQq] = useState("")
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const form = useForm<RegisterValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
phone: "",
|
||||
smsCode: "",
|
||||
password: "",
|
||||
wx: "",
|
||||
qq: "",
|
||||
agreed: false,
|
||||
},
|
||||
})
|
||||
|
||||
const handleSendSMS = async () => {
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
toast.error("请输入正确的手机号")
|
||||
return
|
||||
}
|
||||
const valid = await form.trigger("phone")
|
||||
if (!valid) return
|
||||
|
||||
setSmsLoading(true)
|
||||
try {
|
||||
const { sendSMS } = await import("@/api/verify")
|
||||
const resp = await sendSMS(phone, "User_Code")
|
||||
const resp = await sendSMS(form.getValues("phone"), "User_Code")
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.message || "短信发送失败")
|
||||
}
|
||||
@@ -230,41 +276,20 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!/^1\d{10}$/.test(phone)) {
|
||||
toast.error("请输入正确的手机号")
|
||||
return
|
||||
}
|
||||
if (smsCode.length < 4 || smsCode.length > 6) {
|
||||
toast.error("请输入短信验证码")
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]{8,12}$/.test(newPassword)) {
|
||||
toast.error("密码为8-12位数字或字母组合")
|
||||
return
|
||||
}
|
||||
if (!agreed) {
|
||||
toast.error("请先阅读并同意用户协议和隐私政策")
|
||||
return
|
||||
}
|
||||
|
||||
const onSubmit = async (data: RegisterValues) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await login({
|
||||
username: phone,
|
||||
password: newPassword,
|
||||
code: smsCode,
|
||||
remember: false,
|
||||
mode: "phone_code",
|
||||
wx,
|
||||
qq,
|
||||
const resp = await apiRegister({
|
||||
phone: data.phone,
|
||||
password: data.password,
|
||||
code: data.smsCode,
|
||||
wx: data.wx,
|
||||
qq: data.qq,
|
||||
})
|
||||
|
||||
if (resp.success) {
|
||||
toast.success("注册成功")
|
||||
navigate("/admin")
|
||||
toast.success("注册成功,请登录")
|
||||
onSwitchToLogin()
|
||||
} else {
|
||||
toast.error(resp.message || "注册失败")
|
||||
}
|
||||
@@ -276,7 +301,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="reg-account"
|
||||
@@ -284,29 +309,48 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
>
|
||||
手机号
|
||||
</label>
|
||||
<Input
|
||||
id="reg-account"
|
||||
type="text"
|
||||
placeholder="请输入手机号码"
|
||||
className="w-full"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
<Controller
|
||||
name="phone"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<Input
|
||||
id="reg-account"
|
||||
type="text"
|
||||
placeholder="请输入手机号码"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="reg-sms" className="text-sm font-medium text-gray-700">
|
||||
短信验证码
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reg-sms"
|
||||
type="text"
|
||||
placeholder="请输入短信验证码"
|
||||
className="h-10"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
value={smsCode}
|
||||
onChange={e => setSmsCode(e.target.value.replace(/\D/g, ""))}
|
||||
<div className="flex items-start gap-2">
|
||||
<Controller
|
||||
name="smsCode"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
id="reg-sms"
|
||||
type="text"
|
||||
placeholder="请输入短信验证码"
|
||||
className="h-10"
|
||||
maxLength={6}
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
onChange={e =>
|
||||
field.onChange(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -330,41 +374,54 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
>
|
||||
请输入密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reg-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入8-12位数字或字母组合"
|
||||
className="w-full pr-10"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeIcon className="size-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reg-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="请输入密码"
|
||||
className="w-full pr-10"
|
||||
{...field}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeIcon className="size-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="reg-wx" className="text-sm font-medium text-gray-700">
|
||||
微信号
|
||||
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="reg-wx"
|
||||
type="text"
|
||||
placeholder="请输入微信号"
|
||||
className="w-full"
|
||||
value={wx}
|
||||
onChange={e => setWx(e.target.value)}
|
||||
<Controller
|
||||
name="wx"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="reg-wx"
|
||||
type="text"
|
||||
placeholder="请输入微信号"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -372,35 +429,52 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||
QQ号
|
||||
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
||||
</label>
|
||||
<Input
|
||||
id="reg-qq"
|
||||
type="text"
|
||||
placeholder="请输入QQ号"
|
||||
className="w-full"
|
||||
value={qq}
|
||||
onChange={e => setQq(e.target.value.replace(/\D/g, ""))}
|
||||
<Controller
|
||||
name="qq"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="reg-qq"
|
||||
type="text"
|
||||
placeholder="请输入QQ号"
|
||||
className="w-full"
|
||||
{...field}
|
||||
onChange={e => field.onChange(e.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="agreement"
|
||||
checked={agreed}
|
||||
onCheckedChange={v => setAgreed(!!v)}
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
name="agreed"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="agreement"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label
|
||||
htmlFor="agreement"
|
||||
className="text-xs text-gray-600 cursor-pointer"
|
||||
>
|
||||
我同意
|
||||
<a
|
||||
href="/xieyi.html"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
《聚IP JUIP.COM用户注册协议》
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="agreement"
|
||||
className="text-xs text-gray-600 cursor-pointer"
|
||||
>
|
||||
我同意
|
||||
<a
|
||||
href="/xieyi.html"
|
||||
className="text-blue-600 hover:text-blue-500"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
《聚IP JUIP.COM用户注册协议》
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
617
src/home/product/buy.tsx
Normal file
617
src/home/product/buy.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { type CreateOrderRequest, createOrder } from "@/api/order"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import balance from "../_assets/balance.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
|
||||
// ============ 工具函数 ============
|
||||
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
|
||||
}
|
||||
|
||||
// ============ 模式切换 Tab ============
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 w-48">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 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 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,
|
||||
disabled,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
label: string
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
{label}:
|
||||
</Label>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={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>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={e => {
|
||||
const parsed = Number.parseInt(e.target.value, 10)
|
||||
if (!Number.isNaN(parsed)) onChange(Math.max(1, parsed))
|
||||
}}
|
||||
className="w-20 h-8 text-center text-lg font-semibold text-gray-700"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={disabled}
|
||||
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">{hint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 优惠券选择器 ============
|
||||
function CouponSelector() {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
选择优惠券:
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger className="h-10 rounded-xl border-gray-200 flex-1 max-w-xs">
|
||||
<SelectValue placeholder="请选择优惠券" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">暂无可用优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
淘宝每次下单获得优惠券
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册说明 ============
|
||||
function BatchInfo() {
|
||||
return (
|
||||
<div className="flex items-start gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28 pt-1">
|
||||
说明:
|
||||
</Label>
|
||||
<div className="flex-1 text-sm text-gray-600 leading-relaxed p-2 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 主页面 ============
|
||||
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 [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!product) {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchPwd(pw)
|
||||
}, [navigate, product])
|
||||
|
||||
if (!product) return null
|
||||
|
||||
const packageId = Number(product.id)
|
||||
const isTest = product.isTest ?? false
|
||||
const isDayCard = product.card === "天"
|
||||
// 天卡首单(0.1元)连接数固定为1;大于0.1元的天卡连接数可增减、手输入
|
||||
const isFirstOrderDayCard = isDayCard && product.price <= 0.1
|
||||
|
||||
const displayConnectCount = isFirstOrderDayCard ? 1 : connectCount
|
||||
|
||||
const clampMinPrice = (total: number, count: number): number => {
|
||||
if (
|
||||
!isFirstOrderDayCard &&
|
||||
!isTest &&
|
||||
product.minPrice !== undefined &&
|
||||
product.minPrice > 0
|
||||
) {
|
||||
const minCost = product.minPrice * count
|
||||
if (total < minCost) return minCost
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const singleTotal = clampMinPrice(
|
||||
product.price * displayConnectCount,
|
||||
displayConnectCount,
|
||||
)
|
||||
const batchTotal = clampMinPrice(
|
||||
product.price * batchConnectCount * batchCount,
|
||||
batchConnectCount * batchCount,
|
||||
)
|
||||
const total = mode === "single" ? singleTotal : batchTotal
|
||||
|
||||
// 批量预览
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 4) {
|
||||
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 handlePay = async () => {
|
||||
if (mode === "single") {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const oPayType = Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
|
||||
const params: CreateOrderRequest = {
|
||||
PackageId: packageId,
|
||||
OrderType: mode === "single" ? 1 : 2,
|
||||
Account: mode === "single" ? account.trim() : batchAccount.trim(),
|
||||
Pwd: mode === "single" ? pwd.trim() : batchPwd.trim(),
|
||||
ConnectCount:
|
||||
mode === "single" ? displayConnectCount : batchConnectCount,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: product.price,
|
||||
...(mode === "batch" && {
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await createOrder(params)
|
||||
|
||||
if (result.success) {
|
||||
toast.success("购买成功!")
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 天卡首单(0.1元)不显示批量注册;天卡>0.1元时显示(juipnet:应付款!=0.1时显示批量tab)
|
||||
const showTabs = !isTest && !isFirstOrderDayCard
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-16">
|
||||
<div className="w-full max-w-2xl px-8">
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-6 space-y-2">
|
||||
<h4 className="text-2xl font-bold">
|
||||
<span className="text-gray-800">{product.title}</span>
|
||||
<span className="text-orange-500 ml-2">¥{total.toFixed(2)}</span>
|
||||
</h4>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">天卡不支持退款,请谨慎购买</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<ModeTabs mode={mode} onModeChange={setMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5 mb-20">
|
||||
{mode === "single" ? (
|
||||
// ====== 单个注册 ======
|
||||
<>
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 连接设备数:测试卡不显示;天卡首单(0.1元)显示但禁用 */}
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ====== 批量注册 ======
|
||||
<>
|
||||
{/* 批量说明 */}
|
||||
<BatchInfo />
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP账号前缀:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
3至8位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
开始号:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 flex-1 max-w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
本批次账号的起始账号
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
注册个数:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 flex-1 max-w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
即将生成的账号:
|
||||
</Label>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
{batchPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 单账号连接设备数 */}
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 优惠券 */}
|
||||
{!isTest && <CouponSelector />}
|
||||
|
||||
{/* 余额 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
余额:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">¥10.00</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
支付方式:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</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 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</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 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 whitespace-nowrap w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-12 rounded-xl text-base border-gray-200 hover:bg-gray-50"
|
||||
onClick={handleBack}
|
||||
>
|
||||
上一步
|
||||
</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 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
1257
src/home/product/http.tsx
Normal file
1257
src/home/product/http.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,14 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import {
|
||||
fetchProducts,
|
||||
fetchStaticProducts,
|
||||
fetchWindowProducts,
|
||||
} from "@/api/product"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { PRODUCT_DATA } from "@/lib/models/product"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ProductItem, Tab, TabSource } from "@/lib/models/product"
|
||||
import { transformToTabs } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
import ProductGrid from "./productGrid"
|
||||
import ProductSidebar from "./productSidebar"
|
||||
@@ -11,52 +18,146 @@ import PurchaseInfo from "./purchaseInfo"
|
||||
const TAB_ALIAS: Record<string, string> = {
|
||||
short: "dynamic",
|
||||
long: "dynamic",
|
||||
global: "dynamic",
|
||||
tunnel: "dynamic",
|
||||
exclusive: "static",
|
||||
}
|
||||
|
||||
const TAB_SOURCES: {
|
||||
id: string
|
||||
label: string
|
||||
fetch: () => Promise<ApiResponse<ProductItem[]>>
|
||||
}[] = [
|
||||
{ id: "dynamic", label: "动态独享IP", fetch: fetchProducts },
|
||||
{ id: "static", label: "静态IP", fetch: fetchStaticProducts },
|
||||
{ id: "window", label: "单窗口单IP", fetch: fetchWindowProducts },
|
||||
]
|
||||
|
||||
export default function ProductPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [activeVersionId, setActiveVersionId] = useState<string>("v1")
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [activeBrandId, setActiveBrandId] = useState("")
|
||||
const [selectedVersionId, setSelectedVersionId] = useState("")
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
TAB_SOURCES.map(source => source.fetch()),
|
||||
)
|
||||
console.log(results, "resultsresultsresults")
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
const sources: TabSource[] = []
|
||||
const failedLabels: string[] = []
|
||||
|
||||
TAB_SOURCES.forEach((source, index) => {
|
||||
const result = results[index]
|
||||
if (result?.status === "fulfilled" && result.value.success) {
|
||||
sources.push({
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
data: result.value.data,
|
||||
})
|
||||
} else {
|
||||
failedLabels.push(source.label)
|
||||
}
|
||||
})
|
||||
|
||||
if (sources.length === 0) {
|
||||
setError("产品列表加载失败,请稍后重试")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (failedLabels.length > 0) {
|
||||
console.warn(`部分产品列表加载失败: ${failedLabels.join("、")}`)
|
||||
}
|
||||
|
||||
const newTabs = transformToTabs(sources)
|
||||
setTabs(newTabs)
|
||||
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
||||
setActiveBrandId(newTabs[0].brands[0].id)
|
||||
initializedRef.current = true
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const urlTab = searchParams.get("tab") || "dynamic"
|
||||
const activeTabId = TAB_ALIAS[urlTab] || urlTab
|
||||
const activeBrandId = searchParams.get("brand") || ""
|
||||
|
||||
const currentTab =
|
||||
PRODUCT_DATA.find(t => t.id === activeTabId) || PRODUCT_DATA[0]
|
||||
const currentTab = tabs.find(t => t.id === activeTabId) || tabs[0]
|
||||
const currentBrand =
|
||||
currentTab.brands.find(b => b.id === activeBrandId) || currentTab.brands[0]
|
||||
currentTab?.brands.find(b => b.id === activeBrandId) ||
|
||||
currentTab?.brands[0]
|
||||
|
||||
const effectiveVersionId =
|
||||
selectedVersionId || currentBrand?.versions?.[0]?.id || ""
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
const targetTab = PRODUCT_DATA.find(t => t.id === tabId)
|
||||
setSearchParams({ tab: tabId })
|
||||
const targetTab = tabs.find(t => t.id === tabId)
|
||||
const firstBrand = targetTab?.brands[0]
|
||||
setSearchParams({ tab: tabId, brand: firstBrand?.id || "" })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(firstBrand?.id || "")
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const handleBrandSelect = (brandId: string) => {
|
||||
setSearchParams({ tab: activeTabId, brand: brandId })
|
||||
setActiveVersionId("v1")
|
||||
setActiveBrandId(brandId)
|
||||
setSelectedVersionId("")
|
||||
}
|
||||
|
||||
const getDisplayProducts = () => {
|
||||
const displayProducts = (() => {
|
||||
if (!currentBrand) return []
|
||||
|
||||
if (currentBrand.versions && currentBrand.versions.length > 0) {
|
||||
return currentBrand.products.filter(p => p.version === activeVersionId)
|
||||
return currentBrand.products.filter(p => p.version === effectiveVersionId)
|
||||
}
|
||||
return currentBrand.products
|
||||
})()
|
||||
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">加载中...</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-red-400">加载失败:{error}</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<Wrap className="flex items-center justify-center pt-30 bg-white min-h-96">
|
||||
<div className="text-gray-400">暂无产品数据</div>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
const displayProducts = getDisplayProducts()
|
||||
const isPurchaseInfo = currentBrand?.id === "info"
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-16 pt-30 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<ProductTabs
|
||||
tabs={PRODUCT_DATA}
|
||||
tabs={tabs}
|
||||
activeId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
/>
|
||||
@@ -73,7 +174,6 @@ export default function ProductPage() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{!isPurchaseInfo ? (
|
||||
<>
|
||||
{" "}
|
||||
<div className="grid grid-cols-2 pl-3 text-sm gap-2 text-gray-600 leading-relaxed">
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
@@ -105,10 +205,10 @@ export default function ProductPage() {
|
||||
{currentBrand.versions.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setActiveVersionId(v.id)}
|
||||
onClick={() => setSelectedVersionId(v.id)}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center",
|
||||
activeVersionId === v.id
|
||||
effectiveVersionId === v.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white shadow-sm"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import vector from "../_assets/vector.webp"
|
||||
@@ -7,6 +8,8 @@ type ProductGridProps = {
|
||||
}
|
||||
|
||||
export default function ProductGrid({ products }: ProductGridProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||
{products.map(product => (
|
||||
@@ -15,9 +18,13 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
className="relative overflow-visible rounded-xl shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white p-6 flex flex-col min-h-60"
|
||||
>
|
||||
{product.tag && (
|
||||
<div className="absolute -top-5 -right-5 flex items-center gap-1 text-xs font-bold px-3 py-1">
|
||||
<img src={vector} alt="打折" />
|
||||
{product.tag}
|
||||
<div className="absolute -top-5 -right-5">
|
||||
<div className="relative">
|
||||
<img src={vector} alt="打折" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold">
|
||||
{product.tag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -68,6 +75,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => navigate("/product/buy", { state: { product } })}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
|
||||
@@ -15,13 +15,13 @@ export default function ProductTabs({
|
||||
}: ProductTabsProps) {
|
||||
return (
|
||||
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-slate-100 rounded-xl h-14">
|
||||
<TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
|
||||
{tabs.map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className={cn(
|
||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0",
|
||||
"w-full h-auto py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0",
|
||||
activeId === tab.id
|
||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white! shadow-none"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||
|
||||
408
src/home/product/routeros.tsx
Normal file
408
src/home/product/routeros.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import balance from "../_assets/balance.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
|
||||
const PRODUCT_VARIANTS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Mikrotik百兆5G双频-1拖40",
|
||||
describe: "千兆双频",
|
||||
price: 299,
|
||||
lineprice: 399,
|
||||
src: "https://gd4.alicdn.com/imgextra/i2/2212500574675/O1CN015GmSuk1kPCJNLBMl9_!!2212500574675.jpg_400x400.jpg",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ros系统千兆5G双频版-1拖40",
|
||||
describe: "千兆双频增强",
|
||||
price: 399,
|
||||
lineprice: 499,
|
||||
src: "https://mp4.juip.com/%E8%B7%AF%E7%94%B1%E5%99%A8.png",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "热销款-ros系统千兆5G双频版1拖30",
|
||||
describe: "企业级性能",
|
||||
price: 499,
|
||||
lineprice: 599,
|
||||
src: "https://gd1.alicdn.com/imgextra/i2/2212500574675/O1CN010VprRs1kPCJG5ks8l_!!2212500574675.jpg_400x400.jpg",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Mikrotik千兆5G双频版-1拖30",
|
||||
describe: "五口千兆",
|
||||
price: 599,
|
||||
lineprice: 699,
|
||||
src: "https://gd1.alicdn.com/imgextra/i2/2212500574675/O1CN010VprRs1kPCJG5ks8l_!!2212500574675.jpg_400x400.jpg",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "千兆5G双频PRO版-1拖60",
|
||||
describe: "五口千兆",
|
||||
price: 599,
|
||||
lineprice: 699,
|
||||
src: "https://gd4.alicdn.com/imgextra/i4/2212500574675/O1CN01blV7JL1kPCJcWJgyY_!!2212500574675.jpg_400x400.jpg",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "千兆5G旗舰版-1拖100",
|
||||
describe: "五口千兆",
|
||||
price: 599,
|
||||
lineprice: 699,
|
||||
src: "https://gd1.alicdn.com/imgextra/i3/2212500574675/O1CN01DdOPmM1kPCJHFrtcS_!!2212500574675.jpg_400x400.jpg",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "全协议千兆双频软路由一拖100+AP",
|
||||
describe: "五口千兆",
|
||||
price: 599,
|
||||
lineprice: 699,
|
||||
src: "https://mp4.juip.com/%E5%85%A8%E5%8D%8F%E8%AE%AE%E5%8D%83%E5%85%86%E5%8F%8C%E9%A2%91%E8%BD%AF%E8%B7%AF%E7%94%B1%E4%B8%80%E6%8B%96100%2BAP%20.jpg",
|
||||
},
|
||||
]
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
{ id: "alipay", label: "支付宝支付", icon: alipay },
|
||||
{ id: "wechat", label: "微信支付", icon: wechat },
|
||||
{ id: "balance", label: "余额支付", icon: balance },
|
||||
]
|
||||
|
||||
const SPECS = [
|
||||
[
|
||||
{ label: "品牌", value: "Mikrotik" },
|
||||
{ label: "售后服务", value: "店铺三包" },
|
||||
{ label: "无线传输速率", value: "1000Mbps" },
|
||||
{ label: "是否无线", value: "是" },
|
||||
{ label: "适用场景", value: "中小企业/游戏路由" },
|
||||
],
|
||||
[
|
||||
{ label: "型号", value: "AC2" },
|
||||
{ label: "USB接口", value: "1个" },
|
||||
{ label: "无线速度", value: "1167M" },
|
||||
{ label: "上市时间", value: "2012-08-22" },
|
||||
],
|
||||
[
|
||||
{ label: "成色", value: "全新" },
|
||||
{ label: "有线传输率", value: "千兆端口" },
|
||||
{ label: "无线频率", value: "2.4G&5G" },
|
||||
{ label: "版本类型", value: "中国大陆" },
|
||||
{ label: "保修期", value: "1年" },
|
||||
],
|
||||
]
|
||||
|
||||
export default function RouterosPage() {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const [payMethod, setPayMethod] = useState("alipay")
|
||||
const [userInfo, setUserInfo] = useState({
|
||||
name: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
})
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const product = PRODUCT_VARIANTS[selectedVariant]
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-10 pt-30 pb-16 bg-white">
|
||||
<div className="flex flex-col lg:flex-row gap-10">
|
||||
<div className="lg:w-5/12">
|
||||
<Card className="overflow-hidden border-0 shadow-md">
|
||||
<img
|
||||
src={product.src}
|
||||
alt={product.name}
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex gap-3 mt-4 flex-wrap">
|
||||
{PRODUCT_VARIANTS.map((variant, index) => (
|
||||
<button
|
||||
key={variant.id}
|
||||
onClick={() => setSelectedVariant(index)}
|
||||
className={cn(
|
||||
"size-16 rounded-lg cursor-pointer border-2 transition-all overflow-hidden p-0 bg-white",
|
||||
index === selectedVariant
|
||||
? "border-blue-500 shadow-md scale-105"
|
||||
: "border-gray-200 hover:border-blue-300",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={variant.src}
|
||||
alt={variant.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:w-7/12 space-y-6">
|
||||
<h4 className="text-2xl font-bold text-gray-800 leading-relaxed">
|
||||
ros千兆软路由 一拖100矩阵 抖音快手单机单IP 魔硬软改 工作室试玩
|
||||
</h4>
|
||||
|
||||
<Card className="bg-orange-50/60 border-0">
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">商品信息</span>
|
||||
<span className="font-bold text-gray-700">{product.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">价格</span>
|
||||
<span className="line-through text-gray-400">
|
||||
¥{product.lineprice}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground text-sm">会员价</span>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xl font-bold px-3 py-1 h-auto"
|
||||
>
|
||||
¥{product.price}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
{product.describe}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">颜色分类</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PRODUCT_VARIANTS.map((variant, index) => (
|
||||
<Button
|
||||
key={variant.id}
|
||||
variant={index === selectedVariant ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedVariant(index)}
|
||||
className={cn(
|
||||
"rounded-full text-sm font-medium transition-all",
|
||||
index === selectedVariant &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 text-white border-transparent hover:opacity-90",
|
||||
index !== selectedVariant &&
|
||||
"border-gray-200 hover:border-blue-300",
|
||||
)}
|
||||
>
|
||||
{variant.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground w-20 shrink-0">
|
||||
收货人
|
||||
</span>
|
||||
<span className="text-gray-800 font-medium">
|
||||
{userInfo.name || "未设置"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground w-20 shrink-0">
|
||||
电话号码
|
||||
</span>
|
||||
<span className="text-gray-800 font-medium">
|
||||
{userInfo.phone || "未设置"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground w-20 shrink-0">
|
||||
收货地址
|
||||
</span>
|
||||
<span className="text-gray-800 font-medium">
|
||||
{userInfo.address || "未设置"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">支付方式</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PAYMENT_METHODS.map(method => (
|
||||
<label
|
||||
key={method.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium cursor-pointer border transition-all",
|
||||
payMethod === method.id
|
||||
? "bg-blue-50 border-blue-400 text-blue-600"
|
||||
: "bg-white border-gray-200 text-muted-foreground hover:border-blue-300",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payMethod"
|
||||
value={method.id}
|
||||
checked={payMethod === method.id}
|
||||
onChange={() => setPayMethod(method.id)}
|
||||
className="hidden"
|
||||
/>
|
||||
<img
|
||||
src={method.icon}
|
||||
alt={method.label}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span>{method.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="flex-1 rounded-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 font-bold"
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
修改收货信息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-6 text-center">
|
||||
产品规格
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{SPECS.map((col, colIdx) => (
|
||||
<Card key={colIdx} className="border-0 bg-muted/30">
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{col.map((spec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex justify-between text-sm py-1.5 border-b border-border/50 last:border-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{spec.label}</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{spec.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-6 text-center">
|
||||
产品展示
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
"https://img.alicdn.com/imgextra/i4/2212500574675/O1CN01RYiT0X1kPCJFZqcIn_!!2212500574675.jpg",
|
||||
"https://img.alicdn.com/imgextra/i2/2212500574675/O1CN01K3pAdS1kPCJFZr5Pv_!!2212500574675.jpg",
|
||||
"https://img.alicdn.com/imgextra/i2/2212500574675/O1CN01fLjEYo1kPCJMaaMkj_!!2212500574675.jpg",
|
||||
"https://img.alicdn.com/imgextra/i1/2212500574675/O1CN01TN50Rn1kPCJCp9gBl_!!2212500574675.jpg",
|
||||
"https://img.alicdn.com/imgextra/i1/2212500574675/O1CN018Iy0Xg1kPCJJP5Zn5_!!2212500574675.jpg",
|
||||
"https://img.alicdn.com/imgextra/i2/2212500574675/O1CN01fQQIT81kPCJLg5xrB_!!2212500574675.png",
|
||||
"https://img.alicdn.com/imgextra/i3/2212500574675/O1CN01oAjpI01kPCJGyE0JZ_!!2212500574675.png",
|
||||
"https://img.alicdn.com/imgextra/i3/2212500574675/O1CN01unvVq91kPCJFZq52D_!!2212500574675.png",
|
||||
].map((src, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="overflow-hidden border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={`产品图${index + 1}`}
|
||||
className="w-full aspect-square object-cover"
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改收货信息</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">收货人</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={userInfo.name}
|
||||
onChange={e =>
|
||||
setUserInfo({ ...userInfo, name: e.target.value })
|
||||
}
|
||||
placeholder="请输入收货人"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">电话</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={userInfo.phone}
|
||||
onChange={e =>
|
||||
setUserInfo({ ...userInfo, phone: e.target.value })
|
||||
}
|
||||
placeholder="请输入电话"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">地址</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={userInfo.address}
|
||||
onChange={e =>
|
||||
setUserInfo({ ...userInfo, address: e.target.value })
|
||||
}
|
||||
placeholder="请输入地址"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded-full"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
提交更改
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
@@ -23,18 +23,18 @@ export default function SoftDownloadPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-6 mt-30">
|
||||
<div className="grid grid-cols-3">
|
||||
<Wrap className="flex flex-col gap-6 mt-20 bg-white">
|
||||
<div className="grid grid-cols-3 mt-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
软件下载
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
href="/ipline"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
下载客户端
|
||||
IP线路表
|
||||
</a>
|
||||
<a
|
||||
href="/help"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// 定义后端服务URL和OAuth2配置
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
if (!API_BASE_URL) throw new Error("VITE_API_BASE_URL is not set")
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""
|
||||
|
||||
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
|
||||
if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set")
|
||||
@@ -12,40 +11,14 @@ if (!CLIENT_SECRET) throw new Error("VITE_CLIENT_SECRET is not set")
|
||||
type ApiResponse<T = undefined> =
|
||||
| {
|
||||
success: false
|
||||
status: number
|
||||
status?: number
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
success: true
|
||||
data: T
|
||||
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
type PageRecord<T = unknown> = {
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
// 工具类型:用于提取请求参数和返回数据
|
||||
type ExtraReq<T extends (...args: never) => unknown> = T extends (
|
||||
...args: infer P
|
||||
) => unknown
|
||||
? P[0]
|
||||
: never
|
||||
type ExtraResp<T extends (...args: never) => unknown> =
|
||||
Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
|
||||
|
||||
// 预定义错误
|
||||
const UnauthorizedError = new Error("未授权访问")
|
||||
|
||||
export {
|
||||
API_BASE_URL,
|
||||
type ApiResponse,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
type ExtraReq,
|
||||
type ExtraResp,
|
||||
type PageRecord,
|
||||
UnauthorizedError,
|
||||
}
|
||||
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||
|
||||
67
src/lib/models/order.ts
Normal file
67
src/lib/models/order.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type OrderItem = {
|
||||
Id: number
|
||||
OrderNo: string
|
||||
OrderType: number
|
||||
ProductId: number
|
||||
ProductName: string
|
||||
PackageName: string
|
||||
DayPrice: number
|
||||
ConnectCount: number
|
||||
AccountCount: number
|
||||
Accounts: string
|
||||
OrderAmount: number
|
||||
CouponAmount: number
|
||||
PaymentAmount: number
|
||||
CreateTime: string
|
||||
UpdateTime: string
|
||||
}
|
||||
|
||||
export type OrderFilters = {
|
||||
orderType: string
|
||||
productId: string
|
||||
packageName: string
|
||||
keyword: string
|
||||
bTime: string
|
||||
eTime: string
|
||||
}
|
||||
|
||||
export const DEFAULT_FILTERS: OrderFilters = {
|
||||
orderType: "0",
|
||||
productId: "0",
|
||||
packageName: "0",
|
||||
keyword: "",
|
||||
bTime: "",
|
||||
eTime: "",
|
||||
}
|
||||
|
||||
export const ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
1: "新开",
|
||||
2: "续费",
|
||||
3: "批量新开",
|
||||
4: "批量续费",
|
||||
}
|
||||
|
||||
export const PACKAGE_OPTIONS = [
|
||||
"测试卡",
|
||||
"天卡",
|
||||
"周卡",
|
||||
"月卡",
|
||||
"月卡(活动)",
|
||||
"双月卡(活动)",
|
||||
"季卡",
|
||||
"季卡(活动)",
|
||||
"年卡",
|
||||
]
|
||||
|
||||
export function formatOrderType(type: number): string {
|
||||
return ORDER_TYPE_LABELS[type] ?? String(type)
|
||||
}
|
||||
|
||||
export function cutAccount(accounts: string, max = 15): string {
|
||||
if (accounts.length > max) return `${accounts.slice(0, max)}...`
|
||||
return accounts
|
||||
}
|
||||
|
||||
export function totalConnections(item: OrderItem): number {
|
||||
return item.ConnectCount * item.AccountCount
|
||||
}
|
||||
@@ -5,8 +5,10 @@ export type Version = {
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
productId: number
|
||||
title: string
|
||||
price: number
|
||||
minPrice?: number
|
||||
desc?: string
|
||||
tag?: string
|
||||
card?: string
|
||||
@@ -14,6 +16,7 @@ export type Product = {
|
||||
duration?: string
|
||||
version?: string
|
||||
features?: string[]
|
||||
isTest?: boolean
|
||||
}
|
||||
|
||||
export type Brand = {
|
||||
@@ -29,267 +32,118 @@ export type Tab = {
|
||||
brands: Brand[]
|
||||
}
|
||||
|
||||
export const PRODUCT_DATA: Tab[] = [
|
||||
{
|
||||
id: "dynamic",
|
||||
label: "动态独享IP",
|
||||
brands: [
|
||||
{
|
||||
id: "fox",
|
||||
name: "极狐IP(推荐)",
|
||||
versions: [
|
||||
{ id: "v1", label: "尊享版-不限速" },
|
||||
{ id: "v2", label: "高级版-限速6M" },
|
||||
{ id: "v3", label: "普通版-限速2M" },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
id: "1",
|
||||
title: "免费",
|
||||
price: 0,
|
||||
desc: "注册即送3次",
|
||||
card: "测试卡",
|
||||
duration: "1小时",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "首单低至",
|
||||
price: 0.1,
|
||||
originalPrice: 0.1,
|
||||
// card: "测试卡",
|
||||
duration: "24小时",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
tag: "8.5折",
|
||||
originalPrice: 37.5,
|
||||
// card: "周卡",
|
||||
duration: "7天",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "月卡套餐",
|
||||
price: 78,
|
||||
tag: "8.5折",
|
||||
originalPrice: 80.0,
|
||||
// card: "月卡",
|
||||
duration: "30天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "季卡套餐",
|
||||
price: 208,
|
||||
tag: "8.5折",
|
||||
originalPrice: 220.0,
|
||||
// card: "季卡",
|
||||
duration: "90天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "年卡套餐",
|
||||
price: 770,
|
||||
tag: "8.5折",
|
||||
originalPrice: 800.0,
|
||||
card: "年卡",
|
||||
duration: "365天",
|
||||
version: "v3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "aurora",
|
||||
name: "极光IP(推荐)",
|
||||
versions: [
|
||||
{ id: "v1", label: "尊享版-不限速" },
|
||||
{ id: "v2", label: "高级版-限速6M" },
|
||||
{ id: "v3", label: "普通版-限速2M" },
|
||||
],
|
||||
products: [
|
||||
{
|
||||
id: "7",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
originalPrice: 37.5,
|
||||
card: "周卡",
|
||||
duration: "7天",
|
||||
version: "v1",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
title: "月卡套餐",
|
||||
price: 78,
|
||||
tag: "8.5折",
|
||||
originalPrice: 80.0,
|
||||
card: "月卡",
|
||||
duration: "30天",
|
||||
version: "v2",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
title: "季卡套餐",
|
||||
price: 208,
|
||||
tag: "8.5折",
|
||||
originalPrice: 220.0,
|
||||
card: "季卡",
|
||||
duration: "90天",
|
||||
version: "v3",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mushroom",
|
||||
name: "蘑菇IP",
|
||||
products: [
|
||||
{
|
||||
id: "10",
|
||||
title: "免费",
|
||||
price: 0,
|
||||
desc: "注册即送3次",
|
||||
card: "测试卡",
|
||||
duration: "1小时",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
title: "首单低至",
|
||||
price: 0.1,
|
||||
originalPrice: 0.1,
|
||||
// card: "测试卡",
|
||||
duration: "24小时",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
originalPrice: 37.5,
|
||||
// card: "周卡",
|
||||
duration: "7天",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "kylin",
|
||||
name: "麒麟IP",
|
||||
products: [
|
||||
{
|
||||
id: "13",
|
||||
title: "测试卡",
|
||||
price: 0.1,
|
||||
card: "测试卡",
|
||||
duration: "1小时",
|
||||
},
|
||||
{
|
||||
id: "14",
|
||||
title: "24小时套餐",
|
||||
price: 37.5,
|
||||
originalPrice: 37.5,
|
||||
card: "24小时卡",
|
||||
duration: "24小时",
|
||||
},
|
||||
{
|
||||
id: "15",
|
||||
title: "周卡套餐",
|
||||
price: 37.5,
|
||||
originalPrice: 37.5,
|
||||
card: "周卡",
|
||||
duration: "7天",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cheetah",
|
||||
name: "猎豹IP",
|
||||
products: [
|
||||
{
|
||||
id: "16",
|
||||
title: "月卡套餐",
|
||||
price: 78,
|
||||
tag: "8.5折",
|
||||
originalPrice: 80.0,
|
||||
card: "月卡",
|
||||
duration: "30天",
|
||||
},
|
||||
{
|
||||
id: "17",
|
||||
title: "季卡套餐",
|
||||
price: 208,
|
||||
tag: "8.5折",
|
||||
originalPrice: 220.0,
|
||||
card: "季卡",
|
||||
duration: "90天",
|
||||
},
|
||||
{
|
||||
id: "18",
|
||||
title: "年卡套餐",
|
||||
price: 770,
|
||||
tag: "8.5折",
|
||||
originalPrice: 800.0,
|
||||
card: "年卡",
|
||||
duration: "365天",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "info",
|
||||
name: "购买说明",
|
||||
products: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "static",
|
||||
label: "静态IP",
|
||||
brands: [
|
||||
{
|
||||
id: "drip",
|
||||
name: "水滴独享IP",
|
||||
products: [
|
||||
{
|
||||
id: "19",
|
||||
title: "纯住宅池",
|
||||
price: 50,
|
||||
desc: "分散式真实住宅IP",
|
||||
// card: "住宅卡",
|
||||
features: [
|
||||
"带宽充足,IP段优质分布广",
|
||||
"适合对IP要求高的业务",
|
||||
"IP 3-7天周期性变化",
|
||||
"线路切换每天20次",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "20",
|
||||
title: "多拨池",
|
||||
price: 60,
|
||||
desc: "集中拨号住宅,带宽较高",
|
||||
// card: "拨号卡",
|
||||
features: [
|
||||
"IP段较多,稳定性较高",
|
||||
"IP 3-7天周期性变化",
|
||||
"线路切换每天20次",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "21",
|
||||
title: "固定池",
|
||||
price: 70,
|
||||
desc: "天翼云节点",
|
||||
// card: "固定卡",
|
||||
features: ["IP段多IP不变化,稳定性高", "线路切换每日限制为20次"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "single",
|
||||
label: "单窗口单IP",
|
||||
brands: [],
|
||||
},
|
||||
]
|
||||
export type TabSource = {
|
||||
id: string
|
||||
label: string
|
||||
data: ProductItem[]
|
||||
}
|
||||
|
||||
export type ProductItem = {
|
||||
Product: {
|
||||
Id: number
|
||||
Name: string
|
||||
Image: string
|
||||
Sort: number
|
||||
Content: string
|
||||
Profile: string
|
||||
TenantId: number
|
||||
OnLine: number
|
||||
ContentLine: string[]
|
||||
}
|
||||
Packages: PackageItem[]
|
||||
}
|
||||
|
||||
export type PackageItem = {
|
||||
Id: number
|
||||
TenantId: number
|
||||
ProductId: number
|
||||
PackageType: number
|
||||
Name: string
|
||||
Title: string
|
||||
Image: string
|
||||
Status: number
|
||||
Profile: string
|
||||
Price: number
|
||||
LinePrice: number
|
||||
DayPrice: number
|
||||
MinPrice: number
|
||||
DayCount: number
|
||||
OriginKey: string
|
||||
OriginName: string
|
||||
IsTest: number
|
||||
DeleteTag: number
|
||||
}
|
||||
|
||||
function extractTier(name: string): string {
|
||||
const match = name.match(/\((.+?)\)$/)
|
||||
return match ? match[1] : "默认"
|
||||
}
|
||||
|
||||
function transformToBrands(data: ProductItem[]): Brand[] {
|
||||
const activeProducts = data.filter(p => p.Product.OnLine === 1)
|
||||
|
||||
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
||||
|
||||
return activeProducts.map(item => {
|
||||
const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
|
||||
|
||||
const tierMap = new Map<string, PackageItem[]>()
|
||||
for (const pkg of activePackages) {
|
||||
const tier = extractTier(pkg.Name)
|
||||
if (!tierMap.has(tier)) tierMap.set(tier, [])
|
||||
tierMap.get(tier)?.push(pkg)
|
||||
}
|
||||
|
||||
const tiers = Array.from(tierMap.entries())
|
||||
|
||||
const versions: Version[] | undefined =
|
||||
tiers.length > 1
|
||||
? tiers.map(([tier]) => ({ id: tier, label: tier }))
|
||||
: undefined
|
||||
|
||||
const products: Product[] = []
|
||||
for (const [tier, packages] of tiers) {
|
||||
for (const pkg of packages) {
|
||||
products.push({
|
||||
id: pkg.Id.toString(),
|
||||
productId: item.Product.Id,
|
||||
title: pkg.Name,
|
||||
price: pkg.Price,
|
||||
minPrice: pkg.MinPrice,
|
||||
originalPrice: pkg.LinePrice,
|
||||
duration: pkg.Profile,
|
||||
card: pkg.OriginName,
|
||||
version: tier,
|
||||
isTest: pkg.IsTest === 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.Product.Name,
|
||||
name: item.Product.Name,
|
||||
versions,
|
||||
products,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function transformToTabs(sources: TabSource[]): Tab[] {
|
||||
const tabs: Tab[] = []
|
||||
for (const source of sources) {
|
||||
const brands = transformToBrands(source.data)
|
||||
if (brands.length === 0) continue
|
||||
tabs.push({ id: source.id, label: source.label, brands })
|
||||
}
|
||||
|
||||
const firstTab = tabs[0]
|
||||
if (firstTab) {
|
||||
firstTab.brands.push({
|
||||
id: "info",
|
||||
name: "购买须知",
|
||||
products: [],
|
||||
})
|
||||
}
|
||||
|
||||
return tabs
|
||||
}
|
||||
|
||||
30
src/lib/models/record.ts
Normal file
30
src/lib/models/record.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type HttpUsedSummary = {
|
||||
todayShortPackIpUsed: number
|
||||
shortPackIpUsed: number
|
||||
}
|
||||
|
||||
export type HttpUsedLogItem = {
|
||||
packType: number
|
||||
userIp: string
|
||||
ip: string
|
||||
port: number
|
||||
createTime: string
|
||||
}
|
||||
|
||||
export type HttpUsedLogQuery = {
|
||||
page: number
|
||||
limit: number
|
||||
packType: number
|
||||
}
|
||||
|
||||
export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = {
|
||||
12: "储值套餐",
|
||||
21: "短效包天套餐",
|
||||
22: "短效包量套餐",
|
||||
}
|
||||
|
||||
export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22]
|
||||
|
||||
export function formatPackType(packType: number): string {
|
||||
return HTTP_USE_PACK_TYPE_LABELS[packType] ?? String(packType)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import Channels from "@/admin/channels/page"
|
||||
import CouponPage from "@/admin/coupon/page"
|
||||
import Dashboard from "@/admin/dashboard/page"
|
||||
import Funds from "@/admin/funds/page"
|
||||
import HttpRechargePage from "@/admin/httpRecharge/page"
|
||||
import AdminLayout from "@/admin/layout"
|
||||
import LongPage from "@/admin/product/long"
|
||||
import ShortPage from "@/admin/product/short"
|
||||
import OrdersPage from "@/admin/orders/page"
|
||||
import RecordPage from "@/admin/record/page"
|
||||
import RefundOrdersPage from "@/admin/refundOrders/page"
|
||||
import LongPage from "@/admin/resources/long"
|
||||
import ShortPage from "@/admin/resources/short"
|
||||
import RosorderPage from "@/admin/rosorder/page"
|
||||
import Whitelist from "@/admin/whitelist/page"
|
||||
import AuthGuard from "@/components/authGuard"
|
||||
import HomePage from "@/home"
|
||||
@@ -14,6 +20,9 @@ import IpLinePage from "@/home/ipLine"
|
||||
import LayoutPage from "@/home/layout"
|
||||
import LoginPage from "@/home/login"
|
||||
import ProductPage from "@/home/product"
|
||||
import BuyPage from "@/home/product/buy"
|
||||
import HttpPage from "@/home/product/http"
|
||||
import RouterosPage from "@/home/product/routeros"
|
||||
import SoftDownloadPage from "@/home/softDownload"
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -22,6 +31,9 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: "product", element: <ProductPage /> },
|
||||
{ path: "product/buy", element: <BuyPage /> },
|
||||
{ path: "product/http", element: <HttpPage /> },
|
||||
{ path: "product/routeros", element: <RouterosPage /> },
|
||||
{ path: "ipline", element: <IpLinePage /> },
|
||||
{ path: "softdownload", element: <SoftDownloadPage /> },
|
||||
{ path: "help", element: <HelpPage /> },
|
||||
@@ -51,6 +63,14 @@ export const router = createBrowserRouter([
|
||||
{ path: "short", element: <ShortPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "orders", element: <OrdersPage /> },
|
||||
{ path: "refundOrders", element: <RefundOrdersPage /> },
|
||||
{ path: "httpRecharge", element: <HttpRechargePage /> },
|
||||
{ path: "record", element: <RecordPage /> },
|
||||
{ path: "coupon", element: <CouponPage /> },
|
||||
{ path: "resources/long", element: <LongPage /> },
|
||||
{ path: "resources/short", element: <ShortPage /> },
|
||||
{ path: "rosorder", element: <RosorderPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -11,4 +11,19 @@ export default defineConfig({
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://192.168.3.6:5000",
|
||||
"/user": "http://192.168.3.6:5000",
|
||||
"/product": {
|
||||
target: "http://192.168.3.6:5000",
|
||||
bypass(req) {
|
||||
if (req.headers?.accept?.includes("text/html")) {
|
||||
return "/index.html"
|
||||
}
|
||||
},
|
||||
},
|
||||
"/script": "https://php-api.juip.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user