59 Commits

Author SHA1 Message Date
Eamon-meng
49725fd38e 提取IP页面地区筛选数据更新为调用后端接口返回 2026-06-12 16:42:05 +08:00
Eamon-meng
7947fc48a2 帮助中心数据实现动态渲染 2026-06-11 16:30:24 +08:00
Eamon-meng
99039b6622 购买页面提示取消 2026-06-02 13:19:28 +08:00
Eamon-meng
db1acf6f70 购买套餐没有套餐时修复页面展示 2026-06-02 13:17:45 +08:00
Eamon-meng
3a2fbe29fb 修改提取ip时的传参方式 2026-05-22 17:02:19 +08:00
Eamon-meng
5c236c0b01 解决我的账单页面报错 & 提交记录添加套餐号筛选 2026-05-20 13:43:44 +08:00
Eamon-meng
fde097c601 添加seo功能 2026-05-15 16:56:05 +08:00
Eamon-meng
670961c17d 添加获取价格响应异常显示loading & 时间显示到秒 2026-05-14 15:44:31 +08:00
Eamon-meng
84a5e27c05 提取IP添加主机格式选项功能 2026-05-09 13:34:36 +08:00
Eamon-meng
9dea370a87 更改白名单上限 2026-05-06 15:14:33 +08:00
Eamon-meng
602372e58d 套餐页面添加标题字段 2026-04-30 16:57:00 +08:00
Eamon-meng
574ad0e662 购买页面 & IP提取页面样式调整 2026-04-28 18:02:26 +08:00
Eamon-meng
78d916ade1 购买页面调间隙高度 2026-04-28 18:02:25 +08:00
Eamon-meng
6a9e7289b5 购买套餐页面更改字段和添加小字提示 & 添加余额管理页面
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 18:02:23 +08:00
6adcd33943 放开提取接口权限 2026-04-28 18:01:57 +08:00
Eamon-meng
165151b9d2 解决构建报错问题 2026-04-23 14:09:38 +08:00
Eamon-meng
1e76275f04 套餐添加操作列 & 登录后已实名的更新状态显示 &更新脚本的远程仓库地址 2026-04-23 13:43:06 +08:00
Eamon-meng
d9cd5eb41b 发布v1.9.0版本 2026-04-22 13:18:40 +08:00
Eamon-meng
7ff42861f1 套餐管理添加IP检查状态字段 &修复套餐提取和购买的数量显示问题 2026-04-22 10:27:19 +08:00
Eamon-meng
eb4c2d2d5f 暂无可用套餐时取消加载中 & 登录页加返回首页 & 加联系专属客服 & 修改白名单上限10个 2026-04-21 16:51:30 +08:00
a0b0956677 免费与菜单提示文字 2026-04-21 16:19:11 +08:00
Eamon-meng
27e694ee0d 取消关闭支付弹窗调用关闭订单接口 & 添加未实名去支付时的拦截 2026-04-20 16:22:49 +08:00
Eamon-meng
74d53c619d 购买套餐添加count_min字段 2026-04-20 15:36:50 +08:00
8f8def3a87 完善异常跳转 2026-04-20 15:34:49 +08:00
Eamon-meng
ed73d8579f 更新发布v1.7.0 2026-04-18 17:36:22 +08:00
Eamon-meng
e3c61a77e6 修复未登录时提取ip时的套餐显示和不调用获取套餐接口 2026-04-18 17:13:26 +08:00
5f7756199a 修复 sku 缺失显示问题 2026-04-18 15:47:20 +08:00
6aa108e8d3 优化完善套餐购买页面 2026-04-18 15:35:35 +08:00
Eamon-meng
8b65a1745c 修复用户未/授权购买套餐价格计算 & 发布v1.6.0版本 2026-04-16 17:46:11 +08:00
Eamon-meng
5607217625 动态生成购买套餐 & 取消初次进后台修改密码的弹窗 & 添加总折扣字段 & 发布v1.5.0版本 2026-04-16 14:41:42 +08:00
319baea5e8 修复环境变量问题 2026-04-14 11:34:28 +08:00
Eamon-meng
9a8a1826c9 发布v1.4.0版本 2026-04-13 11:33:19 +08:00
Eamon-meng
c2a0310ee5 修复未认证提取ip的接口调用户会话 2026-04-13 10:23:19 +08:00
Eamon-meng
8ee8feb2bf 修改构建脚本路径 2026-04-02 17:56:48 +08:00
Eamon-meng
1e090f5c88 更新配置文件修复构建问题 2026-04-02 17:52:15 +08:00
Eamon-meng
665ce79e1d 发布v1.3.0版本 2026-04-02 17:35:51 +08:00
Eamon-meng
93110954bb 更新订单详情显示字段名称 2026-04-02 14:08:32 +08:00
Eamon-meng
8ce5f99a24 开启充值和余额支付功能 2026-03-31 16:11:47 +08:00
Eamon-meng
e27869fb4a 重新计算价格显示 2026-03-31 16:11:46 +08:00
Eamon-meng
01c4afd209 更新发布v1.2.3版本 2026-03-31 16:11:46 +08:00
2a959fa9cf 优化客户端请求机制 2026-03-31 16:09:43 +08:00
Eamon-meng
d9f267e257 调整桌面端产品订购的菜单栏布局 2026-03-14 18:00:27 +08:00
Eamon-meng
83530d7f1e 修改移动端菜单栏侧边栏布局 2026-03-14 15:25:23 +08:00
Eamon-meng
b2c36196b4 修复实名认证阶段的问题 2026-03-13 18:26:23 +08:00
Eamon-meng
d2d6c1709c 我的套餐新增状态筛选字段 2026-03-13 18:12:22 +08:00
Eamon-meng
a76e61beb0 补充按钮手形样式显示 2026-03-13 14:30:48 +08:00
Eamon-meng
d83ad11241 我的账单页面取消退款和操作列 & 后台Header添加返回首页功能 2026-03-13 14:15:29 +08:00
Eamon-meng
bce7e41adf 修改网站图标显示 & 所以按钮添加手形样式 2026-03-13 14:15:28 +08:00
2b77ea189b 支付组件统一使用二维码展示 2026-03-13 14:13:06 +08:00
Eamon-meng
82bd8051d8 更新发布v1.2.2版本 2026-03-11 17:34:21 +08:00
Eamon-meng
4e27d707ec 手机端支付修改为桌面支付方式 2026-03-11 17:29:29 +08:00
Eamon-meng
32c08d96d4 调整帮助中心移动端文档布局 2026-03-10 17:15:30 +08:00
Eamon-meng
1031630712 更新README.md文档 2026-03-10 17:06:54 +08:00
Eamon-meng
31c26e9636 更新README.md文档项目目录目录结构 2026-03-03 15:47:34 +08:00
Eamon-meng
333bd3f686 更新README.md文档 2026-03-03 14:28:53 +08:00
Eamon-meng
9201a819be 更新README.md文档 2026-03-03 13:23:18 +08:00
Eamon-meng
a2187adb05 新增本地构建脚本 2026-02-27 16:41:38 +08:00
Eamon-meng
4b18c91157 修复修改密码弹窗&取消后台显示客服弹窗 & 取消退出登录profile为空抛异常的判断 2026-02-27 15:03:17 +08:00
Eamon-meng
2125f1ef9e 更新首页文档跳转链接&调整后台分页显示 2026-02-26 16:37:50 +08:00
119 changed files with 4767 additions and 4116 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# 开发环境配置
API_BASE_URL=http://192.168.0.15:8080
CLIENT_ID=web
CLIENT_SECRET=web

View File

@@ -122,7 +122,7 @@ type UserProfile = ExtraResp<typeof getProfile>
## 环境变量 ## 环境变量
需要配置: 需要配置:
- `NEXT_PUBLIC_API_BASE_URL` - 后端 API 地址 - `API_BASE_URL` - 后端 API 地址
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据 - `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
## 部署 ## 部署

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel

View File

@@ -7,4 +7,7 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "vscode.json-language-features" "editor.defaultFormatter": "vscode.json-language-features"
}, },
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
} }

View File

@@ -16,6 +16,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN mv .env.example .env
RUN bun run build RUN bun run build
# 生产阶段 # 生产阶段
@@ -31,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN rm .env
USER nextjs USER nextjs

133
README.md
View File

@@ -6,41 +6,108 @@
购买页固定套餐 购买页固定套餐
优惠问题
### 禁止直接依赖 form
`\[(.*,)?form(,.*)?\]`
### 次要
业务定制页面每月需求用量,可选项需要确认是否合理
页头高度降低
帮助中心文档优化
考虑重新组织导航栏
- 产品购买
- 提取 IP
- 业务场景
- 帮助中心
- 大客户定制
购买与提取手机端优化,尽量一页展示全部
全部替换封装时间范围组件,检查结束时间字段手机端适配问题(需要尾部对齐)
迁移到 tanstack form
页尾链接完善跳转地址
树组件优化 树组件优化
### 架构改进 ## 目录结构
考虑使用 swr 或 react query 来代替直接的服务端 react cache 缓存以及客户端 zustand 缓存,以将服务端请求的数据能够水合到客户端,避免重复请求 ```
├── node_modules/ # 项目依赖包
├── public/ # 静态资源(如 favicon、图片等可直接通过根路径访问
├── src/ # 源代码目录
│ ├── actions/ # 服务端操作或 API 逻辑(如 Server Actions
│ ├── app/ # Next.js 13+ App Router 目录,存放页面、布局、路由等
| | ├── (api)/ # API 路由目录
| | ├── (auth)/ # 认证相关页面
| | ├── (home)/ # 首页模块
| | ├── admin/ # 管理后台模块
| | ├── effects.tsx # 全局副作用/状态管理(如 Redux、SWR 初始化)
| | ├── favicon.ico # 网站图标
| | ├── globals.css # 全局样式
| | └── layout.tsx # 根布局组件(所有页面共享的布局结构)
| |
│ ├── assets/ # 项目资源文件(如图片、字体、样式等)
│ ├── components/ # 可复用的 React 组件
│ ├── lib/ # 工具函数、配置、服务等
│ ├── mdx-components.tsx # MDX 组件配置
│ └── proxy.ts # 代理配置(如 API 代理)
├── .dockerignore # Docker 忽略文件
├── .env # 环境变量文件
├── .gitignore # Git 忽略文件
├── .npmrc # npm 配置
├── bun.lock # Bun 包管理器锁文件
├── components.json # 组件库配置(如 shadcn/ui
├── Dockerfile # Docker 构建配置
├── eslint.config.mjs # ESLint 代码检查配置
├── next-env.d.ts # Next.js 类型声明
├── next.config.ts # Next.js 配置文件
├── package.json # 项目依赖和脚本
├── postcss.config.mjs # PostCSS 配置
├── publish.ps1 # PowerShell 发布脚本
├── README.md # 项目说明文档
└── tsconfig.json # TypeScript 配置
```
### 需要确认 ## 技术栈
| 类别 | 场景/库名 | 推荐方案/用途 |
| :--- | :--- | :--- |
| **状态管理** | 简单跨组件通信 | React Context |
| | 复杂全局状态 | Zustand + persist |
| | 服务端状态 | TanStack Query |
| | 表单状态 | React Hook Form |
| | 路由状态 | Next.js 内置 (useSearchParams, useParams) |
| **核心框架** | Next.js | 服务框架 (React 全栈框架) |
| **UI / 样式体系** | shadcn/ui | UI 组件库 |
| | Radix UI | 无样式基础 UI 组件原语 |
| | Tailwind CSS | CSS 框架 (原子化 CSS) |
| | lucide-react | 图标库 |
| **表单与数据验证** | React Hook Form | 表单状态管理及验证 |
| | Zod | 数据验证与类型推断 |
| **数据管理与通信** | Zustand | 全局状态管理库 |
| | TanStack Query | 服务端状态管理 (数据请求、缓存) |
| | TanStack Table | 无头 UI 表格库 |
| **图表可视化** | Recharts | 图表库 |
| **工具库** | date-fns | 日期时间处理库 |
| | qrcode | QR 码生成库 |
页面内操作是否需要关联到 url 上,以在使用后退功能时返回到上一次操作 ## 搭建开发环境
项目基于 bun 运行bun 是一个 typescript 原生的运行时环境,用于代替 nodejs可以带来更高的性能提升。
1. 拉取项目:`git clone https://43.226.58.254:53000/lanhu/web`
2. 安装依赖包:`bun install`
3. 创建环境变量文件 .env复制 .env.example 中的内容到 .env并根据实际情况修改
4. 运行项目 `bun run dev`
## 构建项目 & 版本管理
1. 在 package.json 文件中修改版本号
2. 构建并上传镜像, 终端运行 `./publish.ps1 <版本号>`
3. 终端执行成功后在 `https://43.226.58.254:53000/lanhu/web` 发布最新版本
生产环境的项目部署通过单独的部署脚本进行管理,前端开发上线只需要构建以及发布版本,无需考虑部署问题。
## 开发规范 & 主要业务逻辑
原型图https://lanhuapp.com/link/#/invite?sid=lxgnSyga
### 快速创建前台新页面
wrap.tsx :新页面间距复用组件
page.tsx新页面统一布局
stores共享状态组件
### 数据流与状态管理: 组件间通信方式
- 通信方式Props 父传子 / 回调函数 子传父 / Context 跨组件 useContext() / Zustand Store 全局状态管理useStore() / URL 参数 页面间状态共享 useSearchParams()
路由与导航: 路由使用的Next.js App Router 架构 Server Actions 实现。
### 认证与权限控制:登录流程-路由守卫(中间件)
### 支付流程实现: 支付二维码生成
### SSE 支付状态监听
## 注意事项
- 图片使用 Next.js Image 组件自动优化
- 动态导入 (next/dynamic) 实现纯客户端组件

View File

@@ -35,6 +35,7 @@
"lucide-react": "^0.479.0", "lucide-react": "^0.479.0",
"next": "^16.0.10", "next": "^16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"photoswipe": "^5.4.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
@@ -45,6 +46,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76", "zod": "^3.25.76",
"zustand": "^5.0.9", "zustand": "^5.0.9",
}, },
@@ -1163,6 +1165,8 @@
"path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"photoswipe": ["photoswipe@5.4.4", "https://registry.npmmirror.com/photoswipe/-/photoswipe-5.4.4.tgz", {}, "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -1399,6 +1403,8 @@
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vaul": ["vaul@1.1.2", "https://registry.npmmirror.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],

View File

@@ -1,6 +1,6 @@
{ {
"name": "lanhu-web", "name": "lanhu-web",
"version": "1.1.1", "version": "1.13.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack", "dev": "next dev -H 0.0.0.0 --turbopack",
@@ -41,6 +41,7 @@
"lucide-react": "^0.479.0", "lucide-react": "^0.479.0",
"next": "^16.0.10", "next": "^16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"photoswipe": "^5.4.4",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "8.10.1", "react-day-picker": "8.10.1",
@@ -51,6 +52,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76", "zod": "^3.25.76",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

16
publish.ps1 Normal file
View File

@@ -0,0 +1,16 @@
if (-not $args) {
Write-Error "需要指定版本号"
exit 1
}
$confrim = Read-Host "构建版本为 [web:$($args[0])],是否继续?(y/n)"
if ($confrim -ne "y") {
Write-Host "已取消构建"
exit 0
}
docker build -t repo.lanhuip.com/lanhu/web:latest .
docker build -t repo.lanhuip.com/lanhu/web:$($args[0]) .
docker push repo.lanhuip.com/lanhu/web:latest
docker push repo.lanhuip.com/lanhu/web:$($args[0])

12
src/actions/article.ts Normal file
View File

@@ -0,0 +1,12 @@
'use server'
import {callByDevice} from './base'
import {ArticleDetail, ArticleNavGroup} from '@/lib/models/article'
export async function getArticleNav(params: {}) {
return await callByDevice<ArticleNavGroup[]>('/api/article/nav', params)
}
export async function getArticleDetail(params: {id: number}) {
return await callByDevice<ArticleDetail>('/api/article/get', params)
}

View File

@@ -2,15 +2,7 @@
import {cookies} from 'next/headers' import {cookies} from 'next/headers'
import {ApiResponse, UnauthorizedError} from '@/lib/api' import {ApiResponse, UnauthorizedError} from '@/lib/api'
import {User} from '@/lib/models' import {User} from '@/lib/models'
import {callByDevice, callByUser} from '@/actions/base' import {callByDevice, callByUser, TokenResp} from '@/actions/base'
type TokenResp = {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope?: string
}
export type LoginMode = 'phone_code' | 'password' export type LoginMode = 'phone_code' | 'password'

14
src/actions/balance.ts Normal file
View File

@@ -0,0 +1,14 @@
'use server'
import {Balance} from '@/lib/models'
import {callByUser} from '@/actions/base'
import {PageRecord} from '@/lib/api'
export async function listBalances(params: {
page?: number
size?: number
bill_no?: string
created_at_start?: Date
created_at_end?: Date
}) {
return await callByUser<PageRecord<Balance>>('/api/balance/page', params)
}

View File

@@ -1,8 +1,24 @@
'use server' 'use server'
import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api' import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api'
import {add, isBefore} from 'date-fns'
import {cookies, headers} from 'next/headers' import {cookies, headers} from 'next/headers'
import {cache} from 'react'
import {redirect} from 'next/navigation' import {redirect} from 'next/navigation'
import {cache} from 'react'
export type TokenResp = {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope?: string
}
export async function getApiUrl() {
return {
success: true,
data: API_BASE_URL,
} satisfies ApiResponse<string>
}
// ====================== // ======================
// public // public
@@ -26,6 +42,9 @@ const _callPublic = cache(async <R = undefined>(
// device // device
// ====================== // ======================
let token: string | null = null
let token_expire: Date | null = null
async function callByDevice<R = undefined>( async function callByDevice<R = undefined>(
endpoint: string, endpoint: string,
data: unknown, data: unknown,
@@ -37,18 +56,20 @@ const _callByDevice = cache(async <R = undefined>(
endpoint: string, endpoint: string,
data?: string, data?: string,
): Promise<ApiResponse<R>> => { ): Promise<ApiResponse<R>> => {
// 获取设备令牌 if (!token || !token_expire || isBefore(token_expire, new Date())) {
if (!CLIENT_ID || !CLIENT_SECRET) { const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
return { const resp = await call<TokenResp>(`${API_BASE_URL}/api/auth/token`, JSON.stringify({
success: false, grant_type: 'client_credentials',
status: 401, }), `Basic ${basic}`)
message: '未配置 CLIENT_ID 或 CLIENT_SECRET', if (!resp.success) {
return resp
} }
token = resp.data.access_token
token_expire = add(new Date(), {seconds: resp.data.expires_in})
} }
const token = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
// 发起请求 // 发起请求
return call(`${API_BASE_URL}${endpoint}`, data, `Basic ${token}`) return call(`${API_BASE_URL}${endpoint}`, data, `Bearer ${token}`)
}) })
// ====================== // ======================
@@ -86,7 +107,6 @@ const _callByUser = cache(async <R = undefined>(
// ====================== // ======================
async function call<R = undefined>(url: string, body: RequestInit['body'], auth?: string): Promise<ApiResponse<R>> { async function call<R = undefined>(url: string, body: RequestInit['body'], auth?: string): Promise<ApiResponse<R>> {
let response: Response
try { try {
const reqHeaders = await headers() const reqHeaders = await headers()
const reqIP = reqHeaders.get('x-forwarded-for') const reqIP = reqHeaders.get('x-forwarded-for')
@@ -98,73 +118,59 @@ async function call<R = undefined>(url: string, body: RequestInit['body'], auth?
if (reqIP) callHeaders['X-Forwarded-For'] = reqIP if (reqIP) callHeaders['X-Forwarded-For'] = reqIP
if (reqUA) callHeaders['User-Agent'] = reqUA if (reqUA) callHeaders['User-Agent'] = reqUA
response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: callHeaders, headers: callHeaders,
body, body,
}) })
if (response.status === 401) {
return redirect('/login?redirect=' + encodeURIComponent(url.replace(API_BASE_URL, '')))
}
const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('text/plain') !== -1) {
const text = await response.text()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, text)
return {
success: false,
status: response.status,
message: text || '请求失败',
}
}
if (!!text?.trim()?.length) {
console.log('未处理的响应成功', `type=text`, `text=${text}`)
}
return {
success: true,
data: undefined as R, // 强转类型,考虑优化
}
}
else if (type.indexOf('application/json') !== -1) {
const json = await response.json()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || json.error_description || '请求失败', // 业务错误message或者 oauth 错误error_description
}
}
return {
success: true,
data: json,
}
}
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
} }
catch (e) { catch (e) {
console.error('后端请求失败', url, (e as Error).message) console.error('后端请求失败', url, (e as Error).message)
throw new Error(`请求失败,网络错误`) throw new Error(`请求失败,网络错误`)
} }
const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('text/plain') !== -1) {
const text = await response.text()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, text)
return {
success: false,
status: response.status,
message: text || '请求失败',
}
}
if (!!text?.trim()?.length) {
console.log('未处理的响应成功', `type=text`, `text=${text}`)
}
return {
success: true,
data: undefined as R, // 强转类型,考虑优化
}
}
else if (type.indexOf('application/json') !== -1) {
const json = await response.json()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || json.error_description || '请求失败', // 业务错误message或者 oauth 错误error_description
}
}
return {
success: true,
data: json,
}
}
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
}
async function postCall<R = undefined>(rawResp: Promise<ApiResponse<R>>) {
const header = await headers()
const pathname = header.get('x-pathname') || '/'
const resp = await rawResp
// 重定向到登录页
const match = [
RegExp(`^/admin.*`),
].some(item => item.test(pathname))
if (match && !resp.success && resp.status === 401) {
console.log('🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗')
redirect('/login?force=true')
}
return resp
} }
// 导出 // 导出

View File

@@ -3,6 +3,11 @@ import {PageRecord} from '@/lib/api'
import {Batch} from '@/lib/models/batch' import {Batch} from '@/lib/models/batch'
import {callByUser} from './base' import {callByUser} from './base'
export async function pageBatch(props: {page: number, size: number}) { export async function pageBatch(props: {
page: number
size: number
time_start?: Date
time_end?: Date
resource_no?: string}) {
return callByUser<PageRecord<Batch>>('/api/batch/page', props) return callByUser<PageRecord<Batch>>('/api/batch/page', props)
} }

View File

@@ -17,6 +17,7 @@ export async function listChannels(props: {
} }
type CreateChannelsResp = { type CreateChannelsResp = {
ip: string
host: string host: string
port: string port: string
username?: string username?: string
@@ -28,9 +29,23 @@ export async function createChannels(params: {
protocol: number protocol: number
auth_type: number auth_type: number
count: number count: number
prov?: string // prov?: string
city?: string area_id?: number
isp?: number isp?: number
host_format?: number
}) { }) {
return callPublic<CreateChannelsResp[]>('/api/channel/create', params) return callPublic<CreateChannelsResp[]>('/api/channel/create', params)
} }
export async function createChannelsV3(params: {
resource_no: string
protocol: number
auth_type: number
count: number
// prov?: string
area_id?: number
isp?: number
host_format?: number
}) {
return callPublic<CreateChannelsResp[]>('/api/channel/create/v3', params)
}

12
src/actions/product.ts Normal file
View File

@@ -0,0 +1,12 @@
import {callByUser, callPublic} from './base'
import {Product} from '@/lib/models/product'
export type ProductItem = Product
export async function listProduct(props: {}) {
return callByUser<Product[]>('/api/product/list', props)
}
export async function listProductHome(props: {}) {
return callPublic<Product[]>('/api/product/list', props)
}

View File

@@ -24,6 +24,7 @@ export async function listResourceShort(props: {
size: number size: number
resource_no?: string resource_no?: string
type?: number type?: number
status?: number
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -37,6 +38,7 @@ export async function listResourceLong(props: {
size: number size: number
resource_no?: string resource_no?: string
type?: number type?: number
status?: number
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -87,9 +89,28 @@ export async function payClose(props: {
} }
export async function getPrice(props: CreateResourceReq) { export async function getPrice(props: CreateResourceReq) {
return callByDevice<{ return callByUser<{
price: string price: string
discounted_price?: string actual?: string
discounted?: number discounted?: string
}>('/api/resource/price', props) }>('/api/resource/price', props)
} }
export async function getPriceHome(props: CreateResourceReq) {
return callByDevice<{
price: string
actual?: string
discounted?: string
}>('/api/resource/price', props)
}
export async function updateCheckip(props: {
id: number
checkip: boolean
}) {
return callByUser('/api/resource/update/checkip', props)
}
export async function getAreaList(props: {}) {
return callByUser('/api/area/list', props)
}

View File

@@ -38,16 +38,16 @@ export async function Identify(props: {
} }
export async function update(props: { export async function update(props: {
username: string username?: string
email: string email?: string
contact_qq: string contact_qq?: string
contact_wechat: string contact_wechat?: string
}) { }) {
return await callByUser('/api/user/update', props) return await callByUser('/api/user/update', props)
} }
export async function updatePassword(props: { export async function updatePassword(props: {
phone: string // phone: string
code: string code: string
password: string password: string
}) { }) {

View File

@@ -1,6 +1,6 @@
'use server' 'use server'
import {ApiResponse} from '@/lib/api' import {ApiResponse} from '@/lib/api'
import {callByDevice} from '@/actions/base' import {callByDevice, callByUser} from '@/actions/base'
import {getCap} from '@/lib/cap' import {getCap} from '@/lib/cap'
export async function sendSMS(props: { export async function sendSMS(props: {
@@ -28,7 +28,7 @@ export async function sendSMS(props: {
} }
// 请求发送短信 // 请求发送短信
return await callByDevice('/api/auth/verify/sms', { return await callByDevice('/api/verify/sms', {
phone: props.phone, phone: props.phone,
purpose: 0, purpose: 0,
}) })
@@ -38,3 +38,35 @@ export async function sendSMS(props: {
throw new Error('验证码验证失败', {cause: error}) throw new Error('验证码验证失败', {cause: error})
} }
} }
export async function updateSendSMS(props: {
captcha: string
}): Promise<ApiResponse> {
try {
// 人机验证
if (!props.captcha?.length) {
return {
success: false,
status: 400,
message: '请输入验证码',
}
}
const cap = await getCap()
const valid = await cap.validateToken(props.captcha)
if (!valid) {
return {
success: false,
status: 400,
message: '验证码错误或已过期',
}
}
// 请求发送短信
return await callByUser('/api/verify/sms/password', {})
}
catch (error) {
console.error('验证码验证失败:', error)
throw new Error('验证码验证失败', {cause: error})
}
}

View File

@@ -1,11 +1,13 @@
import {NextRequest, NextResponse} from 'next/server' import {NextRequest, NextResponse} from 'next/server'
import {createChannels} from '@/actions/channel' import {createChannels, createChannelsV3} from '@/actions/channel'
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const params = req.nextUrl.searchParams const params = req.nextUrl.searchParams
try { try {
const resource_id = params.get('i') const resourceParam = params.get('i')
if (!resource_id) {
if (!resourceParam) {
throw new Error('需要指定资源ID') throw new Error('需要指定资源ID')
} }
let protocol = params.get('x') let protocol = params.get('x')
@@ -20,19 +22,50 @@ export async function GET(req: NextRequest) {
if (!count) { if (!count) {
throw new Error('需要指定通道创建数量') throw new Error('需要指定通道创建数量')
} }
const prov = params.get('a') || undefined // const prov = params.get('a') || undefined
const city = params.get('b') || undefined const area_id = params.get('b') || undefined
const isp = params.get('s') || undefined const isp = params.get('s') || undefined
const hostFormat = params.get('rh') || 'domain'
const isNumeric = /^\d+$/.test(resourceParam)
let result
if (!isNumeric) {
console.log(area_id, 'area_id', params.get('b'), 'params.get')
result = await createChannelsV3({
resource_no: resourceParam,
auth_type: Number(auth_type),
protocol: Number(protocol),
count: Number(count),
// prov,
area_id: Number(area_id),
isp: Number(isp),
host_format: hostFormat === 'domain' ? 1 : 2,
})
console.log({
resource_no: resourceParam,
auth_type: Number(auth_type),
protocol: Number(protocol),
count: Number(count),
// prov,
area_id: Number(area_id),
isp: Number(isp),
host_format: hostFormat === 'domain' ? 1 : 2,
})
}
else {
result = await createChannels({
resource_id: Number(resourceParam),
auth_type: Number(auth_type),
protocol: Number(protocol),
count: Number(count),
// prov,
area_id: Number(area_id),
isp: Number(isp),
host_format: hostFormat === 'domain' ? 1 : 2,
})
}
const result = await createChannels({
resource_id: Number(resource_id),
auth_type: Number(auth_type),
protocol: Number(protocol),
count: Number(count),
prov,
city,
isp: Number(isp),
})
if (!result.success) { if (!result.success) {
throw new Error(result.message) throw new Error(result.message)
} }
@@ -46,10 +79,32 @@ export async function GET(req: NextRequest) {
switch (format) { switch (format) {
case 'json': case 'json':
return NextResponse.json(result.data) if (hostFormat === 'domain') {
const domainFormatData = result.data.map(item => ({
host: item.host,
port: item.port,
...(item.username && item.password ? {username: item.username, password: item.password} : {}),
}))
return NextResponse.json(domainFormatData)
}
else {
const ipFormatData = result.data.map(item => ({
ip: item.ip,
port: item.port,
...(item.username && item.password ? {username: item.username, password: item.password} : {}),
}))
return NextResponse.json(ipFormatData)
}
case 'text': case 'text':
const text = result.data.map((item) => { const text = result.data.map((item) => {
const list = [item.host, item.port] let hostValue: string
if (hostFormat === 'domain') {
hostValue = item.host
}
else {
hostValue = item.ip
}
const list = [hostValue, String(item.port)]
if (item.username && item.password) { if (item.username && item.password) {
list.push(item.username) list.push(item.username)
list.push(item.password) list.push(item.password)

View File

@@ -6,7 +6,7 @@ import {Card, CardContent} from '@/components/ui/card'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Label} from '@/components/ui/label' import {Label} from '@/components/ui/label'
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs' import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {EyeClosedIcon, EyeIcon} from 'lucide-react' import {EyeClosedIcon, EyeIcon, HomeIcon} from 'lucide-react'
import {useState, ReactNode, useEffect, Suspense} from 'react' import {useState, ReactNode, useEffect, Suspense} from 'react'
import zod from 'zod' import zod from 'zod'
import {useForm, useFormContext, useWatch} from 'react-hook-form' import {useForm, useFormContext, useWatch} from 'react-hook-form'
@@ -16,6 +16,7 @@ import {useRouter} from 'next/navigation'
import {login, LoginMode} from '@/actions/auth' import {login, LoginMode} from '@/actions/auth'
import {useProfileStore} from '@/components/stores/profile' import {useProfileStore} from '@/components/stores/profile'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link'
const smsSchema = zod.object({ const smsSchema = zod.object({
username: zod.string().length(11, '请输入正确的手机号码'), username: zod.string().length(11, '请输入正确的手机号码'),
@@ -34,20 +35,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard() { export default function LoginCard() {
const router = useRouter() const router = useRouter()
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const [mode, setMode] = useState<LoginMode>('phone_code') const [mode, setMode] = useState<LoginMode>('password')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const updateLoginMode = (mode: LoginMode) => { const updateLoginMode = (mode: LoginMode) => {
sessionStorage.setItem('login_mode', mode) sessionStorage.setItem('login_mode', mode)
} }
useEffect(() => {
const mode = sessionStorage.getItem('login_mode')
if (mode) {
setMode(mode as LoginMode)
}
}, [])
const form = useForm<LoginSchema>({ const form = useForm<LoginSchema>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema), resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: { defaultValues: {
@@ -55,7 +48,16 @@ export default function LoginCard() {
password: '', password: '',
remember: false, remember: false,
}, },
mode: 'onChange',
}) })
useEffect(() => {
const savedMode = sessionStorage.getItem('login_mode') as LoginMode
if (savedMode && savedMode === 'phone_code') {
setMode(savedMode)
}
}, [])
const handler = form.handleSubmit(async (data) => { const handler = form.handleSubmit(async (data) => {
setSubmitting(true) setSubmitting(true)
try { try {
@@ -87,107 +89,122 @@ export default function LoginCard() {
const [showPwd, setShowPwd] = useState(false) const [showPwd, setShowPwd] = useState(false)
return ( return (
<Card className="w-96 mx-4 shadow-lg relative z-20 py-8"> <div className="relative flex flex-col items-center">
<CardContent className="px-8"> <div className="relative w-96 mx-4">
{/* 登录方式切换 */} <Link
<Tabs href="/"
value={mode} className="absolute -top-8 right-0 inline-flex items-center text-sm transition-colors px-10"
onValueChange={(val) => { >
setMode(val as typeof mode) <HomeIcon size={18} className="mr-1"/>
form.reset({username: form.getValues('username'), password: '', remember: false})
}} </Link>
className="mb-6"> </div>
<TabsList className="w-full p-0 bg-white"> <Card className="w-96 mx-4 shadow-lg relative z-20 py-8">
<Tab value="password"></Tab> <CardContent className="px-8">
<Tab value="phone_code"></Tab> {/* 登录方式切换 */}
</TabsList> <Tabs
</Tabs> value={mode}
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}> onValueChange={(val) => {
<FormField name="username" label={mode === 'phone_code' ? '手机号' : '用户名'}> setMode(val as LoginMode)
{({id, field}) => ( form.reset({username: '', password: '', remember: false})
<Input form.clearErrors()
{...field} }}
id={id} className="mb-6">
type="tel" <TabsList className="w-full p-0 bg-white">
placeholder={mode === 'phone_code' ? '请输入手机号' : '请输入用户名/手机号/邮箱'} <Tab value="password"></Tab>
autoComplete="tel-national" <Tab value="phone_code">/</Tab>
/> </TabsList>
)} </Tabs>
</FormField> <Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
<FormField name="password" label={mode === 'phone_code' ? '验证码' : '密码'}> <FormField name="username" label={mode === 'phone_code' ? '手机号' : '用户名'}>
{({id, field}) => {({id, field}) => (
mode === 'phone_code' ? ( <Input
<div className="flex space-x-4"> {...field}
<Input
{...field}
id={id}
className="h-10"
placeholder="请输入验证码"
autoComplete="one-time-code"
/>
<SendMsgByUsername/>
</div>
) : (
<div className="relative">
<Input
{...field}
id={id}
type={showPwd ? 'text' : 'password'}
className="h-10 pr-10"
placeholder="至少6位密码需包含字母和数字"
autoComplete="current-password"
minLength={6}
/>
<button
type="button"
tabIndex={-1}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPwd(v => !v)}
aria-label={showPwd ? '隐藏密码' : '显示密码'}
>
{showPwd ? (
<EyeIcon size={20}/>
) : (
<EyeClosedIcon size={20}/>
)}
</button>
</div>
)
}
</FormField>
<FormField name="remember">
{({id, field}) => (
<div className="flex flex-row items-start space-x-2 space-y-0">
<Checkbox
id={id} id={id}
checked={field.value} type="tel"
onCheckedChange={field.onChange} placeholder={mode === 'phone_code' ? '请输入手机号' : '请输入用户名/手机号/邮箱'}
autoComplete="tel-national"
/> />
<div className="space-y-1 leading-none"> )}
<Label></Label> </FormField>
<FormField name="password" label={mode === 'phone_code' ? '验证码' : '密码'}>
{({id, field}) =>
mode === 'phone_code' ? (
<div className="flex space-x-4">
<Input
{...field}
id={id}
className="h-10"
placeholder="请输入验证码"
autoComplete="one-time-code"
disabled={submitting}
/>
<SendMsgByUsername/>
</div>
) : (
<div className="relative">
<Input
{...field}
id={id}
type={showPwd ? 'text' : 'password'}
className="h-10 pr-10"
placeholder="至少6位密码需包含字母和数字"
autoComplete="current-password"
minLength={6}
disabled={submitting}
/>
<button
type="button"
tabIndex={-1}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
onClick={() => setShowPwd(v => !v)}
aria-label={showPwd ? '隐藏密码' : '显示密码'}
>
{showPwd ? (
<EyeIcon size={20}/>
) : (
<EyeClosedIcon size={20}/>
)}
</button>
</div>
)
}
</FormField>
<FormField name="remember">
{({id, field}) => (
<div className="flex flex-row items-start space-x-2 space-y-0">
<Checkbox
id={id}
checked={field.value}
onCheckedChange={field.onChange}
disabled={submitting}
/>
<div className="space-y-1 leading-none">
<Label></Label>
</div>
</div> </div>
</div> )}
)} </FormField>
</FormField> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-3"> <Button
<Button className="w-full h-12 text-lg"
className="w-full h-12 text-lg" type="submit"
type="submit" theme="gradient"
theme="gradient" disabled={submitting}
disabled={submitting} >
> {submitting ? '登录中...' : (mode === 'phone_code' ? '首次登录即注册' : '立即登录')}
{submitting ? '登录中...' : (mode === 'phone_code' ? '首次登录即注册' : '立即登录')} </Button>
</Button> <p className="text-xs text-center text-gray-500">
<p className="text-xs text-center text-gray-500">
<a href="/userAgreement" className="text-blue-600 hover:text-blue-500"></a>
<a href="/userAgreement" className="text-blue-600 hover:text-blue-500"></a>
<a href="/privacyPolicy" className="text-blue-600 hover:text-blue-500"></a>
<a href="/privacyPolicy" className="text-blue-600 hover:text-blue-500"></a> </p>
</p> </div>
</div> </Form>
</Form> </CardContent>
</CardContent> </Card>
</Card> </div>
) )
} }

View File

@@ -0,0 +1,17 @@
import {ReactNode} from 'react'
import {Metadata} from 'next'
export async function generateMetadata(): Promise<Metadata> {
return {
title: '隐私政策',
description: '蓝狐代理隐私政策 - 了解我们如何收集、使用和保护您的个人信息',
openGraph: {
title: '隐私政策',
description: '蓝狐代理隐私政策 - 了解我们如何收集、使用和保护您的个人信息',
},
}
}
export default function PrivacyPolicyLayout({children}: {children: ReactNode}) {
return children
}

View File

@@ -0,0 +1,17 @@
import {ReactNode} from 'react'
import {Metadata} from 'next'
export async function generateMetadata(): Promise<Metadata> {
return {
title: '用户协议',
description: '蓝狐代理用户服务协议 - 使用服务前请仔细阅读用户协议条款',
openGraph: {
title: '用户协议',
description: '蓝狐代理用户服务协议 - 使用服务前请仔细阅读用户协议条款',
},
}
}
export default function UserAgreementLayout({children}: {children: ReactNode}) {
return children
}

View File

@@ -11,21 +11,21 @@ export function ArticlesSection() {
icon={<BookOpen className="w-12 h-12"/>} icon={<BookOpen className="w-12 h-12"/>}
title="浏览器设置代理教程" title="浏览器设置代理教程"
description="快速上手5分钟学会在浏览器中配置代理服务器" description="快速上手5分钟学会在浏览器中配置代理服务器"
href="/docs/client/browser-proxy" href="/docs/browser-proxy"
/> />
<ArticleCard <ArticleCard
icon={<Smartphone className="w-12 h-12"/>} icon={<Smartphone className="w-12 h-12"/>}
title="Windows10 代理配置" title="Windows10 代理配置"
description="详细图文教程,帮助你在 Windows 系统中设置代理" description="详细图文教程,帮助你在 Windows 系统中设置代理"
href="/docs/client/windows10-proxy" href="/docs/windows10-proxy"
/> />
<ArticleCard <ArticleCard
icon={<HelpCircle className="w-12 h-12"/>} icon={<HelpCircle className="w-12 h-12"/>}
title="常见问题总览" title="常见问题总览"
description="解决使用过程中遇到的各类问题,快速找到答案" description="解决使用过程中遇到的各类问题,快速找到答案"
href="/docs/faqs/faq-general" href="/docs/faq-general"
/> />
</div> </div>
</PageSection> </PageSection>

View File

@@ -28,8 +28,8 @@ export function HeroSection() {
</div> </div>
<FreeTrial className={[ <FreeTrial className={[
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`, `mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg cursor-pointer`,
`bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl`, `bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl cursor-pointer`,
].join(' ')}/> ].join(' ')}/>
</Wrap> </Wrap>
</section> </section>

View File

@@ -1,9 +1,34 @@
import {Metadata} from 'next'
import {siteConfig} from '@/config/site'
import {HeroSection} from './hero-section' import {HeroSection} from './hero-section'
import {StatsSection} from './stats-section' import {StatsSection} from './stats-section'
import {ProductTypesSection} from './product-types-section' import {ProductTypesSection} from './product-types-section'
import {AdvantagesSection} from './advantages-section' import {AdvantagesSection} from './advantages-section'
import {ArticlesSection} from './articles-section' import {ArticlesSection} from './articles-section'
export async function generateMetadata(): Promise<Metadata> {
return {
title: siteConfig.name,
description: siteConfig.description,
openGraph: {
title: siteConfig.name,
description: siteConfig.description,
url: siteConfig.url,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: siteConfig.name,
},
],
},
alternates: {
canonical: siteConfig.url,
},
}
}
export default function Home() { export default function Home() {
return ( return (
<main className="flex flex-col gap-16 lg:gap-32 pb-16 lg:pb-32 bg-white"> <main className="flex flex-col gap-16 lg:gap-32 pb-16 lg:pb-32 bg-white">

View File

@@ -17,8 +17,8 @@ export default function HelpMenu() {
icon={h01} icon={h01}
title="提取 IP" title="提取 IP"
items={[ items={[
{lead: '短效 IP 提取', href: '/collect?type=short'}, {lead: '短效/长效 IP 提取', href: '/collect?type=short'},
{lead: '长效 IP 提取', href: '/collect?type=long'}, // {lead: '长效 IP 提取', href: '/collect?type=long'},
]} ]}
/> />
<Column <Column

View File

@@ -1,31 +1,313 @@
import ProductMenu from './menu-product' 'use client'
import HelpMenu from './menu-help'
import SolutionMenu from './menu-solution' import {useContext, useEffect, useState} from 'react'
import {useRouter} from 'next/navigation'
import {X} from 'lucide-react'
import {HeaderContext} from './common'
import Image, {StaticImageData} from 'next/image'
import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg'
import s01 from '@/assets/header/solution/01.svg'
import s02 from '@/assets/header/solution/02.svg'
import s03 from '@/assets/header/solution/03.svg'
import s04 from '@/assets/header/solution/04.svg'
import s05 from '@/assets/header/solution/05.svg'
import s06 from '@/assets/header/solution/06.svg'
import s07 from '@/assets/header/solution/07.svg'
import s08 from '@/assets/header/solution/08.svg'
import h01 from '@/assets/header/help/01.svg'
import h02 from '@/assets/header/help/02.svg'
import h03 from '@/assets/header/help/03.svg'
import {merge} from '@/lib/utils'
import Link from 'next/link'
import logo from '@/assets/logo.webp'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type MobileMenuProps = {} export type MobileMenuProps = {}
export default function MobileMenu(props: MobileMenuProps) { export default function MobileMenu(props: MobileMenuProps) {
const ctx = useContext(HeaderContext)
const router = useRouter()
const [productTab, setProductTab] = useState<'domestic' | 'oversea'>('domestic')
if (!ctx) {
throw new Error(`HeaderContext not found`)
}
const navigate = (href: string) => {
ctx.setMenu(false)
router.push(href)
}
const [productList, setProductList] = useState<Product[]>([])
useEffect(() => {
const fetchProducts = async () => {
const res = await listProductHome({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
return ( return (
<div className="flex flex-col gap-8"> <div className="h-full flex flex-col bg-white">
<div className="flex flex-col gap-4"> <div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
<ProductMenu/> {/* logo */}
<Link href="/" className="flex items-center">
<Image src={logo} alt="logo" height={40} className="translate-y-0.5"/>
</Link>
<button
type="button"
className="rounded-md p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-colors"
onClick={() => ctx.setMenu(false)}
aria-label="关闭菜单"
>
<X className="h-5 w-5"/>
</button>
</div> </div>
<div className="flex flex-col gap-4">
<MenuTitle title="帮助中心"/> <div className="flex-1 overflow-y-auto px-4 py-6 space-y-8">
<HelpMenu/> <div className="space-y-3">
</div> <h3 className="text-sm font-semibold text-gray-500 tracking-wide">
<div className="flex flex-col gap-4">
<MenuTitle title="业务场景"/> </h3>
<SolutionMenu/> <div className="flex rounded-lg bg-gray-100">
<button
className={merge(
'flex-1 py-2.5 text-sm font-medium rounded-md transition-all',
productTab === 'domestic'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900',
)}
onClick={() => setProductTab('domestic')}
>
</button>
<button
className={merge(
'flex-1 py-2.5 text-sm font-medium rounded-md transition-all',
productTab === 'oversea'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900',
)}
onClick={() => setProductTab('oversea')}
>
</button>
</div>
{productTab === 'domestic' && (
<div className="space-y-2">
{shortProduct && (
<ProductItem
icon={prod}
label="短效动态IP"
badge="最低4.5折"
href={`/product?type=${shortProduct.code}`}
onNavigate={navigate}
/>
)}
{longProduct && (
<ProductItem
icon={prod}
label="长效静态IP"
badge="最低4.5折"
href={`/product?type=${longProduct.code}`}
onNavigate={navigate}
/>
)}
<ProductItem
icon={custom}
label="优质/企业/精选IP"
badge="专属定制"
href="/custom"
onNavigate={navigate}
/>
</div>
)}
{productTab === 'oversea' && (
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-600 text-center">
线
</p>
</div>
)}
</div>
<MenuSection title="业务场景">
<div className="grid grid-cols-2 gap-3">
<SolutionItem
icon={s01}
label="数据采集"
href="/data-capture"
onNavigate={navigate}
/>
<SolutionItem
icon={s02}
label="电商运营"
href="/e-commerce"
onNavigate={navigate}
/>
<SolutionItem
icon={s03}
label="市场调研"
href="/market-research"
onNavigate={navigate}
/>
<SolutionItem
icon={s04}
label="SEO优化"
href="/seo-optimization"
onNavigate={navigate}
/>
<SolutionItem
icon={s05}
label="社交媒体"
href="/social-media"
onNavigate={navigate}
/>
<SolutionItem
icon={s06}
label="广告投放"
href="/advertising"
onNavigate={navigate}
/>
<SolutionItem
icon={s07}
label="账号管理"
href="/account-management"
onNavigate={navigate}
/>
<SolutionItem
icon={s08}
label="网络测试"
href="/network-testing"
onNavigate={navigate}
/>
</div>
</MenuSection>
<MenuSection title="帮助中心">
<div className="space-y-2">
<HelpItem
icon={h01}
label="短效IP提取"
onClick={() => navigate('/collect?type=short')}
/>
<HelpItem
icon={h02}
label="操作指南"
onClick={() => navigate('/docs/profile-settings')}
/>
<HelpItem
icon={h03}
label="平台教程"
onClick={() => navigate('/docs/ios-proxy')}
/>
</div>
</MenuSection>
<div className="space-y-2 pt-2">
<OtherLink
label="业务定制"
href="/custom"
onNavigate={navigate}
/>
</div>
</div> </div>
</div> </div>
) )
} }
function MenuTitle(props: {title: string}) { function MenuSection(props: {title: string, children: React.ReactNode}) {
return ( return (
<h3 className="text-xl text-weak px-4"> <div className="space-y-3">
{props.title} <h3 className="text-sm font-semibold text-gray-500 tracking-wide">
</h3> {props.title}
</h3>
{props.children}
</div>
)
}
function ProductItem(props: {
icon: StaticImageData
label: string
badge?: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-3 text-left transition-all hover:border-blue-200 hover:shadow-sm"
onClick={() => props.onNavigate(props.href)}
>
<div className="shrink-0 w-8 h-8 bg-linear-to-br from-blue-50 to-cyan-50 rounded-lg flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="flex-1 font-medium text-sm text-gray-900">{props.label}</span>
{props.badge && (
<span className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.badge}
</span>
)}
</button>
)
}
function SolutionItem(props: {
icon: StaticImageData
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-gray-100 hover:border-blue-200 hover:bg-blue-50/50 transition-all"
onClick={() => props.onNavigate(props.href)}
>
<div className="w-10 h-10 bg-linear-to-br from-blue-50 to-cyan-50 rounded-full flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="text-xs font-medium text-gray-700">{props.label}</span>
</button>
)
}
function HelpItem(props: {
icon: StaticImageData
label: string
onClick: () => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors"
onClick={props.onClick}
>
<Image src={props.icon} alt="" width={20} height={20} className="opacity-70"/>
<span className="text-sm text-gray-700">{props.label}</span>
</button>
)
}
function OtherLink(props: {
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center px-3 py-2.5 text-sm text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => props.onNavigate(props.href)}
>
{props.label}
</button>
) )
} }

View File

@@ -1,13 +1,17 @@
'use client' 'use client'
import {ReactNode, useContext, useState} from 'react' import {ReactNode, useContext, useEffect, useState} from 'react'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Image, {StaticImageData} from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import prod from '@/assets/header/product/prod.svg' import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg' import custom from '@/assets/header/product/custom.svg'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {FragmentTitle, HeaderContext} from './common' import {HeaderContext} from './common'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type ProductItem = Product
type TabType = 'domestic' | 'oversea' type TabType = 'domestic' | 'oversea'
export default function ProductMenu() { export default function ProductMenu() {
@@ -52,33 +56,51 @@ export function Tab(props: {
} }
export function Domestic(props: {}) { export function Domestic(props: {}) {
const [productList, setProductList] = useState<Product[]>([])
useEffect(() => {
const fetchProducts = async () => {
const res = await listProductHome({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
return ( return (
<section role="tabpanel" className="flex-auto flex flex-col lg:flex-row justify-evenly gap-3 lg:gap-0"> <section role="tabpanel" className="flex-auto">
<div className="w-full lg:w-64 flex flex-col"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<FragmentTitle img={prod} text="短效 IP"/> <div className="grid grid-cols-1 gap-3">
<DomesticLink {shortProduct && (
label="短效动态 IP" <ProductCard
desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。" icon={prod}
href="/product?type=short" label="短效动态 IP"
discount={45} discount="最低4.5折"
/> desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
</div> href={`/product?type=${shortProduct.code}`}
<div className="w-full lg:w-64 flex flex-col"> />
<FragmentTitle img={prod} text="长效 IP"/> )}
<DomesticLink {longProduct && (
label="长效动态 IP" <ProductCard
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。" icon={prod}
href="/product?type=long" label="长效动态 IP"
discount={45} discount="最低4.5折"
/> desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
</div> href={`/product?type=${longProduct.code}`}
<div className="w-full lg:w-64 flex flex-col"> />
<FragmentTitle img={custom} text="业务定制"/> )}
<DomesticLink </div>
label="优质/企业/精选IP" <div className="flex flex-col gap-3">
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持" <ProductCard
href="/custom" icon={custom}
/> label="业务定制"
discount="1V1 专属服务"
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持"
href="/custom"
/>
</div>
</div> </div>
</section> </section>
) )
@@ -92,11 +114,12 @@ export function Oversea(props: {}) {
) )
} }
export function DomesticLink(props: { export function ProductCard(props: {
icon: StaticImageData
label: string label: string
discount: string
desc: string desc: string
href: string href: string
discount?: number
}) { }) {
const router = useRouter() const router = useRouter()
const ctx = useContext(HeaderContext) const ctx = useContext(HeaderContext)
@@ -116,18 +139,24 @@ export function DomesticLink(props: {
`transition-colors duration-150 ease-in-out`, `transition-colors duration-150 ease-in-out`,
`p-4 rounded-lg flex flex-col gap-1 hover:bg-blue-50`, `p-4 rounded-lg flex flex-col gap-1 hover:bg-blue-50`,
)} )}
onClick={onClick}> onClick={onClick}
<p className="flex gap-2"> >
<span>{props.label}</span> <div className="flex items-start gap-3">
{props.discount && ( <div className="flex-none">
<span className="text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full"> <Image src={props.icon} alt="" width={30} height={30}/>
{props.discount}% </div>
</span> <div className="flex-1">
)} <div className="flex items-center justify-between gap-3">
</p> <span className="font-bold">{props.label}</span>
<p className="text-gray-400 text-sm"> <span className="text-xs font-medium text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.desc} {props.discount}
</p> </span>
</div>
<div className="mt-2 text-sm text-gray-400 space-y-1">
{props.desc}
</div>
</div>
</div>
</Link> </Link>
) )
} }

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/account-management`,
},
}
}
export default function AccountManagementPage() { export default function AccountManagementPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/advertising`,
},
}
}
export default function AdvertisingPage() { export default function AdvertisingPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -1,3 +1,5 @@
import {Metadata} from 'next'
import {siteConfig} from '@/config/site'
import BreadCrumb from '@/components/bread-crumb' import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Extract from '@/components/composites/extract' import Extract from '@/components/composites/extract'
@@ -5,6 +7,28 @@ import HomePage from '@/components/home/page'
export type CollectPageProps = {} export type CollectPageProps = {}
export async function generateMetadata(): Promise<Metadata> {
return {
title: 'IP提取',
description: '短效/长效IP提取高可用性代理IP支持API调用即时获取全国各地代理IP适用于数据采集、网络测试等场景',
openGraph: {
title: 'IP提取',
description: '短效/长效IP提取高可用性代理IP支持API调用即时获取全国各地代理IP',
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: 'IP提取',
},
],
},
alternates: {
canonical: `${siteConfig.url}/collect`,
},
}
}
export default function CollectPage(props: CollectPageProps) { export default function CollectPage(props: CollectPageProps) {
return ( return (
// <main className="mt-20 flex flex-col gap-4"> // <main className="mt-20 flex flex-col gap-4">
@@ -16,7 +40,7 @@ export default function CollectPage(props: CollectPageProps) {
// </Wrap> // </Wrap>
// </main> // </main>
<HomePage path={[ <HomePage path={[
{label: '短效IP 提取', href: '/collect'}, {label: '短效/长效IP 提取', href: '/collect'},
]}> ]}>
<Wrap> <Wrap>
<Extract/> <Extract/>

View File

@@ -0,0 +1,264 @@
'use client'
import {useState} from 'react'
import Image from 'next/image'
import {useRouter} from 'next/navigation'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import {z} from 'zod'
import {toast} from 'sonner'
import HomePage from '@/components/home/page'
import Wrap from '@/components/wrap'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {merge} from '@/lib/utils'
import {submitInquiry} from '@/actions/inquiry'
import group from './_assets/Group.webp'
import SelfDesc from '@/components/features/self-desc'
const formSchema = z.object({
company: z.string().min(2, '企业名称至少2个字符'),
name: z.string().min(2, '联系人姓名至少2个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的11位手机号码'),
usage: z.string().min(1, '请选择您需要的用量'),
purpose: z.string().min(2, '请输入用途说明').max(200, '用途说明不超过200字符'),
})
type FormValues = z.infer<typeof formSchema>
export default function CustomPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
company: '',
name: '',
phone: '',
usage: '',
purpose: '',
},
})
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
try {
const result = await submitInquiry(data)
if (result.success) {
toast.success('提交成功我们的专属顾问会在24小时内联系您')
form.reset()
}
else {
toast.error(result.message || '提交失败,请稍后重试')
}
}
catch (error) {
toast.error('网络错误,请稍后重试')
}
finally {
setIsSubmitting(false)
}
}
const scrollToForm = () => {
const formElement = document.getElementById('inquiry-form')
if (formElement) {
formElement.scrollIntoView({behavior: 'smooth', block: 'start'})
}
}
return (
<HomePage
path={[
{label: '业务定制', href: '/custom'},
]}
>
<Wrap className="flex flex-col gap-16">
{/* 1. 顶部介绍区 */}
<SelfDesc onInquiry={() => {
document.getElementById('inquiry-form')?.scrollIntoView({behavior: 'smooth', block: 'start'})
}}/>
{/* 2. 表单区 */}
<section id="inquiry-form" className="bg-white rounded-lg p-6 lg:p-12">
<div className="text-center mb-8 lg:mb-12">
<h2 className="text-2xl lg:text-3xl font-semibold"></h2>
<p className="text-gray-500 mt-2 text-sm lg:text-base">
24
</p>
</div>
<Form form={form} handler={form.handleSubmit(onSubmit)}>
<div className="mx-auto max-w-2xl space-y-6">
{/* 企业名称 */}
<FormField name="companyName">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入企业名称"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 联系人姓名 */}
<FormField name="contactName">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入联系人姓名"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 联系人手机号码 */}
<FormField name="phone">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
type="tel"
placeholder="请输入11位手机号码"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 每月需求用量 */}
<FormField name="monthlyUsage">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isSubmitting}
>
<SelectTrigger id={id} aria-required="true">
<SelectValue placeholder="请选择您需要的用量"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="less20">20</SelectItem>
<SelectItem value="20-100">20~100</SelectItem>
<SelectItem value="100-500">100~500</SelectItem>
<SelectItem value="more500">500</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</FormField>
{/* 用途 */}
<FormField name="purpose">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入用途,例如:数据采集、市场调研等"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
<div className="pt-4 flex justify-center">
<Button
type="submit"
className="bg-blue-600 hover:bg-blue-700 px-12 py-2.5"
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : '提交'}
</Button>
</div>
</div>
</Form>
</section>
{/* 3. 底部引导区 */}
<section className="relative rounded-lg overflow-hidden h-48 lg:h-56">
<Image
src={group}
alt="立即试用背景"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full max-w-4xl px-6 flex flex-col lg:flex-row items-center gap-4 lg:gap-10 justify-center lg:justify-between">
<div className="text-blue-600 font-bold text-xl lg:text-2xl text-center lg:text-left">
5000IP
</div>
<Button
className={merge(
'bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-md whitespace-nowrap',
)}
onClick={() => router.push('/product')}
>
</Button>
</div>
</div>
</section>
</Wrap>
</HomePage>
)
}

View File

@@ -1,264 +1,27 @@
'use client' import {Metadata} from 'next'
import {useState} from 'react' import {siteConfig} from '@/config/site'
import Image from 'next/image' import CustomPage from './_client'
import {useRouter} from 'next/navigation'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import {z} from 'zod'
import {toast} from 'sonner'
import HomePage from '@/components/home/page'
import Wrap from '@/components/wrap'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {merge} from '@/lib/utils'
import {submitInquiry} from '@/actions/inquiry'
import group from './_assets/Group.webp'
import SelfDesc from '@/components/features/self-desc'
const formSchema = z.object({ export async function generateMetadata(): Promise<Metadata> {
company: z.string().min(2, '企业名称至少2个字符'), return {
name: z.string().min(2, '联系人姓名至少2个字符'), title: '业务定制',
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的11位手机号码'), description: '蓝狐代理为您提供企业级代理IP定制服务专属顾问1对1服务量身打造代理解决方案满足企业个性化需求',
usage: z.string().min(1, '请选择您需要的用量'), openGraph: {
purpose: z.string().min(2, '请输入用途说明').max(200, '用途说明不超过200字符'), title: '业务定制',
}) description: '蓝狐代理为您提供企业级代理IP定制服务专属顾问1对1服务量身打造代理解决方案',
images: [
type FormValues = z.infer<typeof formSchema> {
url: siteConfig.ogImage.url,
export default function CustomPage() { width: siteConfig.ogImage.width,
const router = useRouter() height: siteConfig.ogImage.height,
const [isSubmitting, setIsSubmitting] = useState(false) alt: '业务定制',
},
const form = useForm<FormValues>({ ],
resolver: zodResolver(formSchema), },
defaultValues: { alternates: {
company: '', canonical: `${siteConfig.url}/custom`,
name: '',
phone: '',
usage: '',
purpose: '',
}, },
})
const onSubmit = async (data: FormValues) => {
setIsSubmitting(true)
try {
const result = await submitInquiry(data)
if (result.success) {
toast.success('提交成功我们的专属顾问会在24小时内联系您')
form.reset()
}
else {
toast.error(result.message || '提交失败,请稍后重试')
}
}
catch (error) {
toast.error('网络错误,请稍后重试')
}
finally {
setIsSubmitting(false)
}
} }
const scrollToForm = () => {
const formElement = document.getElementById('inquiry-form')
if (formElement) {
formElement.scrollIntoView({behavior: 'smooth', block: 'start'})
}
}
return (
<HomePage
path={[
{label: '业务定制', href: '/custom'},
]}
>
<Wrap className="flex flex-col gap-16">
{/* 1. 顶部介绍区 */}
<SelfDesc onInquiry={() => {
document.getElementById('inquiry-form')?.scrollIntoView({behavior: 'smooth', block: 'start'})
}}/>
{/* 2. 表单区 */}
<section id="inquiry-form" className="bg-white rounded-lg p-6 lg:p-12">
<div className="text-center mb-8 lg:mb-12">
<h2 className="text-2xl lg:text-3xl font-semibold"></h2>
<p className="text-gray-500 mt-2 text-sm lg:text-base">
24
</p>
</div>
<Form form={form} handler={form.handleSubmit(onSubmit)}>
<div className="mx-auto max-w-2xl space-y-6">
{/* 企业名称 */}
<FormField name="companyName">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入企业名称"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 联系人姓名 */}
<FormField name="contactName">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入联系人姓名"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 联系人手机号码 */}
<FormField name="phone">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
type="tel"
placeholder="请输入11位手机号码"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
{/* 每月需求用量 */}
<FormField name="monthlyUsage">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Select
onValueChange={field.onChange}
value={field.value}
disabled={isSubmitting}
>
<SelectTrigger id={id} aria-required="true">
<SelectValue placeholder="请选择您需要的用量"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="less20">20</SelectItem>
<SelectItem value="20-100">20~100</SelectItem>
<SelectItem value="100-500">100~500</SelectItem>
<SelectItem value="more500">500</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</FormField>
{/* 用途 */}
<FormField name="purpose">
{({id, field}) => (
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-4">
<label
htmlFor={id}
className="flex items-center gap-1 lg:w-32 lg:text-right lg:pt-2 text-sm"
>
<span className="text-red-500">*</span>
<span></span>
</label>
<div className="flex-1 lg:max-w-md">
<Input
{...field}
id={id}
placeholder="请输入用途,例如:数据采集、市场调研等"
disabled={isSubmitting}
aria-required="true"
/>
</div>
</div>
)}
</FormField>
<div className="pt-4 flex justify-center">
<Button
type="submit"
className="bg-blue-600 hover:bg-blue-700 px-12 py-2.5"
disabled={isSubmitting}
>
{isSubmitting ? '提交中...' : '提交'}
</Button>
</div>
</div>
</Form>
</section>
{/* 3. 底部引导区 */}
<section className="relative rounded-lg overflow-hidden h-48 lg:h-56">
<Image
src={group}
alt="免费试用背景"
fill
className="object-cover"
priority
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full max-w-4xl px-6 flex flex-col lg:flex-row items-center gap-4 lg:gap-10 justify-center lg:justify-between">
<div className="text-blue-600 font-bold text-xl lg:text-2xl text-center lg:text-left">
5000IP
</div>
<Button
className={merge(
'bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-md whitespace-nowrap',
)}
onClick={() => router.push('/login')}
>
</Button>
</div>
</div>
</section>
</Wrap>
</HomePage>
)
} }
export default CustomPage

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/data-capture`,
},
}
}
export default function DataCapturePage() { export default function DataCapturePage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -14,7 +14,8 @@
| b | string | 否 | 归属地城市。默认全局随机 | | b | string | 否 | 归属地城市。默认全局随机 |
| s | string | 否 | 归属地运营商。默认全局随机 | | s | string | 否 | 归属地运营商。默认全局随机 |
| d | string | 否 | 是否去重1 - 是0 - 否。默认为是 | | d | string | 否 | 是否去重1 - 是0 - 否。默认为是 |
| rt | string | 否 | 返回类型1 - TXT2 - JSON。默认 TXT | | rt | string | 否 | 返回类型1 - TXT2 - JSON。默认 TXT
| rh | string | 否 | 返回时主机字段的格式1 - 域名2 - IP。默认为域名 |
| rs | number[] | 否 | 返回时要使用的分隔符,值为该字符的 ascii 编码,可以有多个字符,多个字符用半角逗号连接。默认为 13,10即回车 + 换行(\r\n | | rs | number[] | 否 | 返回时要使用的分隔符,值为该字符的 ascii 编码,可以有多个字符,多个字符用半角逗号连接。默认为 13,10即回车 + 换行(\r\n |
| rb | number[] | 否 | 返回时要使用的换行符,值为该字符的 ascii 编码,可以有多个字符,多个字符用半角逗号连接。默认为 124即垂直线 \| | | rb | number[] | 否 | 返回时要使用的换行符,值为该字符的 ascii 编码,可以有多个字符,多个字符用半角逗号连接。默认为 124即垂直线 \| |
| n | number | 否 | 提取数量。默认为 1 | | n | number | 否 | 提取数量。默认为 1 |
@@ -33,11 +34,11 @@
| password | string | 代理服务器密码(仅在认证类型为密码时返回) | | password | string | 代理服务器密码(仅在认证类型为密码时返回) |
## 示例 ## 示例1
### 请求示例 ### 请求示例
```http ```http
GET https://lanhuip.com/api/extract?i=1&t=2&a=广东省&b=广州市&s=移动&d=1&rt=2&n=3 GET https://lanhuip.com/api/extract?i=1&t=2&a=广东省&b=广州市&s=移动&d=1&rt=2&n=3
``` ```
@@ -65,3 +66,36 @@ GET https://lanhuip.com/api/extract?i=1&t=2&a=广东省&b=广州市&s=移动&d=1
} }
] ]
``` ```
## 示例2
### 请求示例
```http
GET https://lanhuip.com/api/extract?i=24&t=1&a=广东省&b=广州市&d=1&rt=text&rh=ip&rs=124&rb=13%2C10&n=1
```
### 响应示例
```json
[
{
"ip": "127.0.0.1",
"port": 20000,
"username": "user1",
"password": "pass1"
},
{
"ip": "127.0.0.1",
"port": 20001,
"username": "user2",
"password": "pass2"
},
{
"ip": "127.0.0.1",
"port": 20002,
"username": "user3",
"password": "pass3"
}
]
```

View File

@@ -0,0 +1,59 @@
'use client'
import {useEffect, useRef} from 'react'
import 'photoswipe/style.css'
export default function ArticleViewer({content}: {content: string}) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const handleClick = async (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.tagName !== 'IMG') return
if (!container.contains(target)) return
const allImages = Array.from(container.querySelectorAll('img'))
const slides: Array<{src: string, width: number, height: number}> = []
let clickedIndex = 0
allImages.forEach((img) => {
const src = img.getAttribute('src')
if (!src) return
if (img === target) clickedIndex = slides.length
slides.push({
src,
width: img.naturalWidth || 1600,
height: img.naturalHeight || 1200,
})
})
if (slides.length === 0) return
const {default: PhotoSwipe} = await import('photoswipe')
const pswp = new PhotoSwipe({
dataSource: slides,
index: clickedIndex,
bgOpacity: 0.85,
spacing: 0.12,
zoom: true,
})
pswp.init()
}
container.addEventListener('click', handleClick)
return () => {
container.removeEventListener('click', handleClick)
}
}, [content])
return (
<div
ref={containerRef}
className="prose prose-slate max-w-none [&_img]:cursor-zoom-in"
dangerouslySetInnerHTML={{__html: content}}
/>
)
}

View File

@@ -0,0 +1,60 @@
import {notFound} from 'next/navigation'
import {Suspense} from 'react'
import {getArticleDetail} from '@/actions/article'
import {formatDate} from '@/lib/utils/date'
import ArticleViewer from './article-viewer'
interface ArticlePageProps {
params: Promise<{groupCode: string, articleId: string}>
}
function ArticleLoadingSkeleton() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6 pb-4 border-b">
<div className="h-8 bg-gray-200 rounded animate-pulse mb-4 w-3/4"/>
<div className="h-4 bg-gray-100 rounded animate-pulse w-1/3"/>
</div>
<div className="space-y-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-4 bg-gray-100 rounded animate-pulse"/>
))}
</div>
</div>
)
}
async function ArticleContent(props: ArticlePageProps) {
const params = await props.params
const resp = await getArticleDetail({id: Number(params.articleId)})
if (!resp.success || !resp.data) {
notFound()
}
const article = resp.data
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6 pb-4 border-b">
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
{article.title}
</h1>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span>{formatDate(article.updated_at, 'YYYY-MM-DD HH:mm:ss')}</span>
</div>
</div>
<ArticleViewer content={article.content || ''}/>
</div>
)
}
export default function ArticlePage(props: ArticlePageProps) {
return (
<Suspense fallback={<ArticleLoadingSkeleton/>}>
<ArticleContent params={props.params}/>
</Suspense>
)
}

View File

@@ -2,15 +2,16 @@ import Wrap from '@/components/wrap'
import {Children} from '@/lib/utils' import {Children} from '@/lib/utils'
import Sidebar from './sidebar' import Sidebar from './sidebar'
import HomePage from '@/components/home/page' import HomePage from '@/components/home/page'
import SidebarDrawer from './sidebar-drawer'
import {Suspense} from 'react'
export default function DocsLayout(props: Children) { export default function DocsLayout(props: Children) {
return ( return (
<HomePage path={[ <HomePage path={[{label: '帮助中心', href: '/docs'}]}>
{label: '帮助中心', href: '/docs'}, <Wrap className="flex gap-3 flex-col md:flex-row">
]}> <SidebarDrawer/>
<Wrap className="flex gap-3"> <Suspense> <Sidebar className="hidden md:block w-68"/></Suspense>
<Sidebar/> <div className="flex-1 bg-white rounded-lg p-4 md:p-6 min-h-[420px]">
<div className="flex-1 bg-white rounded-lg p-6 min-h-[420px]">
{props.children} {props.children}
</div> </div>
</Wrap> </Wrap>

View File

@@ -1,5 +1,33 @@
import {Metadata} from 'next'
import {siteConfig} from '@/config/site'
export async function generateMetadata(): Promise<Metadata> {
return {
title: '帮助中心',
description: '蓝狐代理帮助中心 - 产品使用教程、常见问题解答、行业资讯、代理IP设置指南',
openGraph: {
title: '帮助中心',
description: '蓝狐代理帮助中心 - 产品使用教程、常见问题解答、行业资讯',
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: '帮助中心',
},
],
},
alternates: {
canonical: `${siteConfig.url}/docs`,
},
}
}
export default function DocsIndexPage() { export default function DocsIndexPage() {
return ( return (
<div></div> <div className="text-center text-slate-500 py-12">
<p className="text-lg"></p>
<p className="text-sm mt-2"></p>
</div>
) )
} }

View File

@@ -0,0 +1,37 @@
'use client'
import {useState} from 'react'
import {Menu} from 'lucide-react'
import {
Drawer,
DrawerContent,
DrawerTrigger,
} from '@/components/ui/drawer'
import Sidebar from './sidebar'
export default function SidebarDrawer() {
const [open, setOpen] = useState(false)
return (
<div className="md:hidden flex items-center justify-between bg-white rounded-lg p-3">
<span className="font-medium text-slate-900"></span>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<button className="flex items-center gap-2 text-slate-600 hover:text-slate-900 p-1 cursor-pointer">
<Menu size={20}/>
<span className="text-sm"></span>
</button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<div className="px-4 py-3 border-b">
<h3 className="text-lg font-semibold text-slate-900"></h3>
</div>
<div className="px-2 py-2 max-h-[70vh] overflow-y-auto">
<Sidebar onClose={() => setOpen(false)}/>
</div>
</div>
</DrawerContent>
</Drawer>
</div>
)
}

View File

@@ -1,152 +1,139 @@
'use client' 'use client'
import {useState, useMemo, useCallback} from 'react' import {useState, useEffect, useMemo} from 'react'
import Link from 'next/link' import Link from 'next/link'
import {usePathname} from 'next/navigation' import {usePathname} from 'next/navigation'
import {ChevronRight} from 'lucide-react' import {ChevronRight} from 'lucide-react'
import {merge} from '@/lib/utils'
import {getArticleNav} from '@/actions/article'
import type {ArticleNavGroup} from '@/lib/models/article'
type Props = { type Props = {
collapsed?: boolean className?: string
onClose?: () => void
} }
// 菜单配置 export default function Sidebar({className, onClose}: Props) {
const MENU_ITEMS = [
{
group: '产品文档',
items: [
{key: 'product-overview', label: '产品介绍'},
{key: 'choose-product', label: '如何选择产品'},
{key: 'why-verify', label: '为什么需要实名认证'},
{key: 'city-lines', label: '有哪些城市线路'},
{key: 'api-docs', label: 'ip提取接口文档'},
// 服务条款
],
},
{
group: '操作指南',
items: [
{key: 'profile-settings', label: '修改个人信息和重置密码'},
{key: 'whitelist-guide', label: '如何添加白名单'},
{key: 'verify-guide', label: '如何进行实名认证'},
{key: 'extract-link', label: '如何生成提取链接'},
{key: 'payment-records', label: '查看支付和使用记录'},
],
},
{
group: '客户端教程',
items: [
{key: 'browser-proxy', label: '浏览器设置代理教程'},
{key: 'ios-proxy', label: 'iOS设置代理教程'},
{key: 'android-proxy', label: '安卓手机设置代理教程'},
{key: 'windows10-proxy', label: 'Windows10设置代理教程'},
],
},
{
group: '常见问题',
items: [
{key: 'faq-general', label: '常见问题总览'},
{key: 'faq-billing', label: '计费与套餐问题'},
// 业务场景集成方案
// 故障排查
],
},
{
group: '新闻资讯',
items: [
{key: 'news-latest', label: '了解代理服务器的工作原理'},
{key: 'news-announce', label: '网站公告'},
],
},
]
export default function Sidebar({collapsed = false}: Props) {
const pathname = usePathname() const pathname = usePathname()
const [navGroups, setNavGroups] = useState<ArticleNavGroup[]>([])
const [loading, setLoading] = useState(true)
const [manualExpanded, setManualExpanded] = useState<Record<string, boolean>>({})
// 获取当前文档 key useEffect(() => {
const getCurrentKey = useCallback(() => { const loadNav = async () => {
const parts = pathname?.split('/') || [] const resp = await getArticleNav({})
return parts[2] || '' if (resp.success) {
}, [pathname]) setNavGroups(resp.data || [])
}
setLoading(false)
}
loadNav()
}, [])
const currentKey = getCurrentKey() const parts = pathname?.split('/') || []
const currentArticleId = parts[3]
// 展开/收起状态 const autoExpanded = useMemo(() => {
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
// 初始化:自动展开包含当前活跃项的分组
const initialExpandedGroups = useMemo(() => {
const result: Record<string, boolean> = {} const result: Record<string, boolean> = {}
MENU_ITEMS.forEach((section, index) => {
const hasActive = section.items.some(item => item.key === currentKey) if (navGroups.length === 0) return result
if (hasActive || index === 0) {
result[section.group] = true let activeGroupCode: string | null = null
navGroups.forEach((group) => {
const hasActive = group.articles.some(
article => String(article.id) === currentArticleId,
)
if (hasActive) {
activeGroupCode = group.code
} }
}) })
result[navGroups[0].code] = true
if (activeGroupCode && activeGroupCode !== navGroups[0].code) {
result[activeGroupCode] = true
}
return result return result
}, [currentKey]) }, [navGroups, currentArticleId])
// 合并自动展开和用户手动切换 const expandedGroups = useMemo(() => {
const finalExpandedGroups = useMemo(() => { return {...autoExpanded, ...manualExpanded}
return {...initialExpandedGroups, ...expandedGroups} }, [autoExpanded, manualExpanded])
}, [initialExpandedGroups, expandedGroups])
const toggleGroup = (group: string) => { const toggleGroup = (groupCode: string) => {
setExpandedGroups(prev => ({ setManualExpanded(prev => ({
...prev, ...prev,
[group]: !finalExpandedGroups[group], [groupCode]: !expandedGroups[groupCode],
})) }))
} }
const getItemHref = (key: string) => `/docs/${key}` const getActiveArticleId = () => {
return currentArticleId || ''
}
if (loading) {
return (
<aside className={merge('bg-white rounded-lg p-3', className)}>
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-10 bg-gray-100 rounded animate-pulse"/>
))}
</div>
</aside>
)
}
if (navGroups.length === 0) {
return (
<aside className={merge('bg-white rounded-lg p-3', className)}>
<div className="text-center text-slate-400 py-4">
</div>
</aside>
)
}
return ( return (
<aside <aside className={merge('bg-white rounded-lg p-3 transition-all duration-200 shrink-0', className)}>
className={`bg-white rounded-lg p-3 transition-all duration-200 shrink-0 ${
collapsed ? 'w-20' : 'w-68'
}`}
>
<nav className="space-y-2"> <nav className="space-y-2">
{MENU_ITEMS.map(section => ( {navGroups.map(group => (
<div key={section.group}> <div key={group.code}>
<div <div
onClick={() => toggleGroup(section.group)} onClick={() => toggleGroup(group.code)}
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-sm transition-colors ${ className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-sm transition-colors ${
finalExpandedGroups[section.group] && !collapsed expandedGroups[group.code] && 'bg-blue-50'
? 'bg-blue-50'
: 'hover:bg-slate-50'
}`} }`}
> >
<div <div
className={`w-4 flex items-center justify-center text-sm text-slate-400 transform transition-transform ${ className={`w-4 flex items-center justify-center text-sm text-slate-400 transform transition-transform ${
finalExpandedGroups[section.group] ? 'rotate-90' : '' expandedGroups[group.code] ? 'rotate-90' : ''
}`} }`}
> >
<ChevronRight size={16}/> <ChevronRight size={16}/>
</div> </div>
<div className="text-lg font-semibold text-slate-900">
{!collapsed && ( {group.name}
<div className="text-lg font-semibold text-slate-900"> </div>
{section.group}
</div>
)}
</div> </div>
{finalExpandedGroups[section.group] && ( {expandedGroups[group.code] && (
<ul className={`mt-1 text-base ${collapsed ? 'hidden' : 'block'}`}> <ul className="mt-1 text-base">
{section.items.map((item) => { {group.articles.map((article) => {
const isActive = currentKey === item.key const isActive = getActiveArticleId() === String(article.id)
const href = getItemHref(item.key) const href = `/docs/${group.code}/${article.id}`
return ( return (
<li key={item.key}> <li key={article.id}>
<Link <Link
href={href} href={href}
onClick={() => onClose?.()}
className={`block pl-8 py-2 text-base cursor-pointer transition-colors ${ className={`block pl-8 py-2 text-base cursor-pointer transition-colors ${
isActive isActive
? 'bg-blue-50 font-semibold' ? 'bg-blue-50 font-semibold text-blue-600'
: 'text-slate-700 hover:text-slate-900 hover:bg-slate-50' : 'text-slate-700 hover:text-slate-900 hover:bg-slate-50'
}`} }`}
> >
{item.label} {article.title}
</Link> </Link>
</li> </li>
) )

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/e-commerce`,
},
}
}
export default function ECommercePage() { export default function ECommercePage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -41,7 +41,7 @@ export default function Footer(props: FooterProps) {
items={[ items={[
{name: `产品订购`, href: `/product`}, {name: `产品订购`, href: `/product`},
{name: `获取代理`, href: `/collect`}, {name: `获取代理`, href: `/collect`},
{name: `帮助中心`, href: `/docs/faqs/faq-general`}, {name: `帮助中心`, href: `/docs/faq-general`},
{name: `企业服务`, href: `/custom`}, {name: `企业服务`, href: `/custom`},
]} ]}
/> />
@@ -69,9 +69,9 @@ export default function Footer(props: FooterProps) {
<SiteNavList <SiteNavList
title="帮助文档" title="帮助文档"
items={[ items={[
{name: `产品功能`, href: `/docs/product/product-features`}, {name: `产品功能`, href: `/docs/product-overview`},
{name: `使用教程`, href: `/docs/client/browser-proxy`}, {name: `使用教程`, href: `/docs/browser-proxy`},
{name: `行业资讯`, href: `/docs/news/news-latest`}, {name: `行业资讯`, href: `/docs/news-latest`},
]} ]}
/> />
</div> </div>

View File

@@ -164,10 +164,10 @@ export default function Header(props: HeaderProps) {
</Wrap> </Wrap>
</div> </div>
{/* 下拉菜单 */} {/* 桌面端下拉菜单 */}
<div <div
className={merge( className={merge(
`flex-auto overflow-auto lg:flex-none lg:basis-72 shadow-lg box-content`, `hidden lg:flex flex-auto overflow-auto lg:flex-none lg:basis-72 shadow-lg box-content`,
`bg-[#fffe] backdrop-blur-sm`, `bg-[#fffe] backdrop-blur-sm`,
`transition-[opacity,padding] transition-discrete duration-200 ease-in-out`, `transition-[opacity,padding] transition-discrete duration-200 ease-in-out`,
menu menu
@@ -180,7 +180,20 @@ export default function Header(props: HeaderProps) {
{pages[page]} {pages[page]}
</div> </div>
{/* 遮罩层 */} {/* 移动端侧边栏菜单 */}
{menu && page === 3 && (
<div className="lg:hidden fixed inset-0 z-20 flex">
<div
className="flex-1 bg-black/40"
onPointerDown={enterMenuMask}
/>
<div className="w-72 max-w-[80vw] bg-white shadow-xl overflow-y-auto">
{pages[3]}
</div>
</div>
)}
{/* 遮罩层(桌面端) */}
<div className="flex-auto" onPointerEnter={enterMenuMask}/> <div className="flex-auto" onPointerEnter={enterMenuMask}/>
</HeaderContext.Provider> </HeaderContext.Provider>
@@ -225,7 +238,7 @@ function MenuItem(props: {
onPointerLeave={props.onPointerLeave} onPointerLeave={props.onPointerLeave}
className={[ className={[
`h-full px-4 flex gap-3 items-center cursor-pointer text-lg`, `h-full px-4 flex gap-3 items-center cursor-pointer text-lg`,
`transition-colors duration-200 ease-in-out`, `transition-colors duration-200 ease-in-out cursor-pointer`,
props.active props.active
? `text-blue-500` ? `text-blue-500`
: ``, : ``,
@@ -269,7 +282,7 @@ function ProfileOrLogin() {
<span></span> <span></span>
</Link> </Link>
<Link <Link
href="/login" href="/login?tab=sms"
className={[ className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`, `w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`, `transition-colors duration-200 ease-in-out`,

View File

@@ -1,7 +1,8 @@
import {ReactNode} from 'react' import {ReactNode} from 'react'
import Header from './header' import Header from './header'
import Footer from './footer' import Footer from './footer'
import Script from 'next/script'
import {MessageCircleMoreIcon} from 'lucide-react'
export type HomeLayoutProps = { export type HomeLayoutProps = {
children: ReactNode children: ReactNode
} }
@@ -17,6 +18,22 @@ export default function HomeLayout(props: HomeLayoutProps) {
{/* 页脚 */} {/* 页脚 */}
<Footer/> <Footer/>
{/* <Script id="qd2852138148beb7882a4a6a3e5ff5b569436003e7dc" src="https://wp.qiye.qq.com/qidian/2852138148/beb7882a4a6a3e5ff5b569436003e7dc" async defer></Script> */}
<a
href="https://wpa1.qq.com/K0s0cvwf?_type=wpa&qidian=true"
target="_blank"
rel="noopener noreferrer"
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-blue-600 hover:bg-blue-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 flex items-center justify-center group"
aria-label="在线客服"
>
<span className="text-white font-bold text-lg"></span>
<span className="text-white font-bold text-lg"></span>
<span className="absolute -top-2 -right-2 flex items-center justify-center w-6 h-6 bg-red-500 rounded-full text-white shadow-md">
<MessageCircleMoreIcon size={14}/>
</span>
</a>
</div> </div>
) )
} }

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/market-research`,
},
}
}
export default function MarketResearchPage() { export default function MarketResearchPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/network-testing`,
},
}
}
export default function NetworkTestingPage() { export default function NetworkTestingPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -1,7 +1,9 @@
import {Suspense} from 'react'
import {Metadata} from 'next'
import {siteConfig} from '@/config/site'
import BreadCrumb from '@/components/bread-crumb' import BreadCrumb from '@/components/bread-crumb'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Purchase, {TabType} from '@/components/composites/purchase' import Purchase, {TabType} from '@/components/composites/purchase'
import {Suspense} from 'react'
import HomePage from '@/components/home/page' import HomePage from '@/components/home/page'
export type ProductPageProps = { export type ProductPageProps = {
@@ -10,6 +12,28 @@ export type ProductPageProps = {
}> }>
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: '产品中心',
description: '为您的业务提供多样化代理产品 - 短效代理、长效代理、固定IP代理、SOCKS5代理高可用性、低延迟',
openGraph: {
title: '产品中心',
description: '为您的业务提供多样化代理产品 - 短效代理、长效代理、固定IP代理、SOCKS5代理高可用性、低延迟',
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: '产品中心',
},
],
},
alternates: {
canonical: `${siteConfig.url}/product`,
},
}
}
export default function ProductPage(props: ProductPageProps) { export default function ProductPage(props: ProductPageProps) {
return ( return (
<HomePage path={[ <HomePage path={[

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/seo-optimization`,
},
}
}
export default function SeoOptimizationPage() { export default function SeoOptimizationPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -1,4 +1,6 @@
import {Metadata} from 'next'
import ScenePage, {ScenePageConfig} from '@/components/scene-page' import ScenePage, {ScenePageConfig} from '@/components/scene-page'
import {siteConfig} from '@/config/site'
import bannerImg from './_assets/banner.webp' import bannerImg from './_assets/banner.webp'
import solutionImg from './_assets/solution-main.webp' import solutionImg from './_assets/solution-main.webp'
import value1Img from './_assets/value-1.webp' import value1Img from './_assets/value-1.webp'
@@ -46,6 +48,28 @@ const config: ScenePageConfig = {
}, },
} }
export async function generateMetadata(): Promise<Metadata> {
return {
title: config.banner.title,
description: config.banner.description,
openGraph: {
title: config.banner.title,
description: config.banner.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: config.banner.title,
},
],
},
alternates: {
canonical: `${siteConfig.url}/social-media`,
},
}
}
export default function SocialMediaPage() { export default function SocialMediaPage() {
return <ScenePage {...config}/> return <ScenePage {...config}/>
} }

View File

@@ -59,7 +59,7 @@ export default async function UserCenter() {
</> </>
)} )}
</div> </div>
{/* <div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-sm text-weak"></h4> <h4 className="text-sm text-weak"></h4>
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<p className="text-xl text-accent"> <p className="text-xl text-accent">
@@ -68,7 +68,7 @@ export default async function UserCenter() {
</p> </p>
<RechargeModal/> <RechargeModal/>
</div> </div>
</div> */} </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h4 className="text-sm text-weak"></h4> <h4 className="text-sm text-weak"></h4>
<div className="flex justify-around gap-2"> <div className="flex justify-around gap-2">

View File

@@ -10,13 +10,13 @@ export default function Addr({channel}: {
const expired = isBefore(channel.expired_at, new Date()) const expired = isBefore(channel.expired_at, new Date())
return ( return (
<div className={`${expired ? 'text-weak' : ''}`}> <>
<span>{ip}:{port}</span> <span>{ip}:{port}</span>
{expired && ( {expired && (
<Badge variant="secondary"> <Badge className="ml-2 bg-orange-100 text-orange-700 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400">
</Badge> </Badge>
)} )}
</div> </>
) )
} }

View File

@@ -0,0 +1,16 @@
import {ReactNode} from 'react'
import {Metadata} from 'next'
export async function generateMetadata(): Promise<Metadata> {
return {
title: '余额管理 - 蓝狐代理',
}
}
export type PurchaseLayoutProps = {
children: ReactNode
}
export default async function PurchaseLayout(props: PurchaseLayoutProps) {
return props.children
}

View File

@@ -0,0 +1,236 @@
'use client'
import {Suspense, useCallback, useEffect, useState} from 'react'
import {PageRecord} from '@/lib/api'
import {Balance} from '@/lib/models'
import {useStatus} from '@/lib/states'
import {Search, Eraser} from 'lucide-react'
import {Button} from '@/components/ui/button'
import DataTable from '@/components/data-table'
import {format} from 'date-fns'
import DatePicker from '@/components/date-picker'
import {Form, FormField} from '@/components/ui/form'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import {Label} from '@/components/ui/label'
import Page from '@/components/page'
import {CheckCircle, AlertCircle} from 'lucide-react'
import {Input} from '@/components/ui/input'
import {listBalances} from '@/actions/balance'
const filterSchema = zod.object({
created_at_start: zod.date().optional(),
created_at_end: zod.date().optional(),
bill_no: zod.string().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
export type BalancePageProps = {}
export default function BalancePage(props: BalancePageProps) {
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<Balance>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
bill_no: '',
created_at_start: undefined,
created_at_end: undefined,
},
})
const onSubmit = async (value: FilterSchema) => {
await refresh(1, data.size)
}
const refresh = useCallback(async (page: number, size: number) => {
setStatus('load')
try {
const created_at_start = form.getValues('created_at_start')
const created_at_end = form.getValues('created_at_end')
const bill_no = form.getValues('bill_no')
const res = await listBalances({
page, size,
created_at_start,
created_at_end,
bill_no: bill_no || undefined,
})
if (res.success) {
setData(res.data)
setStatus('done')
}
else {
throw new Error('Failed to load bills')
}
}
catch (e) {
setStatus('fail')
}
}, [form, setStatus])
useEffect(() => {
refresh(1, 10).then()
}, [refresh])
return (
<Page>
<section className="flex justify-between flex-wrap">
<Form form={form} handler={form.handleSubmit(onSubmit)} className="flex-auto flex flex-wrap gap-4 items-end">
<FormField name="bill_no" label={<span className="text-sm"></span>}>
{({id, field}) => {
return <Input {...field} id={id} className="h-9"/>
}}
</FormField>
<div className="flex flex-col gap-2">
<Label className="text-sm"></Label>
<div className="flex items-center">
<FormField name="created_at_start">
{({field}) => {
const dateValue = typeof field.value === 'string' && field.value ? new Date(field.value) : undefined
return (
<DatePicker
placeholder="选择开始时间"
{...field}
format="yyyy-MM-dd"
/>
)
}
}
</FormField>
<span className="px-1">-</span>
<FormField name="created_at_end">
{({field}) => (
<DatePicker
placeholder="选择结束时间"
{...field}
format="yyyy-MM-dd"
/>
)
}
</FormField>
</div>
</div>
<Button className="h-9" type="submit">
<Search/>
<span></span>
</Button>
<Button
theme="outline"
className="h-9"
type="button"
onClick={() => {
form.reset()
refresh(1, data.size)
}}
>
<Eraser/>
<span></span>
</Button>
</Form>
</section>
<Suspense>
<DataTable
data={data.list}
status={status}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: async (page: number) => {
await refresh(page, data.size)
},
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{accessorKey: 'bill_no', header: `账单编号`,
accessorFn: row => row.bill?.bill_no || '',
},
{
accessorKey: 'status',
header: `状态`,
cell: ({row}) => {
const trade = row.original.trade
if (![1, 2, 3, 4, 5].includes(trade?.method)) {
return (
<div className="flex items-center gap-2">
<CheckCircle size={16} className="text-done"/>
<span></span>
</div>
)
}
if (!trade) return <span>-</span>
return (
<div className="flex items-center gap-2">
{trade?.status === 1 ? (
<CheckCircle size={16} className="text-done"/>
) : trade?.status === 2 ? (
<AlertCircle size={16} className="text-weak"/>
) : trade?.status === 3 ? (
<AlertCircle size={16} className="text-fail"/>
) : null}
<span>
{trade?.status === 1 ? '已完成'
: trade?.status === 2 ? '已取消'
: trade?.status === 3 ? '已退款' : '-'}
</span>
</div>
)
},
},
{
accessorKey: 'amount',
header: '变动金额',
cell: ({row}) => {
const amount = row.original.amount
const isPositive = Number(amount) > 0
return (
<div className="flex items-center gap-1">
<span
className={`font-semibold ${
isPositive ? 'text-green-600' : 'text-red-600'
}`}
>
{isPositive ? '+' : ''}
{Number(amount).toFixed(2)}
</span>
</div>
)
},
},
{
header: '余额变化',
accessorKey: 'balance_prev',
cell: ({row}) => (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-sm">¥{Number(row.original.balance_prev).toFixed(2)}</span>
<span className="text-muted-foreground"></span>
<span>¥{Number(row.original.balance_curr).toFixed(2)}</span>
</div>
),
},
{
header: '备注',
accessorKey: 'remark',
},
{
header: '创建时间',
accessorKey: 'created_at',
cell: ({row}) =>
format(new Date(row.original.created_at), 'yyyy-MM-dd HH:mm:ss'),
},
]}
/>
</Suspense>
</Page>
)
}

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {useCallback, useEffect, useState} from 'react' import {Suspense, useCallback, useEffect, useState} from 'react'
import {PageRecord} from '@/lib/api' import {PageRecord} from '@/lib/api'
import {Bill} from '@/lib/models' import {Bill} from '@/lib/models'
import {useStatus} from '@/lib/states' import {useStatus} from '@/lib/states'
@@ -88,7 +88,7 @@ export default function BillsPage(props: BillsPageProps) {
<div> <div>
</div> </div>
<Form form={form} handler={form.handleSubmit(onSubmit)} className="flex items-end gap-4 flex-wrap"> <Form form={form} handler={form.handleSubmit(onSubmit)} className="flex-auto flex flex-wrap gap-4 items-end">
<FormField name="type" label={<span className="text-sm"></span>}> <FormField name="type" label={<span className="text-sm"></span>}>
{({id, field}) => ( {({id, field}) => (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
@@ -99,7 +99,7 @@ export default function BillsPage(props: BillsPageProps) {
<SelectItem value="all"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="3"></SelectItem> <SelectItem value="3"></SelectItem>
<SelectItem value="1"></SelectItem> <SelectItem value="1"></SelectItem>
<SelectItem value="2">退</SelectItem> {/* <SelectItem value="2">退款</SelectItem> */}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@@ -141,158 +141,159 @@ export default function BillsPage(props: BillsPageProps) {
</Form> </Form>
</section> </section>
<DataTable <Suspense>
data={data.list} <DataTable
status={status} data={data.list}
pagination={{ status={status}
total: data.total, pagination={{
page: data.page, total: data.total,
size: data.size, page: data.page,
onPageChange: async (page: number) => { size: data.size,
await refresh(page, data.size) onPageChange: async (page: number) => {
}, await refresh(page, data.size)
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{
accessorKey: 'bill_no', header: `账单编号`,
},
{
accessorKey: 'info',
header: `账单详情`,
cell: ({row}) => {
const bill = row.original
return (
<div className="flex items-center gap-2">
{/* 类型展示 */}
<div className="shrink-0">
{bill.type === 1 && (
<div className="flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span></span>
</div>
)}
{bill.type === 2 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span>退</span>
</div>
)}
{bill.type === 3 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span></span>
</div>
)}
</div>
{/* 账单详情 */}
<div className="text-sm">
{bill.info}
</div>
</div>
)
}, },
}, onSizeChange: async (size: number) => {
{ await refresh(data.page, size)
accessorKey: 'status', },
header: `状态`, }}
cell: ({row}) => { columns={[
const trade = row.original.trade {
if (![1, 2, 3, 4, 5].includes(trade?.method)) { accessorKey: 'bill_no', header: `账单编号`,
},
{
accessorKey: 'info',
header: `账单详情`,
cell: ({row}) => {
const bill = row.original
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle size={16} className="text-done"/> {/* 类型展示 */}
<span></span> <div className="shrink-0">
{bill.type === 1 && (
<div className="flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span></span>
</div>
)}
{bill.type === 2 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span>退</span>
</div>
)}
{bill.type === 3 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16}/>
<span></span>
</div>
)}
</div>
{/* 账单详情 */}
<div className="text-sm">
{bill.info}
</div>
</div> </div>
) )
} },
if (!trade) return <span>-</span>
return (
<div className="flex items-center gap-2">
{trade?.status === 1 ? (
<CheckCircle size={16} className="text-done"/>
) : trade?.status === 2 ? (
<AlertCircle size={16} className="text-weak"/>
) : trade?.status === 3 ? (
<AlertCircle size={16} className="text-fail"/>
) : null}
<span>
{trade?.status === 1 ? '已完成'
: trade?.status === 2 ? '已取消'
: trade?.status === 3 ? '已退款' : '-'}
</span>
</div>
)
}, },
}, {
{ accessorKey: 'status',
accessorKey: 'amount', header: `状态`,
header: '支付信息', cell: ({row}) => {
cell: ({row}) => { const trade = row.original.trade
const amount = typeof row.original.amount === 'string' if (![1, 2, 3, 4, 5].includes(trade?.method)) {
? parseFloat(row.original.amount) return (
: row.original.amount || 0 <div className="flex items-center gap-2">
const trade = row.original.trade <CheckCircle size={16} className="text-done"/>
const paymentMethodMap = { <span></span>
1: '支付宝*', </div>
2: '微信*', )
3: '其他', }
4: '支付宝', if (!trade) return <span>-</span>
5: '微信', return (
} <div className="flex items-center gap-2">
const paymentMethod = trade ? paymentMethodMap[trade.method as keyof typeof paymentMethodMap] || '余额' : '余额' {trade?.status === 1 ? (
return ( <CheckCircle size={16} className="text-done"/>
<div className="flex gap-1"> ) : trade?.status === 2 ? (
<span className="text-sm"> <AlertCircle size={16} className="text-weak"/>
{paymentMethod} ) : trade?.status === 3 ? (
</span> <AlertCircle size={16} className="text-fail"/>
<span className={amount > 0 ? 'text-green-500' : 'text-orange-500'}> ) : null}
{amount.toFixed(2)} <span>
</span> {trade?.status === 1 ? '已完成'
</div> : trade?.status === 2 ? '已取消'
) : trade?.status === 3 ? '已退款' : '-'}
</span>
</div>
)
},
}, },
}, {
{ accessorKey: 'amount',
accessorKey: 'platform', header: '支付信息',
header: '支付平台', cell: ({row}) => {
cell: ({row}) => { const amount = typeof row.original.amount === 'string'
const trade = row.original.trade ? parseFloat(row.original.amount)
if (!trade) return <span>-</span> : row.original.amount || 0
return ( const trade = row.original.trade
<div className="flex items-center gap-2"> const paymentMethodMap = {
{trade.platform === 1 ? ( 1: '支付宝*',
<> 2: '微信*',
<span></span> 3: '其他',
</> 4: '支付宝',
) : trade.platform === 2 ? ( 5: '微信',
<> }
<span></span> const paymentMethod = trade ? paymentMethodMap[trade.method as keyof typeof paymentMethodMap] || '余额' : '余额'
</> return (
) : ( <div className="flex gap-1">
<span>-</span> <span className="text-sm">
)} {paymentMethod}
</div> </span>
) <span className={amount > 0 ? 'text-green-500' : 'text-orange-500'}>
{amount.toFixed(2)}
</span>
</div>
)
},
}, },
}, {
{ accessorKey: 'platform',
accessorKey: 'created_at', header: '创建时间', cell: ({row}) => ( header: '支付平台',
format(new Date(row.original.created_at), 'yyyy-MM-dd HH:mm') cell: ({row}) => {
), const trade = row.original.trade
}, if (!trade) return <span>-</span>
{ return (
accessorKey: 'action', header: `操作`, cell: item => ( <div className="flex items-center gap-2">
<div className="flex gap-2"> {trade.platform === 1 ? (
- <>
</div> <span></span>
), </>
}, ) : trade.platform === 2 ? (
]} <>
/> <span></span>
</>
) : (
<></>
)}
</div>
)
},
},
{
accessorKey: 'created_at',
header: '创建时间',
cell: ({row}) => {
const createdAt = row.original.created_at
if (!createdAt) return <span></span>
const date = new Date(createdAt)
if (isNaN(date.getTime())) return <span></span>
return format(date, 'yyyy-MM-dd HH:mm:ss')
},
},
]}
/>
</Suspense>
</Page> </Page>
) )
} }

View File

@@ -192,7 +192,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
<span ></span> <span ></span>
<div className="flex flex-wrap gap-1 max-w-[200px]"> <div className="flex flex-wrap gap-1 max-w-[200px]">
{channel.whitelists.split(',').map((ip, index) => ( {channel.whitelists.split(',').map((ip, index) => (
<Badge key={index} variant="secondary"> <Badge key={index} className="bg-green-100 text-green-700 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400">
{ip.trim()} {ip.trim()}
</Badge > </Badge >
))} ))}
@@ -201,7 +201,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
) : hasAuth ? ( ) : hasAuth ? (
<div className="flex flex-col"> <div className="flex flex-col">
<span></span> <span></span>
<Badge variant="secondary"> <Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400">
{channel.username}:{channel.password} {channel.username}:{channel.password}
</Badge > </Badge >
</div> </div>
@@ -214,11 +214,35 @@ export default function ChannelsPage(props: ChannelsPageProps) {
}, },
{ {
header: '提取时间', header: '提取时间',
cell: ({row}) => format(row.original.created_at, 'yyyy-MM-dd HH:mm'), cell: ({row}) => {
const timeValue = row.original.created_at
if (!timeValue) return <div>-</div>
try {
const date = new Date(timeValue)
if (isNaN(date.getTime())) return <div>-</div>
return <div>{format(date, 'yyyy-MM-dd HH:mm:ss')}</div>
}
catch {
return <div>-</div>
}
},
}, },
{ {
header: '过期时间', header: '过期时间',
cell: ({row}) => format(row.original.expired_at, 'yyyy-MM-dd HH:mm:ss'), cell: ({row}) => {
const timeValue = row.original.expired_at
if (!timeValue) return <div>-</div>
try {
const date = new Date(timeValue)
if (isNaN(date.getTime())) return <div>-</div>
return <div>{format(date, 'yyyy-MM-dd HH:mm:ss')}</div>
}
catch {
return <div>-</div>
}
},
}, },
]} ]}
/> />

View File

@@ -2,12 +2,11 @@
import {ReactNode, Suspense, use, useState} from 'react' import {ReactNode, Suspense, use, useState} from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog' import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import UserCenter from '@/components/composites/user-center' import UserCenter from '@/components/composites/user-center'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip' import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'
import {Archive, ArchiveRestore, Eye, HardDriveUpload, IdCard, LockKeyhole, Package, PanelLeftCloseIcon, PanelLeftOpenIcon, ShoppingCart, UserRound, UserRoundPen, Wallet} from 'lucide-react' import {Archive, ArchiveRestore, CircleDollarSign, Eye, HardDriveUpload, IdCard, LockKeyhole, MessageCircleMoreIcon, Package, PanelLeftCloseIcon, PanelLeftOpenIcon, ShoppingCart, UserRound, UserRoundPen, Wallet} from 'lucide-react'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import logoAvatar from '@/assets/logo-avatar.svg' import logoAvatar from '@/assets/logo-avatar.svg'
import logoText from '@/assets/logo-text.svg' import logoText from '@/assets/logo-text.svg'
@@ -75,25 +74,27 @@ export function Content(props: {children: ReactNode}) {
} }
function ContentResolved() { function ContentResolved() {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
if (!profile) throw new Error('登录状态异常')
return ( if (profile)
<> return (
<RealnameAuthDialog <>
triggerClassName="hidden" <RealnameAuthDialog
defaultOpen={!profile.id_token} triggerClassName="hidden"
/> defaultOpen={!profile.id_token}
<ChangePasswordDialog />
triggerClassName="hidden" {/* <ChangePasswordDialog
defaultOpen={!profile.has_password} triggerClassName="hidden"
/> defaultOpen={!profile.has_password}
</> /> */}
) </>
)
} }
export function Header() { export function Header() {
const navbar = useLayoutStore(store => store.navbar) const navbar = useLayoutStore(store => store.navbar)
const toggleNavbar = useLayoutStore(store => store.toggleNavbar) const toggleNavbar = useLayoutStore(store => store.toggleNavbar)
const profile = use(useProfileStore(store => store.profile))
const showRealnameAuth = profile?.id_type === 0
return ( return (
<header className={merge( <header className={merge(
`flex-none h-16 overflow-hidden`, `flex-none h-16 overflow-hidden`,
@@ -103,20 +104,55 @@ export function Header() {
<div className="flex-auto flex items-center gap-2"> <div className="flex-auto flex items-center gap-2">
<Button <Button
theme="ghost" theme="ghost"
className="w-9 h-9 ml-4 md:ml-0" className="h-9 ml-4 md:ml-0"
onClick={toggleNavbar}> onClick={toggleNavbar}>
{navbar {navbar ? (
? <PanelLeftCloseIcon/> <>
: <PanelLeftOpenIcon/> <PanelLeftCloseIcon/>
} <span className="text-foreground/90"></span>
</>
) : (
<>
<PanelLeftOpenIcon/>
<span className="text-foreground/90"></span>
</>
)}
</Button> </Button>
<span className="max-md:hidden"> <span className="max-md:hidden"></span>
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2"/>
</span> {showRealnameAuth ? (
<Link
href="/admin/identify"
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<IdCard size={16}/>
<span></span>
</Link>
) : (
<Link
href=""
className="max-md:hidden flex items-center gap-1.5 text-sm text-green-400 hover:text-green-400 transition-colors"
>
<IdCard size={16}/>
<span></span>
</Link>
)}
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2"/>
<a
href="https://wpa1.qq.com/K0s0cvwf?_type=wpa&qidian=true"
target="_blank"
rel="noopener noreferrer"
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors mr-2"
>
<MessageCircleMoreIcon size={16}/>
<span></span>
</a>
</div> </div>
{/* right */} <div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
<div className="flex-none flex items-center justify-end pr-4"> <Link href="/" className="flex-none h-16 flex items-center justify-center text-sm">
</Link>
<Suspense> <Suspense>
<HeaderUserCenter/> <HeaderUserCenter/>
</Suspense> </Suspense>
@@ -127,8 +163,7 @@ export function Header() {
function HeaderUserCenter() { function HeaderUserCenter() {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
if (!profile) throw new Error('登录状态异常') if (profile) return <UserCenter profile={profile}/>
return <UserCenter profile={profile}/>
} }
export function Navbar() { export function Navbar() {
@@ -170,12 +205,13 @@ export function Navbar() {
<TooltipProvider> <TooltipProvider>
<NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/> <NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/>
<NavTitle label="快速开始"/> <NavTitle label="快速开始"/>
<NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/> {/* <NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/> */}
<NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/> <NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/>
<NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/> <NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/>
<NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/> <NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/>
<NavTitle label="个人中心"/> <NavTitle label="个人中心"/>
<NavItem href="/admin/profile" icon={<UserRoundPen size={20}/>} label="基本信息" expand={navbar}/> <NavItem href="/admin/profile" icon={<UserRoundPen size={20}/>} label="基本信息" expand={navbar}/>
<NavItem href="/admin/balance" icon={<CircleDollarSign size={20}/>} label="余额管理" expand={navbar}/>
<NavItem href="/admin/bills" icon={<Wallet size={20}/>} label="我的账单" expand={navbar}/> <NavItem href="/admin/bills" icon={<Wallet size={20}/>} label="我的账单" expand={navbar}/>
<NavTitle label="资源管理"/> <NavTitle label="资源管理"/>
<NavItem href="/admin/resources" icon={<Package size={20}/>} label="我的套餐" expand={navbar}/> <NavItem href="/admin/resources" icon={<Package size={20}/>} label="我的套餐" expand={navbar}/>

View File

@@ -98,6 +98,16 @@ export default function IdentifyPage(props: IdentifyPageProps) {
const profile = useProfileStore(store => store.profile) const profile = useProfileStore(store => store.profile)
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
// 重置认证流程
const handleDialogOpenChange = async (open: boolean) => {
setOpenDialog(open)
if (!open) {
setStep('form')
setTarget('')
await refreshProfile()
}
}
// ====================== // ======================
// render // render
// ====================== // ======================
@@ -125,7 +135,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</div> </div>
<Suspense> <Suspense>
<IfNotIdentofy> <IfNotIdentofy>
<Dialog open={openDialog} onOpenChange={setOpenDialog}> <Dialog open={openDialog} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-full"></Button> <Button className="w-full"></Button>
</DialogTrigger> </DialogTrigger>
@@ -165,11 +175,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col gap-4 items-center">
<canvas ref={canvas} width={256} height={256}/> <canvas ref={canvas} width={256} height={256}/>
<p className="text-sm text-gray-600"></p> <p className="text-sm text-gray-600"></p>
<Button onClick={async () => { <Button onClick={() => handleDialogOpenChange(false)}>
await refreshProfile()
setOpenDialog(false)
}}>
</Button> </Button>
</div> </div>
)} )}
@@ -223,7 +230,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<p className="flex gap-2 items-center justify-between w-56 self-center"> <p className="flex gap-2 items-center justify-between w-56 self-center">
<span className="flex gap-2"> <span className="flex gap-2">
<span className="bg-primary/25 text-primary w-8 h-8 rounded-full flex items-center justify-center">03</span> <span className="bg-primary/25 text-primary w-8 h-8 rounded-full flex items-center justify-center">03</span>
<span></span> <span></span>
</span> </span>
<Image alt="步骤配图" src={step3}/> <Image alt="步骤配图" src={step3}/>
</p> </p>

View File

@@ -1,4 +1,4 @@
import {ReactNode} from 'react' import {ReactNode, Suspense} from 'react'
import {Shell, Content, Header, Navbar, Mask} from './clients' import {Shell, Content, Header, Navbar, Mask} from './clients'
export default function Template(props: { export default function Template(props: {
@@ -13,7 +13,9 @@ export default function Template(props: {
</div> </div>
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20"> <div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
<Header/> <Suspense>
<Header/>
</Suspense>
</div> </div>
<svg className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none" preserveAspectRatio="none"> <svg className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none" preserveAspectRatio="none">

View File

@@ -78,8 +78,8 @@ export function BasicForm(props: {
}) { }) {
const schema = z.object({ const schema = z.object({
username: z.string(), username: z.string(),
email: z.string(), email: z.string().email('请输入正确的邮箱格式').or(z.literal('')),
contact_qq: z.string(), contact_qq: z.string().regex(/^\d*$/, 'QQ号只能包含数字'),
contact_wechat: z.string(), contact_wechat: z.string(),
}) })
type Schema = z.infer<typeof schema> type Schema = z.infer<typeof schema>

View File

@@ -3,16 +3,17 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {CheckCircle} from 'lucide-react' import {CheckCircle} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import banner from '@/app/admin/identify/_assets/banner.webp' import banner from '@/app/admin/identify/_assets/banner.webp'
import RechargeModal from '@/components/composites/recharge'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog' import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import {Aftersale, BasicForm} from './clients' import {Aftersale, BasicForm} from './clients'
import {Button} from '@/components/ui/button'
import Link from 'next/link'
export type ProfilePageProps = {} export type ProfilePageProps = {}
export default async function ProfilePage(props: ProfilePageProps) { export default async function ProfilePage(props: ProfilePageProps) {
const profile = await getProfile() const profile = await getProfile()
if (!profile.success) { if (!profile.success) {
return ( return (
<Page> <Page>
@@ -22,6 +23,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
} }
const user = profile.data const user = profile.data
return ( return (
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col"> <Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
<div className="flex-3/4 flex flex-col gap-4"> <div className="flex-3/4 flex flex-col gap-4">
@@ -34,26 +36,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
{/* 块信息 */} {/* 块信息 */}
<div className="flex gap-4 max-md:flex-col max-sm:flex-col"> <div className="flex gap-4 max-md:flex-col max-sm:flex-col">
{/* <Card className="flex-1 ">
<CardHeader>
<CardTitle className="font-normal">账户余额(元)</CardTitle>
</CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8">
<p className="text-xl">{user.balance}</p>
<RechargeModal classNames={{
trigger: `h-10 px-6`,
}}/>
</CardContent>
</Card> */}
<Card className="flex-1 "> <Card className="flex-1 ">
<CardHeader> <CardHeader>
<CardTitle className="font-normal"></CardTitle> <CardTitle className="font-normal"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8"> <CardContent className="flex-auto flex justify-between items-center px-8">
<p>{user.phone}</p> <p>{user.phone}</p>
<ChangePasswordDialog triggerClassName="w-24 h-9"/> <ChangePasswordDialog triggerClassName="w-24 h-9" phone={user?.phone}/>
</CardContent> </CardContent>
</Card> </Card>
@@ -66,10 +55,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
? ( ? (
<> <>
<p className="text-sm">使</p> <p className="text-sm">使</p>
<RealnameAuthDialog {/* <RealnameAuthDialog
defaultOpen={!user.id_token} // defaultOpen={!user.id_token}
triggerClassName="w-24" triggerClassName="w-24"
/> /> */}
<Link href="/admin/identify">
<Button></Button>
</Link>
</> </>
) )
: ( : (

View File

@@ -15,6 +15,7 @@ import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {EraserIcon, SearchIcon} from 'lucide-react' import {EraserIcon, SearchIcon} from 'lucide-react'
import {pageBatch} from '@/actions/batch' import {pageBatch} from '@/actions/batch'
import {Input} from '@/components/ui/input'
export type RecordPageProps = {} export type RecordPageProps = {}
@@ -34,6 +35,7 @@ export default function RecordPage(props: RecordPageProps) {
const filterSchema = z.object({ const filterSchema = z.object({
time_start: z.date().optional(), time_start: z.date().optional(),
time_end: z.date().optional(), time_end: z.date().optional(),
resource_no: z.string().optional(),
}) })
type FilterSchema = z.infer<typeof filterSchema> type FilterSchema = z.infer<typeof filterSchema>
@@ -42,6 +44,7 @@ export default function RecordPage(props: RecordPageProps) {
defaultValues: { defaultValues: {
time_start: undefined, time_start: undefined,
time_end: undefined, time_end: undefined,
resource_no: '',
}, },
}) })
@@ -53,7 +56,9 @@ export default function RecordPage(props: RecordPageProps) {
const result = await pageBatch({ const result = await pageBatch({
page, page,
size, size,
...filter, time_start: filter.time_start,
time_end: filter.time_end,
resource_no: filter.resource_no || undefined,
}) })
if (result.success && result.data) { if (result.success && result.data) {
@@ -88,12 +93,22 @@ export default function RecordPage(props: RecordPageProps) {
<section className="flex justify-between"> <section className="flex justify-between">
<div></div> <div></div>
<Form form={filterForm} handler={filterHandler} className="flex-auto flex flex-wrap gap-4 items-end"> <Form form={filterForm} handler={filterHandler} className="flex-auto flex flex-wrap gap-4 items-end">
<FormField name="resource_no" label={<span className="text-sm"></span>}>
{({id, field}) => (
<Input
{...field}
id={id}
className="h-9"
value={field.value ?? ''}
/>
)}
</FormField>
<fieldset className="flex flex-col gap-2 items-start"> <fieldset className="flex flex-col gap-2 items-start">
<div> <div>
<legend className="block text-sm"></legend> <legend className="block text-sm"></legend>
</div> </div>
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<FormField<FilterSchema, 'time_start'> name="time_start"> <FormField<FilterSchema, 'time_start'> name="time_start" >
{({field}) => ( {({field}) => (
<DatePicker <DatePicker
placeholder="选择开始时间" placeholder="选择开始时间"
@@ -144,6 +159,10 @@ export default function RecordPage(props: RecordPageProps) {
onSizeChange: size => fetchRecords(1, size), onSizeChange: size => fetchRecords(1, size),
}} }}
columns={[ columns={[
{
header: '套餐编号',
accessorKey: 'resource.resource_no',
},
{ {
header: '批次号', header: '批次号',
cell: ({row}) => <div>{row.original.batch_no}</div>, cell: ({row}) => <div>{row.original.batch_no}</div>,
@@ -164,16 +183,16 @@ export default function RecordPage(props: RecordPageProps) {
cell: ({row}) => <div>{row.original.prov}</div>, cell: ({row}) => <div>{row.original.prov}</div>,
accessorKey: 'prov', accessorKey: 'prov',
}, },
{
header: '城市',
cell: ({row}) => <div>{row.original.city}</div>,
accessorKey: 'city',
},
{ {
header: '提取数量', header: '提取数量',
cell: ({row}) => <div>{row.original.count}</div>, cell: ({row}) => <div>{row.original.count}</div>,
accessorKey: 'count', accessorKey: 'count',
}, },
{
header: '资源数量',
cell: ({row}) => <div>{row.original.resource_id}</div>,
accessorKey: 'resource_id',
},
{ {
header: '提取时间', header: '提取时间',
cell: ({row}) => { cell: ({row}) => {

View File

@@ -12,6 +12,7 @@ import {Eraser, Search} from 'lucide-react'
export interface ResourceFilterValues { export interface ResourceFilterValues {
resource_no: string resource_no: string
type: 'expire' | 'quota' | 'all' type: 'expire' | 'quota' | 'all'
status: '0' | '1' | '2'
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -28,7 +29,7 @@ export default function ResourceFilter({form, onSubmit, onReset}: ResourceFilter
const handler = form.handleSubmit(onSubmit) const handler = form.handleSubmit(onSubmit)
return ( return (
<Form form={form} handler={handler} className="flex items-end gap-4 flex-wrap"> <Form form={form} handler={handler} className="flex-auto flex flex-wrap gap-4 items-end">
<FormField name="resource_no" label={<span className="text-sm"></span>}> <FormField name="resource_no" label={<span className="text-sm"></span>}>
{({id, field}) => ( {({id, field}) => (
<Input {...field} id={id} className="h-9"/> <Input {...field} id={id} className="h-9"/>
@@ -41,13 +42,27 @@ export default function ResourceFilter({form, onSubmit, onReset}: ResourceFilter
<SelectValue placeholder="选择套餐类型"/> <SelectValue placeholder="选择套餐类型"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all" ></SelectItem>
<SelectItem value="expire"></SelectItem> <SelectItem value="expire"></SelectItem>
<SelectItem value="quota"></SelectItem> <SelectItem value="quota"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</FormField> </FormField>
<FormField name="status" label={<span className="text-sm"></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-24 h-9">
<SelectValue placeholder="选择状态"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-sm"></Label> <Label className="text-sm"></Label>
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -8,7 +8,7 @@ import zod from 'zod'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useStatus} from '@/lib/states' import {useStatus} from '@/lib/states'
import {ExtraResp} from '@/lib/api' import {ExtraResp} from '@/lib/api'
import {listResourceLong, listResourceShort} from '@/actions/resource' import {listResourceLong, listResourceShort, updateCheckip} from '@/actions/resource'
import DataTable from '@/components/data-table' import DataTable from '@/components/data-table'
import {ColumnDef} from '@tanstack/react-table' import {ColumnDef} from '@tanstack/react-table'
import {Resource} from '@/lib/models/resource' import {Resource} from '@/lib/models/resource'
@@ -17,13 +17,16 @@ import {
ExpireBadge, ExpireBadge,
formatDateTime, formatDateTime,
getTodayUsage, getTodayUsage,
isValidResourcestatus,
isValidResourceType, isValidResourceType,
ResourceTypeBadge, ResourceTypeBadge,
} from './utils' } from './utils'
import {Button} from '@/components/ui/button'
const filterSchema = zod.object({ const filterSchema = zod.object({
resource_no: zod.string().optional().default(''), resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'), type: zod.enum(['expire', 'quota', 'all']).default('all'),
status: zod.enum(['0', '1', '2']).default('1'),
create_after: zod.date().optional(), create_after: zod.date().optional(),
create_before: zod.date().optional(), create_before: zod.date().optional(),
expire_after: zod.date().optional(), expire_after: zod.date().optional(),
@@ -47,12 +50,13 @@ export default function ResourceList({resourceType}: ResourceListProps) {
// 从 URL 参数初始化筛选条件 // 从 URL 参数初始化筛选条件
const params = useSearchParams() const params = useSearchParams()
const paramType = params.get('type') const paramType = params.get('type')
const paramStatus = params.get('status')
const form = useForm<ResourceFilterValues>({ const form = useForm<ResourceFilterValues>({
resolver: zodResolver(filterSchema), resolver: zodResolver(filterSchema),
defaultValues: { defaultValues: {
resource_no: params.get('resource_no') || '', resource_no: params.get('resource_no') || '',
type: isValidResourceType(paramType) ? paramType : 'all', type: isValidResourceType(paramType) ? paramType : 'all',
status: isValidResourcestatus(paramStatus) ? paramStatus : '1',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined, create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined, create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined, expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
@@ -71,6 +75,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
expire: 1, expire: 1,
quota: 2, quota: 2,
}[getValues('type')] }[getValues('type')]
const status = getValues('status')
const create_after = getValues('create_after') const create_after = getValues('create_after')
const create_before = getValues('create_before') const create_before = getValues('create_before')
const expire_after = getValues('expire_after') const expire_after = getValues('expire_after')
@@ -82,6 +87,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
page, page,
size, size,
type, type,
status: Number(status),
create_after, create_after,
create_before, create_before,
expire_after, expire_after,
@@ -116,6 +122,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const handleReset = () => { const handleReset = () => {
form.reset({ form.reset({
type: 'all', type: 'all',
status: '1',
resource_no: '', resource_no: '',
create_after: undefined, create_after: undefined,
create_before: undefined, create_before: undefined,
@@ -124,11 +131,31 @@ export default function ResourceList({resourceType}: ResourceListProps) {
}) })
} }
const handleCheckipChange = async (id: number, currentCheckip: boolean) => {
try {
const result = await updateCheckip({
id: id,
checkip: !currentCheckip,
})
if (result.success) {
toast.success(`IP检查已${!currentCheckip ? '启用' : '停用'}`)
await refresh(data.page, data.size)
}
else {
throw new Error(result.message || '操作失败')
}
}
catch (e) {
toast.error(e instanceof Error ? e.message : '更新IP检查状态失败')
}
}
// 表格列定义 // 表格列定义
const columns = useMemo<ColumnDef<Resource<1> | Resource<2>>[]>(() => { const columns = useMemo<ColumnDef<Resource<1> | Resource<2>>[]>(() => {
const resourceKey = isLong ? 'long' : 'short' const resourceKey = isLong ? 'long' : 'short'
const baseColumns: ColumnDef<Resource<1> | Resource<2>>[] = [ const baseColumns = ([
{ {
header: '套餐编号', header: '套餐编号',
cell: ({row}) => { cell: ({row}) => {
@@ -159,7 +186,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const live = resourceKey === 'long' const live = resourceKey === 'long'
? (row.original as Resource<2>).long.live ? (row.original as Resource<2>).long.live
: (row.original as Resource<1>).short.live : (row.original as Resource<1>).short.live
return <span>{isLong ? `${live}小时` : `${live / 60}分钟`}</span> return <span>{isLong ? `${live}分钟` : `${live}分钟`}</span>
}, },
}, },
{ {
@@ -212,16 +239,38 @@ export default function ResourceList({resourceType}: ResourceListProps) {
{ {
header: '开通时间', header: '开通时间',
cell: ({row}) => formatDateTime(row.original.created_at), cell: ({row}) => formatDateTime(row.original.created_at),
}, }, // 短效资源增加到期时间列
] !isLong ? {
// 短效资源增加到期时间列
if (!isLong) {
baseColumns.push({
header: '到期时间', header: '到期时间',
cell: ({row}) => formatDateTime((row.original as Resource<1>).short.expire_at), cell: ({row}) => formatDateTime((row.original as Resource<1>).short.expire_at),
}) } : undefined,
} {
header: '状态',
cell: ({row}) => {
const isActive = row.original.active
return (
<span className={isActive ? 'text-green-500' : 'text-red-500'}>
{isActive ? '启用' : '禁用'}
</span>
)
},
},
{
header: '操作',
cell: ({row}) => {
const checkip = row.original.checkip
return (
<Button
theme={checkip ? 'fail' : 'default'}
className="h-7 px-3 text-sm"
onClick={() => handleCheckipChange(row.original.id, row.original.checkip)}
>
{checkip ? '停用IP检查' : '启用IP检查'}
</Button>
)
},
},
] satisfies ((ColumnDef<Resource<1> | Resource<2>> | undefined)[])).filter(Boolean) as ColumnDef<Resource<1> | Resource<2>>[]
return baseColumns return baseColumns
}, [isLong]) }, [isLong])

View File

@@ -7,6 +7,11 @@ export function isValidResourceType(type: string | null): type is 'expire' | 'qu
return type === 'expire' || type === 'quota' || type === 'all' return type === 'expire' || type === 'quota' || type === 'all'
} }
// 状态
export function isValidResourcestatus(status: string | null): status is '0' | '1' | '2' {
return status === '0' || status === '1' || status === '2'
}
// 资源类型徽章 // 资源类型徽章
export function ResourceTypeBadge({type}: {type: number}) { export function ResourceTypeBadge({type}: {type: number}) {
if (type === 1) { if (type === 1) {
@@ -39,7 +44,7 @@ export function ExpireBadge({expireAt}: {expireAt: Date}) {
// 格式化日期 // 格式化日期
export function formatDateTime(date: Date | null | undefined) { export function formatDateTime(date: Date | null | undefined) {
if (!date) return '-' if (!date) return '-'
return format(date, 'yyyy-MM-dd HH:mm') return format(date, 'yyyy-MM-dd HH:mm:ss')
} }
// 计算今日使用量 // 计算今日使用量

View File

@@ -269,7 +269,7 @@ export default function WhitelistPage(props: WhitelistPageProps) {
header: `备注`, accessorKey: 'remark', header: `备注`, accessorKey: 'remark',
}, },
{ {
header: `添加时间`, cell: ({row}) => format(parseISO(row.original.created_at), 'yyyy-MM-dd HH:mm'), header: `添加时间`, cell: ({row}) => format(parseISO(row.original.created_at), 'yyyy-MM-dd HH:mm:ss'),
}, },
{ {
id: 'actions', header: `操作`, cell: ({row}) => ( id: 'actions', header: `操作`, cell: ({row}) => (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -156,8 +156,25 @@
} }
} }
/* highlight.js 样式覆盖 */ /* highlight.js 样式覆盖 - 确保代码块高亮在前台正常显示 */
pre code.hljs { .prose pre {
background: inherit; background: #2b2b2b;
padding: 0; color: #abb2bf;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
overflow-x: auto;
font-size: 0.875rem;
line-height: 1.6;
}
.prose pre code {
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
}
.prose pre code.hljs {
background: inherit;
padding: 0;
} }

View File

@@ -1,17 +1,71 @@
import './globals.css' import './globals.css'
import {ReactNode} from 'react' import {ReactNode} from 'react'
import {Metadata} from 'next' import {Metadata, Viewport} from 'next'
import {Toaster} from '@/components/ui/sonner' import {Toaster} from '@/components/ui/sonner'
import Effects from '@/app/effects' import Effects from '@/app/effects'
import {ProfileStoreProvider} from '@/components/stores/profile' import {ProfileStoreProvider} from '@/components/stores/profile'
import {LayoutStoreProvider} from '@/components/stores/layout' import {LayoutStoreProvider} from '@/components/stores/layout'
import {ClientStoreProvider} from '@/components/stores/client' import {ClientStoreProvider} from '@/components/stores/client'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import Script from 'next/script' import {AppStoreProvider} from '@/components/stores/app'
import {getApiUrl} from '@/actions/base'
import {siteConfig} from '@/config/site'
import {JsonLd} from '@/components/seo/json-ld'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
themeColor: '#3b82f6',
}
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
title: '蓝狐代理', metadataBase: new URL(siteConfig.url),
title: {
default: siteConfig.name,
template: `%s`,
},
description: siteConfig.description,
keywords: siteConfig.keywords,
robots: {
index: true,
follow: true,
googleBot: {
'index': true,
'follow': true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
openGraph: {
type: 'website',
locale: siteConfig.locale,
url: siteConfig.url,
siteName: siteConfig.name,
title: siteConfig.name,
description: siteConfig.description,
images: [
{
url: siteConfig.ogImage.url,
width: siteConfig.ogImage.width,
height: siteConfig.ogImage.height,
alt: siteConfig.name,
},
],
},
twitter: {
card: 'summary_large_image',
title: siteConfig.name,
description: siteConfig.description,
images: [siteConfig.ogImage.url],
},
alternates: {
canonical: siteConfig.url,
},
icons: {
icon: '/favicon.ico',
},
} }
} }
@@ -25,18 +79,29 @@ export default async function RootLayout(props: Readonly<{
<Effects>{props.children}</Effects> <Effects>{props.children}</Effects>
</StoreProviders> </StoreProviders>
<Toaster position="top-center" richColors expand/> <Toaster position="top-center" richColors expand/>
<Script id="qd2852138148beb7882a4a6a3e5ff5b569436003e7dc" src="https://wp.qiye.qq.com/qidian/2852138148/beb7882a4a6a3e5ff5b569436003e7dc" async defer></Script> <JsonLd
schema={{
'@context': 'https://schema.org',
'@type': 'Organization',
'@id': `${siteConfig.url}/#organization`,
'name': siteConfig.name,
'url': siteConfig.url,
'description': siteConfig.description,
}}
/>
</body> </body>
</html> </html>
) )
} }
function StoreProviders(props: {children: ReactNode}) { async function StoreProviders(props: {children: ReactNode}) {
return ( return (
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}> <ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
<LayoutStoreProvider> <LayoutStoreProvider>
<ClientStoreProvider> <ClientStoreProvider>
{props.children} <AppStoreProvider url={await getApiUrl().then(r => r.data)}>
{props.children}
</AppStoreProvider>
</ClientStoreProvider> </ClientStoreProvider>
</LayoutStoreProvider> </LayoutStoreProvider>
</ProfileStoreProvider> </ProfileStoreProvider>

21
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,21 @@
import {MetadataRoute} from 'next'
import {siteConfig} from '@/config/site'
export default function manifest(): MetadataRoute.Manifest {
return {
name: siteConfig.name,
short_name: siteConfig.shortName,
description: siteConfig.description,
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#3b82f6',
icons: [
{
src: '/favicon.ico',
sizes: '48x48',
type: 'image/x-icon',
},
],
}
}

18
src/app/robots.ts Normal file
View File

@@ -0,0 +1,18 @@
import {MetadataRoute} from 'next'
import {siteConfig} from '@/config/site'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/admin/',
'/profile/',
'/settings/',
],
},
sitemap: `${siteConfig.url}/sitemap.xml`,
}
}

178
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,178 @@
import {MetadataRoute} from 'next'
import {siteConfig} from '@/config/site'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = siteConfig.url
const now = new Date()
return [
{
url: baseUrl,
lastModified: now,
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${baseUrl}/product`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: `${baseUrl}/collect`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.8,
},
{
url: `${baseUrl}/custom`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.7,
},
{
url: `${baseUrl}/data-capture`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/e-commerce`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/market-research`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/seo-optimization`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/social-media`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/advertising`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/account-management`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/network-testing`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.7,
},
{
url: `${baseUrl}/docs`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.6,
},
{
url: `${baseUrl}/docs/product/city-lines`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/faqs/faq-general`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/faqs/faq-billing`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/client/android-proxy`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/client/browser-proxy`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/client/ios-proxy`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/client/windows10-proxy`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/news/news-announce`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/news/news-latest`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/operation/extract-link`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/operation/payment-records`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/operation/profile-settings`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/operation/verify-guide`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/docs/operation/whitelist-guide`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.5,
},
{
url: `${baseUrl}/login`,
lastModified: now,
changeFrequency: 'monthly',
priority: 0.3,
},
]
}

View File

@@ -4,7 +4,7 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTr
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
import {useForm, useFormContext, useWatch} from 'react-hook-form' import {useForm, useFormContext} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod' import * as z from 'zod'
import {toast} from 'sonner' import {toast} from 'sonner'
@@ -14,7 +14,6 @@ import dynamic from 'next/dynamic'
// 表单验证规则 // 表单验证规则
const schema = z.object({ const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
captcha: z.string().nonempty('请输入验证码'), captcha: z.string().nonempty('请输入验证码'),
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`), code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
password: z.string().min(6, `密码至少6位`), password: z.string().min(6, `密码至少6位`),
@@ -32,6 +31,7 @@ interface ChangePasswordDialogProps {
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
onSuccess?: () => void onSuccess?: () => void
phone?: string
} }
export function ChangePasswordDialog({ export function ChangePasswordDialog({
@@ -40,12 +40,28 @@ export function ChangePasswordDialog({
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
onSuccess, onSuccess,
phone,
}: ChangePasswordDialogProps) { }: ChangePasswordDialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen || false) const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
const router = useRouter() const router = useRouter()
const actualOpen = open !== undefined ? open : internalOpen const actualOpen = open !== undefined ? open : internalOpen
const actualOnOpenChange = onOpenChange || setInternalOpen const actualOnOpenChange = (open: boolean) => {
if (!open) {
form.reset({
captcha: '',
code: '',
password: '',
confirm_password: '',
})
}
if (onOpenChange) {
onOpenChange(open)
}
else {
setInternalOpen(open)
}
}
// 表单初始化 // 表单初始化
const form = useForm<Schema>({ const form = useForm<Schema>({
@@ -59,7 +75,6 @@ export function ChangePasswordDialog({
), ),
), ),
defaultValues: { defaultValues: {
phone: '',
captcha: '', captcha: '',
code: '', code: '',
password: '', password: '',
@@ -68,10 +83,9 @@ export function ChangePasswordDialog({
}) })
// 提交处理 // 提交处理
const handler = form.handleSubmit(async (value) => { const handler = async (value: Schema) => {
try { try {
const resp = await updatePassword({ const resp = await updatePassword({
phone: value.phone,
code: value.code, code: value.code,
password: value.password, password: value.password,
}) })
@@ -90,7 +104,7 @@ export function ChangePasswordDialog({
description: e instanceof Error ? e.message : String(e), description: e instanceof Error ? e.message : String(e),
}) })
} }
}) }
return ( return (
<Dialog open={actualOpen} onOpenChange={actualOnOpenChange}> <Dialog open={actualOpen} onOpenChange={actualOnOpenChange}>
@@ -98,18 +112,22 @@ export function ChangePasswordDialog({
<Button theme="outline" className={triggerClassName || 'w-24 h-9'}></Button> <Button theme="outline" className={triggerClassName || 'w-24 h-9'}></Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <Form
<DialogTitle></DialogTitle> form={form}
</DialogHeader> handler={async () => {
<Form form={form} handler={handler} className="flex flex-col gap-4 mt-4"> const data = form.getValues()
{/* 手机号输入 */} await handler(data)
<FormField<Schema> name="phone" label="手机号" className="flex-auto"> }}
{({field}) => ( className="flex flex-col gap-4 mt-4">
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/> <DialogHeader>
)} <DialogTitle></DialogTitle>
</FormField> </DialogHeader>
{phone && (
{/* 短信验证码 */} <div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium">{phone}</span>
</div>
)}
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<FormField<Schema> name="code" label="验证码" className="flex-auto"> <FormField<Schema> name="code" label="验证码" className="flex-auto">
{({field}) => ( {({field}) => (
@@ -118,36 +136,35 @@ export function ChangePasswordDialog({
</FormField> </FormField>
<SendMsgByPhone/> <SendMsgByPhone/>
</div> </div>
{/* 新密码 */}
<FormField<Schema> name="password" label="新密码" className="flex-auto"> <FormField<Schema> name="password" label="新密码" className="flex-auto">
{({field}) => ( {({field}) => (
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/> <Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
)} )}
</FormField> </FormField>
{/* 确认密码 */}
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto"> <FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
{({field}) => ( {({field}) => (
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/> <Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
)} )}
</FormField> </FormField>
</Form>
<DialogFooter> <DialogFooter>
<Button <Button
theme="outline" theme="outline"
type="button" type="button"
onClick={() => { onClick={() => {
actualOnOpenChange(false) form.reset({
form.reset() captcha: '',
}}> code: '',
password: '',
</Button> confirm_password: '',
<Button onClick={handler}> })
actualOnOpenChange(false)
</Button> }}>
</DialogFooter>
</Button>
<Button type="submit"></Button>
</DialogFooter>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -155,8 +172,7 @@ export function ChangePasswordDialog({
function SendMsgByPhone() { function SendMsgByPhone() {
const {control} = useFormContext<Schema>() const {control} = useFormContext<Schema>()
const phone = useWatch({control, name: 'phone'}) return <SendMsg/>
return <SendMsg phone={phone}/>
} }
const SendMsg = dynamic(() => import('@/components/send-msg'), {ssr: false}) const SendMsg = dynamic(() => import('@/components/updateSend-msg'), {ssr: false})

File diff suppressed because it is too large Load Diff

View File

@@ -9,29 +9,29 @@ import {Button} from '@/components/ui/button'
import {useForm, useFormContext, useWatch} from 'react-hook-form' import {useForm, useFormContext, useWatch} from 'react-hook-form'
import {Alert, AlertTitle} from '@/components/ui/alert' import {Alert, AlertTitle} from '@/components/ui/alert'
import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react' import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react'
import {memo, ReactNode, useEffect, useRef, useState} from 'react' import {memo, ReactNode, Suspense, use, useEffect, useRef, useState} from 'react'
import {useStatus} from '@/lib/states' import {useStatus} from '@/lib/states'
import {allResource} from '@/actions/resource' import {allResource, getAreaList} from '@/actions/resource'
import {Resource} from '@/lib/models' import {Resource} from '@/lib/models'
import {format, intlFormatDistance} from 'date-fns' import {format, intlFormatDistance} from 'date-fns'
import {toast} from 'sonner' import {toast} from 'sonner'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Combobox} from '@/components/ui/combobox' import {Combobox} from '@/components/ui/combobox'
import cities from './_assets/cities.json'
import ExtractDocs from '@/app/(home)/docs/(product)/api-docs/page.md' import ExtractDocs from '@/app/(home)/docs/(product)/api-docs/page.md'
import Link from 'next/link' import Link from 'next/link'
import {useProfileStore} from '@/components/stores/profile' import {useProfileStore} from '@/components/stores/profile'
const schema = z.object({ const schema = z.object({
resource: z.number({required_error: '请选择套餐'}), resource: z.string({required_error: '请选择套餐'}),
prov: z.string().optional(), prov: z.string().optional(),
city: z.string().optional(), city: z.string().optional(),
regionType: z.enum(['unlimited', 'specific']).default('unlimited'), regionType: z.enum(['unlimited', 'specific']).default('unlimited'),
isp: z.enum(['all', '1', '2', '3'], {required_error: '请选择运营商'}), isp: z.enum(['all', '1', '2', '3'], {required_error: '请选择运营商'}),
proto: z.enum(['all', '1', '2', '3'], {required_error: '请选择协议'}), proto: z.enum(['all', '1', '2'], {required_error: '请选择协议'}),
authType: z.enum(['1', '2'], {required_error: '请选择认证方式'}), authType: z.enum(['1', '2'], {required_error: '请选择认证方式'}),
distinct: z.enum(['1', '0'], {required_error: '请选择去重选项'}), distinct: z.enum(['1', '0'], {required_error: '请选择去重选项'}),
format: z.enum(['text', 'json'], {required_error: '请选择导出格式'}), format: z.enum(['text', 'json'], {required_error: '请选择导出格式'}),
hostFormat: z.enum(['domain', 'ip'], {required_error: '请选择主机格式'}),
separator: z.string({required_error: '请选择分隔符'}), separator: z.string({required_error: '请选择分隔符'}),
breaker: z.string({required_error: '请选择换行符'}), breaker: z.string({required_error: '请选择换行符'}),
count: z.number({required_error: '请输入有效的数量'}).min(1), count: z.number({required_error: '请输入有效的数量'}).min(1),
@@ -53,6 +53,7 @@ export default function Extract(props: ExtractProps) {
authType: '1', authType: '1',
count: 1, count: 1,
distinct: '1', distinct: '1',
hostFormat: 'domain',
format: 'text', format: 'text',
breaker: '13,10', breaker: '13,10',
separator: '124', separator: '124',
@@ -71,20 +72,6 @@ export default function Extract(props: ExtractProps) {
)} )}
> >
<CardSection> <CardSection>
<Alert variant="warn" className="flex items-center justify-between">
<span className="flex items-center gap-2">
<CircleAlert/>
<AlertTitle className="flex text-gray-900">IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
</span>
<Link
href="/admin/whitelist"
className="flex-none text-orange-600 font-medium ml-2 flex gap-0.5 items-center"
>
<span></span>
<ArrowRight className="size-4"/>
</Link>
</Alert>
<FormFields/> <FormFields/>
</CardSection> </CardSection>
@@ -113,7 +100,9 @@ const FormFields = memo(() => {
return ( return (
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]"> <div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
{/* 选择套餐 */} {/* 选择套餐 */}
<SelectResource/> <Suspense>
<SelectResource/>
</Suspense>
{/* 地区筛选 */} {/* 地区筛选 */}
<SelectRegion/> <SelectRegion/>
@@ -161,12 +150,12 @@ const FormFields = memo(() => {
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/> <RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
<span>HTTP</span> <span>HTTP</span>
</FormLabel> </FormLabel>
<FormLabel htmlFor={`${id}-v-https`} className="px-3 h-10 border rounded-md flex items-center text-sm"> {/* <FormLabel htmlFor={`${id}-v-https`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/> <RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
<span>HTTPS</span> <span>HTTPS</span>
</FormLabel> </FormLabel> */}
<FormLabel htmlFor={`${id}-v-socks5`} className="px-3 h-10 border rounded-md flex items-center text-sm"> <FormLabel htmlFor={`${id}-v-socks5`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/> <RadioGroupItem value="2" id={`${id}-v-socks5`} className="mr-2"/>
<span>SOCKS5</span> <span>SOCKS5</span>
</FormLabel> </FormLabel>
</RadioGroup> </RadioGroup>
@@ -231,6 +220,26 @@ const FormFields = memo(() => {
)} )}
</FormField> </FormField>
{/* 主机格式 */}
<FormField name="hostFormat" className="md:max-w-[calc(160px*2+1rem)]" label="主机格式" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-domain`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="domain" id={`${id}-v-domain`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-ip`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="ip" id={`${id}-v-ip`} className="mr-2"/>
<span>IP</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 分隔符 */} {/* 分隔符 */}
<FormField name="separator" className="md:max-w-[calc(160px*3+1rem*2)]" label="分隔符" classNames={{label: 'max-md:text-sm'}}> <FormField name="separator" className="md:max-w-[calc(160px*3+1rem*2)]" label="分隔符" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => ( {({id, field}) => (
@@ -332,12 +341,16 @@ FormFields.displayName = 'FormFields'
function SelectResource() { function SelectResource() {
const [resources, setResources] = useState<Resource[]>([]) const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus() const [status, setStatus] = useStatus()
const profile = useProfileStore(state => state.profile) const profile = use(useProfileStore(store => store.profile))
const getResources = async () => { const getResources = async () => {
if (!profile) {
setStatus('done')
setResources([])
return
}
setStatus('load') setStatus('load')
try { try {
const resp = await allResource() const resp = await allResource()
if (!resp.success) { if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试') throw new Error('获取套餐失败,请稍后再试')
} }
@@ -353,14 +366,14 @@ function SelectResource() {
useEffect(() => { useEffect(() => {
getResources().then() getResources().then()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [profile])
return ( return (
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}> <FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
{({field}) => ( {({field}) => (
<Select <Select
value={field.value ? String(field.value) : undefined} value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))} onValueChange={value => field.onChange(value)}
> >
<SelectTrigger className="min-h-10 h-auto w-full"> <SelectTrigger className="min-h-10 h-auto w-full">
<SelectValue placeholder="选择套餐"/> <SelectValue placeholder="选择套餐"/>
@@ -373,110 +386,109 @@ function SelectResource() {
</div> </div>
) : !profile ? ( ) : !profile ? (
<div className="p-4 flex gap-1 items-center"> <div className="p-4 flex gap-1 items-center">
<Loader className="animate-spin" size={20}/> <span className="text-gray-600"><Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium"></Link></span>
<span><Link href="/login" className="text-blue-600 hover:underline"></Link></span>
</div> </div>
) : resources.length === 0 ? ( ) : resources.length === 0 ? (
<div className="p-4 flex gap-1 items-center"> <div className="p-4 flex gap-1 items-center">
<Loader className="animate-spin" size={20}/>
<span></span> <span></span>
</div> </div>
) : resources.map((resource, i) => ( ) : (
<> <>
<SelectItem {resources.map(resource => (
key={`${resource.id}`} <SelectItem
value={String(resource.id)} key={resource.id}
className="p-3"> value={String(resource.resource_no)}
<div className="flex flex-col gap-2 w-72"> className="p-3">
{resource.type === 1 && resource.short.type === 1 && ( <div className="flex flex-col gap-2 w-72">
<> {resource.type === 1 && resource.short.type === 1 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Timer size={20}/> <div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Timer size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
</span> {format(resource.short.expire_at, 'yyyy-MM-dd HH:mm:ss')}
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span> </span>
</div> <span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
</> </div>
)} </>
{resource.type === 1 && resource.short.type === 2 && ( )}
<> {resource.type === 1 && resource.short.type === 2 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Box size={20}/> <div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Box size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.short.used}
{' '} {resource.short.used}
/ {' '}
{resource.short.quota} /
</span> {resource.short.quota}
<span> </span>
<span>
{resource.short.quota - resource.short.used}
</span> {resource.short.quota - resource.short.used}
</div> </span>
</> </div>
)} </>
{resource.type === 2 && resource.long.type === 1 && ( )}
<> {resource.type === 2 && resource.long.type === 1 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Timer size={20}/> <div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Timer size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
</span> {format(resource.long.expire_at, 'yyyy-MM-dd HH:mm:ss')}
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span> </span>
</div> <span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
</> </div>
)} </>
{resource.type === 2 && resource.long.type === 2 && ( )}
<> {resource.type === 2 && resource.long.type === 2 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Box size={20}/> <div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Box size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.long.used}
{' '} {resource.long.used}
/ {' '}
{resource.long.quota} /
</span> {resource.long.quota}
<span> </span>
<span>
{resource.long.quota - resource.long.used}
</span> {resource.long.quota - resource.long.used}
</div> </span>
</> </div>
)} </>
</div> )}
</SelectItem> </div>
{i < resources.length - 1 && <SelectSeparator className="m-1"/>} </SelectItem>
))}
</> </>
))} )}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@@ -484,11 +496,62 @@ function SelectResource() {
) )
} }
type AreaItem = {
id: number
parent_id: number
level: number
name: string
created_at: string
updated_at: string
}
function AreaTree(flatList: AreaItem[]) {
const provinces = flatList.filter(item => item.level === 1)
const cities = flatList.filter(item => item.level === 2)
return provinces.map(prov => ({
value: String(prov.id),
label: prov.name,
children: cities
.filter(city => city.parent_id === prov.id)
.map(city => ({
value: String(city.id),
label: city.name,
})),
}))
}
function SelectRegion() { function SelectRegion() {
const {control, setValue} = useFormContext<Schema>() const {control, setValue} = useFormContext<Schema>()
const regionType = useWatch({control, name: 'regionType'}) const regionType = useWatch({control, name: 'regionType'})
const prov = useWatch({control, name: 'prov'}) const prov = useWatch({control, name: 'prov'})
const city = useWatch({control, name: 'city'}) const city = useWatch({control, name: 'city'})
const [options, setOptions] = useState<ReturnType<typeof AreaTree>>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (regionType === 'specific') {
const fetchData = async () => {
setLoading(true)
try {
const req = await getAreaList({})
console.log(req, 'req')
if (req.success && req.data) {
setOptions(AreaTree(req.data))
}
}
catch (error) {
toast.error('无法选择区域')
}
finally {
setLoading(false)
}
}
fetchData()
}
}, [regionType])
return ( return (
<div className="flex flex-col gap-4 md:max-w-[calc(160px*2+1rem)]"> <div className="flex flex-col gap-4 md:max-w-[calc(160px*2+1rem)]">
@@ -518,15 +581,22 @@ function SelectRegion() {
</FormField> </FormField>
{regionType === 'specific' && ( {regionType === 'specific' && (
<Combobox loading ? (
placeholder="请选择地区" <div className="flex gap-2 items-center">
options={cities.options} <Loader className="animate-spin" size={16}/>
value={[prov || '', city || '']} <span className="text-sm text-weak">...</span>
onChange={(value) => { </div>
setValue('prov', value[0]) ) : (
setValue('city', value[1]) <Combobox
}} placeholder="请选择地区"
/> options={options}
value={[prov || '', city || '']}
onChange={(value) => {
setValue('prov', value[0] || '')
setValue('city', value[1] || '')
}}
/>
)
)} )}
</div> </div>
) )
@@ -599,25 +669,40 @@ function ApplyLink() {
} }
return ( return (
<div className={merge( <div className="flex flex-col gap-3 rounded-lg">
`flex flex-col gap-4`, <Alert variant="warn" className="flex items-center justify-between">
`rounded-lg`, <div className="flex items-center gap-2">
)}> <CircleAlert className="size-4 shrink-0"/>
<h4>API </h4> <AlertTitle className="text-orange-600">
IP IP 使
</AlertTitle>
</div>
<Link href="/admin/whitelist" className="flex-none text-orange-600 font-medium flex items-center gap-1">
<span></span>
<ArrowRight className="size-4"/>
</Link>
</Alert>
{/* 展示链接地址 */} <Alert className="flex items-center justify-between">
<div className="bg-secondary p-4 rounded-md break-all"> <div className="flex items-center gap-2">
<CircleAlert className="size-4 shrink-0"/>
<AlertTitle> socks5 http </AlertTitle>
</div>
<div className="w-[88px]"/>
</Alert>
<h4 className="text-base font-medium">API </h4>
<div className="bg-gray-100 rounded-md p-4 break-all font-mono text-sm">
{link(form.getValues())} {link(form.getValues())}
</div> </div>
{/* 操作 */} <div className="flex gap-3">
<div className="flex gap-4"> <Button type="button" onClick={() => submit('copy')} className="gap-1">
<Button type="button" onClick={() => submit('copy')}> <CopyIcon className="size-4"/>
<CopyIcon/>
<span></span> <span></span>
</Button> </Button>
<Button type="button" onClick={() => submit('open')}> <Button type="button" onClick={() => submit('open')} className="gap-1">
<ExternalLinkIcon/> <ExternalLinkIcon className="size-4"/>
<span></span> <span></span>
</Button> </Button>
</div> </div>
@@ -626,18 +711,20 @@ function ApplyLink() {
} }
function link(values: Schema) { function link(values: Schema) {
const {resource, prov, city, isp, proto, authType, distinct, format: formatType, separator, breaker, count} = values const {resource, prov, city, isp, proto, authType, distinct, format: formatType, hostFormat, separator, breaker, count} = values
console.log(values, 'values')
const sp = new URLSearchParams() const sp = new URLSearchParams()
if (resource) sp.set('i', String(resource)) if (resource) sp.set('i', String(resource))
if (authType) sp.set('t', authType) if (authType) sp.set('t', authType)
if (proto != 'all') sp.set('x', proto) if (proto != 'all') sp.set('x', proto)
if (prov) sp.set('a', prov) if (prov) sp.set('b', prov)
if (city) sp.set('b', city) if (city) sp.set('b', city)
if (isp != 'all') sp.set('s', isp) if (isp != 'all') sp.set('s', isp)
sp.set('d', distinct) sp.set('d', distinct)
sp.set('rt', formatType) sp.set('rt', formatType)
sp.set('rh', hostFormat)
sp.set('rs', separator) sp.set('rs', separator)
sp.set('rb', breaker) sp.set('rb', breaker)
sp.set('n', String(count)) sp.set('n', String(count))
@@ -651,9 +738,9 @@ function name(resource: Resource) {
// 短效套餐 // 短效套餐
switch (resource.short.type) { switch (resource.short.type) {
case 1: case 1:
return `短效包时 ${resource.short.live / 60} 分钟` return `${resource.short?.sku?.name}`
case 2: case 2:
return `短效包量 ${resource.short.live / 60} 分钟` return `${resource.short?.sku?.name}`
} }
break break
@@ -661,9 +748,9 @@ function name(resource: Resource) {
// 长效套餐 // 长效套餐
switch (resource.long.type) { switch (resource.long.type) {
case 1: case 1:
return `长效包时 ${resource.long.live} 小时` return `${resource.long?.sku?.name}`
case 2: case 2:
return `长效包量 ${resource.long.live} 小时` return `${resource.long?.sku?.name}`
} }
break break
} }

View File

@@ -5,8 +5,10 @@ import {Loader} from 'lucide-react'
import {useState} from 'react' import {useState} from 'react'
import Image from 'next/image' import Image from 'next/image'
import {PaymentModalProps} from './payment-modal' import {PaymentModalProps} from './payment-modal'
import {getTradeMethodDecoration} from '@/lib/models/trade'
export function DesktopPayment(props: PaymentModalProps) { export function DesktopPayment(props: PaymentModalProps) {
const decoration = getTradeMethodDecoration(props.method)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const onSubmit = async () => { const onSubmit = async () => {
@@ -19,10 +21,10 @@ export function DesktopPayment(props: PaymentModalProps) {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex gap-2 items-center"> <DialogTitle className="flex gap-2 items-center">
{props.decoration.icon ? ( {decoration.icon ? (
<Image <Image
src={props.decoration.icon} src={decoration.icon}
alt={props.decoration.text} alt={decoration.text}
width={24} width={24}
height={24} height={24}
className="rounded-md" className="rounded-md"
@@ -30,7 +32,7 @@ export function DesktopPayment(props: PaymentModalProps) {
) : ( ) : (
<div className="w-6 h-6 bg-gray-200 rounded-full"/> <div className="w-6 h-6 bg-gray-200 rounded-full"/>
)} )}
<span>{props.decoration.text}</span> <span>{decoration.text}</span>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -43,8 +45,8 @@ export function DesktopPayment(props: PaymentModalProps) {
/> />
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
使 使
{props.decoration.text} {decoration.text}
{/* 扫码支付 */}
</p> </p>
<div className="w-full text-center space-y-2"> <div className="w-full text-center space-y-2">

View File

@@ -6,8 +6,10 @@ import {CreditCard, Loader} from 'lucide-react'
import {useState} from 'react' import {useState} from 'react'
import Image from 'next/image' import Image from 'next/image'
import {PaymentModalProps} from './payment-modal' import {PaymentModalProps} from './payment-modal'
import {getTradeMethodDecoration} from '@/lib/models/trade'
export function MobilePayment(props: PaymentModalProps) { export function MobilePayment(props: PaymentModalProps) {
const decoration = getTradeMethodDecoration(props.method)
const [loading, setLoading] = useState(false) // 加载状态 const [loading, setLoading] = useState(false) // 加载状态
const [paymentInitiated, setPaymentInitiated] = useState(false) // 是否已发起支付 const [paymentInitiated, setPaymentInitiated] = useState(false) // 是否已发起支付
@@ -54,16 +56,16 @@ export function MobilePayment(props: PaymentModalProps) {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600"></span> <span className="text-gray-600"></span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{props.decoration.icon && ( {decoration.icon && (
<Image <Image
src={props.decoration.icon} src={decoration.icon}
alt={props.decoration.text} alt={decoration.text}
width={28} width={28}
height={28} height={28}
className="rounded-md" className="rounded-md"
/> />
)} )}
<span>{props.decoration.text}</span> <span>{decoration.text}</span>
</div> </div>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">

View File

@@ -6,7 +6,8 @@ import {Dialog} from '@/components/ui/dialog'
import {PaymentProps} from './type' import {PaymentProps} from './type'
import {payClose} from '@/actions/resource' import {payClose} from '@/actions/resource'
import {useEffect} from 'react' import {useEffect} from 'react'
import {useRouter} from 'next/navigation' import {UniversalDesktopPayment} from './universal-desktop-payment'
import {useAppStore} from '@/components/stores/app'
export type PaymentModalProps = { export type PaymentModalProps = {
onConfirm: (showFail: boolean) => Promise<void> onConfirm: (showFail: boolean) => Promise<void>
@@ -16,27 +17,28 @@ export type PaymentModalProps = {
export function PaymentModal(props: PaymentModalProps) { export function PaymentModal(props: PaymentModalProps) {
// 手动关闭时的处理 // 手动关闭时的处理
const handleClose = async () => { const handleClose = async () => {
try { // try {
const res = await payClose({ // const res = await payClose({
trade_no: props.inner_no, // trade_no: props.inner_no,
method: props.method, // method: props.method,
}) // })
if (!res.success) { // if (!res.success) {
throw new Error(res.message) // throw new Error(res.message)
} // }
} // }
catch (error) { // catch (error) {
console.error('关闭订单失败:', error) // console.error('关闭订单失败:', error)
} // }
finally { // finally {
props.onClose?.() props.onClose?.()
} // }
} }
// SSE处理方式检查支付状态 // SSE处理方式检查支付状态
const apiUrl = useAppStore('apiUrl')
useEffect(() => { useEffect(() => {
const eventSource = new EventSource( const eventSource = new EventSource(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`, `${apiUrl}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
) )
eventSource.onmessage = async (event) => { eventSource.onmessage = async (event) => {
switch (event.data) { switch (event.data) {
@@ -53,7 +55,7 @@ export function PaymentModal(props: PaymentModalProps) {
return () => { return () => {
eventSource.close() eventSource.close()
} }
}, [props]) }, [apiUrl, props])
return ( return (
<Dialog <Dialog
@@ -61,17 +63,13 @@ export function PaymentModal(props: PaymentModalProps) {
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) handleClose() if (!open) handleClose()
}}> }}>
{props.platform === TradePlatform.Mobile ? (
<MobilePayment {props.platform === TradePlatform.Mobile
{...props} ? <MobilePayment {...props} onClose={handleClose}/>
onClose={handleClose} : <DesktopPayment {...props} onClose={handleClose}/>
/> }
) : (
<DesktopPayment {/* <UniversalDesktopPayment {...props} onClose={handleClose}/> */}
{...props}
onClose={handleClose}
/>
)}
</Dialog> </Dialog>
) )
} }

View File

@@ -8,8 +8,4 @@ export type PaymentProps = {
amount: number amount: number
platform: TradePlatform platform: TradePlatform
method: TradeMethod method: TradeMethod
decoration: {
icon: StaticImageData
text: string
}
} }

View File

@@ -0,0 +1,83 @@
'use client'
import {DialogClose, DialogContent, DialogHeader, DialogTitle} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button'
import {Loader} from 'lucide-react'
import {useState} from 'react'
import Image from 'next/image'
import {PaymentModalProps} from './payment-modal'
import {getTradeMethodDecoration, TradePlatform} from '@/lib/models/trade'
export function UniversalDesktopPayment(props: PaymentModalProps) {
const decoration = getTradeMethodDecoration(props.method)
const [loading, setLoading] = useState(false)
const onSubmit = async () => {
setLoading(true)
await props.onConfirm(true)
setLoading(false)
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex gap-2 items-center">
{decoration.icon ? (
<Image
src={decoration.icon}
alt={decoration.text}
width={24}
height={24}
className="rounded-md"
/>
) : (
<div className="w-6 h-6 bg-gray-200 rounded-full"/>
)}
<span>{decoration.text}</span>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4">
<Image
src={props.pay_url}
width={208}
height={208}
alt="二维码"
/>
<div className="flex-none flex flex-col gap-1 items-center">
<p className="text-sm text-gray-600">
使
</p>
<p className="text-sm text-gray-600">
</p>
</div>
<div className="w-full text-center space-y-2">
<p className="text-sm font-medium">
:
<span className="text-accent">
¥
{props.amount?.toFixed(2) || '0.00'}
</span>
</p>
<p className="text-xs text-gray-500">
:
{props.inner_no}
</p>
</div>
<div className="flex gap-4 w-full justify-center">
<Button onClick={onSubmit}>
{loading && <Loader className="animate-spin mr-2"/>}
</Button>
<DialogClose asChild>
<Button theme="outline" onClick={() => props.onClose?.()}>
</Button>
</DialogClose>
</div>
</div>
</DialogContent>
)
}

View File

@@ -1,244 +0,0 @@
'use client'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {useForm} from 'react-hook-form'
import {z} from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import Image from 'next/image'
import check from '@/assets/check-accent.svg'
import banner from '../_assets/Mask-group.webp'
import group from '../_assets/Group.webp'
import {merge} from '@/lib/utils'
import FreeTrial from '@/components/free-trial'
const formSchema = z.object({
companyName: z.string().min(2, '企业名称至少2个字符'),
contactName: z.string().min(2, '联系人姓名至少2个字符'),
phone: z.string().min(11, '请输入11位手机号码').max(11, '手机号码长度不正确'),
monthlyUsage: z.string().min(1, '请选择您需要的用量'),
purpose: z.string().min(1, '输入用途'),
})
type FormValues = z.infer<typeof formSchema>
export default function CollectPage() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
companyName: '',
contactName: '',
phone: '',
monthlyUsage: '',
purpose: '',
},
})
return (
<>
<div className="bg-white rounded-lg shadow-md overflow-hidden p-6">
<div className="text-center mb-4">
<h1 className="text-2xl font-bold">IP服务商</h1>
<p className="text-gray-600 font-medium mt-2">
IP代理使用体验
</p>
</div>
<div className="flex flex-col md:flex-row md:gap-4">
<div className="w-full md:w-1/3 mb-6 md:mb-0">
<div className="relative h-full w-full min-h-[200px] md:min-h-[300px] rounded-xl overflow-hidden">
<Image
src={banner}
alt="宣传图"
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
</div>
<div className="w-full md:w-2/3 flex flex-col gap-4">
<p className="text-sm md:text-base text-gray-600 leading-relaxed">
IP领域访IP资源IP稳定使7×24
</p>
<div className="mt-2 md:mt-4">
<Button className="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white px-4 md:px-6 py-2 md:py-3 rounded-md">
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mt-2 md:mt-6">
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
</div>
</div>
</div>
</div>
<div className="text-center">
<h2 className="text-2xl font-semibold mb-6 mt-6"></h2>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<Form form={form}>
<div className="mx-auto max-w-xl space-y-6">
{/* 企业名称 */}
<FormField name="companyName">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入企业名称"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 联系人姓名 */}
<FormField name="contactName">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入联系人姓名"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 联系人手机号码 */}
<FormField name="phone">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入手机号码"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 每月需求用量 */}
<FormField name="monthlyUsage">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger
id={id}
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs">
<SelectValue placeholder="请选择您需要的用量"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="less20">20</SelectItem>
<SelectItem value="20-100">20~100</SelectItem>
<SelectItem value="100-500">100~500</SelectItem>
<SelectItem value="more500">500</SelectItem>
</SelectContent>
</Select>
</div>
)}
</FormField>
{/* 用途 */}
<FormField name="purpose">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入用途,例如:爬虫"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
<div className="pt-4 flex justify-center">
<Button type="submit" className="bg-blue-600 hover:bg-blue-700 px-8">
</Button>
</div>
</div>
</Form>
</div>
<div className="relative mt-8 rounded-lg overflow-hidden">
<div className="h-40 md:h-48 relative">
<div
className="absolute inset-0 bg-no-repeat"
style={{
backgroundImage: `url(${group.src})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full max-w-4xl px-6 flex flex-col md:flex-row items-center gap-4 justify-between md:gap-10">
<div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left">
5000IP
</div>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}/>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -1,11 +1,13 @@
'use client' 'use client'
import {ReactNode} from 'react' import {ReactNode, use, useEffect, useState} from 'react'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import LongForm from '@/components/composites/purchase/long/form' import LongForm from '@/components/composites/purchase/long/form'
import ShortForm from '@/components/composites/purchase/short/form' import ShortForm from '@/components/composites/purchase/short/form'
import {usePathname, useRouter, useSearchParams} from 'next/navigation' import {usePathname, useRouter, useSearchParams} from 'next/navigation'
import SelfDesc from '@/components/features/self-desc' import SelfDesc from '@/components/features/self-desc'
import {listProduct, listProductHome, ProductItem} from '@/actions/product'
import {useProfileStore} from '@/components/stores/profile'
export type TabType = 'short' | 'long' | 'fixed' | 'custom' export type TabType = 'short' | 'long' | 'fixed' | 'custom'
export default function Purchase() { export default function Purchase() {
@@ -13,35 +15,57 @@ export default function Purchase() {
const path = usePathname() const path = usePathname()
const params = useSearchParams() const params = useSearchParams()
const tab = params.get('type') as TabType || 'short' const [productList, setProductList] = useState<ProductItem[]>([])
const tab = (params.get('type') as TabType) || productList[0]?.code || 'short'
const updateTab = (tab: string) => { const updateTab = (tab: string) => {
const newParams = new URLSearchParams(params) const newParams = new URLSearchParams(params)
newParams.set('type', tab) newParams.set('type', tab)
router.push(`${path}?${newParams.toString()}`) router.push(`${path}?${newParams.toString()}`)
} }
const profile = use(useProfileStore(store => store.profile))
useEffect(() => {
const fetchProducts = async () => {
const res = profile
? await listProduct({})
: await listProductHome({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [profile])
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
short: ShortForm,
long: LongForm,
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Tabs value={tab} onValueChange={updateTab} className="gap-4"> <Tabs value={tab} onValueChange={updateTab} className="gap-4">
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto"> <TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
<Tab value="short"></Tab> {productList.map(item => (
<Tab value="long"></Tab> <Tab key={item.code} value={item.code}>
<Tab value="fixed"></Tab> {item.name}
</Tab>
))}
{/* 固定的定制套餐tab */}
<Tab value="custom"></Tab> <Tab value="custom"></Tab>
</TabsList> </TabsList>
<TabsContent value="short"> {productList.map((item) => {
<ShortForm/> const Component = componentMap[item.code]
</TabsContent> const skuList = item.skus || []
<TabsContent value="long"> return (
<LongForm/> <TabsContent key={item.code} value={item.code}>
</TabsContent> {Component ? <Component skuList={skuList}/> : <div></div>}
<TabsContent value="fixed"> </TabsContent>
</TabsContent> )
})}
<TabsContent value="custom"> <TabsContent value="custom">
<SelfDesc onInquiry={() => { <SelfDesc onInquiry={() => router.push('/custom')}/>
router.push('/custom')
}}/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -1,246 +1,184 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react'
import FormOption from '@/components/composites/purchase/option' import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import check from '../_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form' import {Schema} from '@/components/composites/purchase/long/form'
import {useEffect, useMemo} from 'react'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card' import {Card} from '@/components/ui/card'
import {useEffect} from 'react' import {BillingMethodField} from '../shared/billing-method-field'
import {FeatureList} from '../shared/feature-list'
import {NumberStepperField} from '../shared/number-stepper-field'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({skuData}: {
skuData: PurchaseSkuData
}) {
const {setValue, getValues} = useFormContext<Schema>()
const type = useWatch<Schema>({name: 'type'}) as Schema['type']
const live = useWatch<Schema>({name: 'live'}) as Schema['live']
const expire = useWatch<Schema>({name: 'expire'}) as Schema['expire']
const {modeList, priceMap} = skuData
const liveList = type === '1'
? getAvailablePurchaseLives(skuData, {mode: type, expire})
: getAvailablePurchaseLives(skuData, {mode: type})
const expireList = type === '1'
? getAvailablePurchaseExpires(skuData, {mode: type, live})
: []
const currentCountMin = useMemo(() => {
if (!type || !live) return 0
const expireValue = type === '1' ? expire : '0'
const countMin = getPurchaseSkuCountMin(skuData, {
mode: type,
live,
expire: expireValue,
})
return countMin
}, [type, live, expire, skuData])
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => { useEffect(() => {
if (type === '1') { if (currentCountMin <= 0) return
form.setValue('daily_limit', 100) const targetField = type === '1' ? 'daily_limit' : 'quota'
const currentValue = getValues(targetField)
if (currentValue !== currentCountMin) {
setValue(targetField, currentCountMin, {shouldValidate: true})
} }
else { }, [currentCountMin, type, setValue, getValues])
form.setValue('quota', 500)
useEffect(() => {
const nextType = modeList.includes(type) ? type : modeList[0]
if (!nextType) {
return
} }
}, [type, form]) if (nextType !== type) {
setValue('type', nextType)
return
}
const nextLiveList = nextType === '1'
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
: getAvailablePurchaseLives(skuData, {mode: nextType})
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
if (nextLive && nextLive !== live) {
setValue('live', nextLive)
return
}
if (nextType === '2') {
if (expire !== '0') {
setValue('expire', '0')
}
return
}
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
setValue('expire', nextExpireList[0])
}
}, [expire, live, modeList, setValue, skuData, type])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-10 relative">
<div className="text-center -mb-10"> HTTPsocks5 </div>
<BillingMethodField modeList={modeList} timeDailyLimit={100}/>
{/* 套餐时效 */}
{type === '1' && (
<FormField name="expire" label="套餐有效时间" description="有效时间内可用于提取 IP">
{({id, field}) => (
<RadioGroup
id={id}
value={field.value}
onValueChange={(value) => {
field.onChange(value)
{/* 计费方式 */} const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
<FormField if (!nextLiveList.includes(live) && nextLiveList[0]) {
className="flex flex-col gap-4" setValue('live', nextLiveList[0])
name="type" }
label="计费方式"> }}
{({id, field}) => ( className="flex gap-4 flex-wrap">
<RadioGroup {expireList.map(day => (
id={id} <FormOption
defaultValue={field.value} key={day}
onValueChange={field.onChange} id={`${id}-${day}`}
className="flex gap-4 max-md:flex-col"> value={day}
label={`${day}`}
<FormOption compare={field.value}
id={`${id}-2`}
value="2"
label="包量套餐"
description="适用于短期或不定期高提取业务场景"
compare={field.value}/>
<FormOption
id={`${id}-1`}
value="1"
label="包时套餐"
description="适用于每日提取量稳定的业务场景"
compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* IP 时效 */}
<FormField
className="space-y-4"
name="live"
label="IP 时效">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
<FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/>
<FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/>
<FormOption id={`${id}-8`} value="8" label="8 小时" description="¥1.2/IP" compare={field.value}/>
<FormOption id={`${id}-12`} value="12" label="12 小时" description="¥1.8/IP" compare={field.value}/>
<FormOption id={`${id}-24`} value="24" label="24 小时" description="¥3.5/IP" compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* 根据套餐类型显示不同表单项 */}
{type === '2' ? (
/* 包量IP 购买数量 */
<FormField
className="space-y-4"
name="quota"
label="IP 购买数量">
{({id, field}) => {
const value = Number(field.value) || 500
const minValue = 500
const step = 100
return (
<div className="flex gap-2 items-center">
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('quota', Math.max(minValue, value - step))}
disabled={value === minValue}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={minValue}
step={step}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 500) {
form.setValue('quota', 500)
}
}}
/> />
<Button ))}
theme="outline" </RadioGroup>
type="button" )}
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('quota', value + step)}>
<Plus/>
</Button>
</div>
)
}}
</FormField> </FormField>
) : (
<>
{/* 包时:套餐时效 */}
<FormField
className="space-y-4"
name="expire"
label="套餐时效">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex gap-4 flex-wrap">
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* 包时:每日提取上限 */}
<FormField
className="space-y-4"
name="daily_limit"
label="每日提取上限">
{({id, field}) => {
const value = Number(field.value) || 100
const minValue = 100
const step = 100
return (
<div className="flex gap-2 items-center">
<Button
theme="outline"
type="button"
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg ${
value === minValue ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => form.setValue('daily_limit', Math.max(minValue, value - step))}
disabled={value === minValue}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={100}
step={100}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 100) {
form.setValue('daily_limit', 100)
}
}}
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('daily_limit', value + step)}>
<Plus/>
</Button>
</div>
)
}}
</FormField>
</>
)} )}
{/* 产品特性 */} {/* IP 时效 */}
<div className="space-y-6"> <FormField<Schema, 'live'>
<h3></h3> name="live"
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 gap-y-6"> label="IP 有效时间"
<p className="flex gap-2 items-center"> description="提取出的 IP 可用时间"
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> >
<span className="text-sm text-gray-500"></span> {({id, field}) => (
</p> <RadioGroup
<p className="flex gap-2 items-center"> id={id}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> value={field.value}
<span className="text-sm text-gray-500"></span> onValueChange={(value) => {
</p> field.onChange(value)
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> if (type !== '1') {
<span className="text-sm text-gray-500"></span> return
</p> }
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
<span className="text-sm text-gray-500">API接口</span> if (!nextExpireList.includes(expire) && nextExpireList[0]) {
</p> setValue('expire', nextExpireList[0])
<p className="flex gap-2 items-center"> }
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> }}
<span className="text-sm text-gray-500">IP时效3-30()</span> className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
</p> {liveList.map((live) => {
<p className="flex gap-2 items-center"> const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> ? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
<span className="text-sm text-gray-500">IP资源定期筛选</span> : String(expire)
</p> const price = getPurchaseSkuPrice(priceMap, {
<p className="flex gap-2 items-center"> mode: type,
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> live,
<span className="text-sm text-gray-500">API接口</span> expire: priceExpire,
</p> })
<p className="flex gap-2 items-center"> return (
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <FormOption
<span className="text-sm text-gray-500">/</span> key={live}
</p> id={`${id}-${live}`}
<p className="flex gap-2 items-center"> value={live}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> label={`${Number(live) / 60} 小时`}
<span className="text-sm text-gray-500">500</span> description={price && `${price}/IP`}
</p> compare={field.value}
</div> />
</div> )
})}
</RadioGroup>
)}
</FormField>
{/* 每日提取上限/购买数量 */}
{type === '1' ? (
<NumberStepperField
name="daily_limit"
label="IP 每日提取上限"
description="本套餐每日可提取 IP 的最大数量"
min={currentCountMin}
step={100}
/>
) : (
<NumberStepperField
name="quota"
label="IP 总提取上限"
description="本套餐总计可提取 IP 的最大数量"
min={currentCountMin}
step={100}
/>
)}
<FeatureList/>
</Card> </Card>
) )
} }

View File

@@ -1,51 +1,52 @@
'use client' 'use client'
import {createContext} from 'react' import {useForm} from 'react-hook-form'
import {useForm, UseFormReturn} from 'react-hook-form'
import Center from '@/components/composites/purchase/long/center' import Center from '@/components/composites/purchase/long/center'
import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['1', '4', '8', '12', '24']), live: z.string(),
quota: z.number().min(500, '购买数量不能少于 500 个'), quota: z.number().min(1, '购买数量不能少于 1 个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.string(),
daily_limit: z.number().min(100, '每日限额不能少于 100 个'), daily_limit: z.number().min(1, '每日限额不能少于 1 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = { export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
form: UseFormReturn<Schema> const skuData = parsePurchaseSkuList('long', skuList)
onSubmit?: () => void const defaultMode = skuData.modeList.includes('1') ? '1' : '2'
} const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
const defaultExpire = defaultMode === '1'
? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
: '0'
const defaultCountMin = getPurchaseSkuCountMin(skuData, {
mode: defaultMode,
live: defaultLive,
expire: defaultExpire,
})
export const LongFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
export default function LongForm() {
const form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: '1', // 小时 live: defaultLive,
quota: 500, expire: defaultExpire,
expire: '30', // 天 quota: defaultCountMin,
daily_limit: 100, daily_limit: defaultCountMin,
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<LongFormContext.Provider value={{form}}> <Center skuData={skuData}/>
<Center/> <PurchaseSidePanel kind="long"/>
<Right/>
</LongFormContext.Provider>
</Form> </Form>
) )
} }

View File

@@ -1,232 +0,0 @@
'use client'
import {Suspense, use, useContext, useEffect, useMemo, useState} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge'
import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/long/form'
import {Card} from '@/components/ui/card'
import {getPrice, CreateResourceReq} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
export default function Right() {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const mode = useWatch({control, name: 'type'})
const live = useWatch({control, name: 'live'})
const quota = useWatch({control, name: 'quota'})
const expire = useWatch({control, name: 'expire'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const resp = await getPrice({
type: 2,
long: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!resp.success) {
throw new Error('获取价格失败')
}
setPriceData({
price: resp.data.price,
discounted_price: resp.data.discounted_price ?? resp.data.price ?? '',
discounted: resp.data.discounted,
})
}
catch (error) {
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [dailyLimit, expire, live, quota, mode])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
)}>
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{mode === '2' ? `包量套餐` : `包时套餐`}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">
{live}
{' '}
</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{expire}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{dailyLimit}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span className="text-xl text-orange-500">
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
discountedPrice: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500">账户余额</span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span>
<RechargeModal/>
</p>
</div> */}
{/* <FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span>余额</span>
</FormOption> */}
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={props.method}
balance={profile.balance}
amount={props.discountedPrice}
resource={{
type: 2,
long: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

@@ -11,11 +11,10 @@ import {useRouter} from 'next/navigation'
import {completeResource, createResource, CreateResourceReq, prepareResource} from '@/actions/resource' import {completeResource, createResource, CreateResourceReq, prepareResource} from '@/actions/resource'
import { import {
TradeMethod, TradeMethod,
TradeMethodDecoration, TradePlatform,
} from '@/lib/models/trade' } from '@/lib/models/trade'
import {PaymentModal} from '@/components/composites/payment/payment-modal' import {PaymentModal} from '@/components/composites/payment/payment-modal'
import {PaymentProps} from '@/components/composites/payment/type' import {PaymentProps} from '@/components/composites/payment/type'
import {usePlatformType} from '@/lib/hooks'
export type PayProps = { export type PayProps = {
amount: string amount: string
@@ -32,37 +31,35 @@ export default function Pay(props: PayProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [trade, setTrade] = useState<PaymentProps | null>(null) const [trade, setTrade] = useState<PaymentProps | null>(null)
const router = useRouter() const router = useRouter()
const platform = usePlatformType()
const onOpen = async () => { const onOpen = async () => {
setOpen(true) setOpen(true)
if (props.method === 'balance') return if (props.method === 'balance') {
return
}
const method = props.method === 'alipay' const method = props.method === 'alipay'
? TradeMethod.SftAlipay ? TradeMethod.SftAlipay
: TradeMethod.SftWechat : TradeMethod.SftWechat
const req = { const response = await prepareResource({
...props.resource, ...props.resource,
payment_method: method, payment_method: method,
payment_platform: platform, payment_platform: TradePlatform.Desktop,
} })
const resp = await prepareResource(req) if (!response.success) {
toast.error(`创建订单失败: ${response.message}`)
if (!resp.success) {
toast.error(`创建订单失败: ${resp.message}`)
setOpen(false) setOpen(false)
return return
} }
setTrade({ setTrade({
inner_no: resp.data.trade_no, inner_no: response.data.trade_no,
pay_url: resp.data.pay_url, pay_url: response.data.pay_url,
amount: Number(props.amount), amount: Number(props.amount),
platform: platform, platform: TradePlatform.Desktop,
method: method, method,
decoration: TradeMethodDecoration[props.method],
}) })
} }
@@ -113,7 +110,6 @@ export default function Pay(props: PayProps) {
</Button> </Button>
{/* 余额支付对话框 */}
{props.method === 'balance' && ( {props.method === 'balance' && (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
@@ -179,7 +175,6 @@ export default function Pay(props: PayProps) {
</Dialog> </Dialog>
)} )}
{/* 支付宝/微信支付 */}
{props.method !== 'balance' && trade && ( {props.method !== 'balance' && trade && (
<PaymentModal <PaymentModal
{...trade} {...trade}

View File

@@ -0,0 +1,60 @@
'use client'
import {useFormContext} from 'react-hook-form'
import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group'
import FormOption from '../option'
import {PurchaseMode} from './resource'
import {PurchaseFormValues} from './form-values'
export function BillingMethodField(props: {
modeList: PurchaseMode[]
timeDailyLimit: number
}) {
const {setValue, getValues} = useFormContext<PurchaseFormValues>()
return (
<FormField<PurchaseFormValues, 'type'>
className="flex flex-col gap-4"
name="type"
label="计费方式"
>
{({id, field}) => (
<RadioGroup
id={id}
value={field.value}
onValueChange={(value) => {
field.onChange(value)
if (value === '2') {
setValue('expire', '0')
return
}
setValue('expire', getValues('expire') || '0')
}}
className="flex gap-4 max-md:flex-col"
>
{props.modeList.includes('1') && (
<FormOption
id={`${id}-1`}
value="1"
label="包时套餐"
description="适用于每日提取量稳定的业务场景"
compare={field.value}
/>
)}
{props.modeList.includes('2') && (
<FormOption
id={`${id}-2`}
value="2"
label="包量套餐"
description="适用于短期或不定期高提取业务场景"
compare={field.value}
/>
)}
</RadioGroup>
)}
</FormField>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import Image from 'next/image'
import check from '../_assets/check.svg'
const defaultFeatures = [
'支持高并发提取',
'指定省份、城市或混播',
'账密+白名单验证',
'完备的API接口',
'IP时效3-30分钟(可定制)',
'IP资源定期筛选',
'包量/包时计费方式',
'每日去重量500万',
]
export function FeatureList(props: {
items?: string[]
}) {
const items = props.items || defaultFeatures
return (
<div className="space-y-6">
<h3></h3>
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
{items.map(item => (
<p key={item} className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">{item}</span>
</p>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import FormOption from '../option'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import RechargeModal from '@/components/composites/recharge'
export function FieldPayment(props: {
balance: number
}) {
return (
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
value={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
<div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{props.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
)
}

View File

@@ -0,0 +1,8 @@
export type PurchaseFormValues = {
type: '1' | '2'
live: string
quota: number
expire: string
daily_limit: number
pay_type: 'wechat' | 'alipay' | 'balance'
}

View File

@@ -0,0 +1,77 @@
'use client'
import {useFormContext} from 'react-hook-form'
import {Minus, Plus} from 'lucide-react'
import {FormField} from '@/components/ui/form'
import {Button} from '@/components/ui/button'
import {Input} from '@/components/ui/input'
import {PurchaseFormValues} from './form-values'
type PurchaseStepperFieldName = 'quota' | 'daily_limit'
type NumberStepperFieldProps = {
name: PurchaseStepperFieldName
label: string
description?: string
min: number
step: number
}
export function NumberStepperField(props: NumberStepperFieldProps) {
const form = useFormContext<PurchaseFormValues>()
const setValue = (value: number) => {
form.setValue(props.name, value)
}
return (
<FormField<PurchaseFormValues, PurchaseStepperFieldName>
description={props.description}
name={props.name}
label={props.label}
>
{({id, field}) => {
const value = Number(field.value) || props.min
return (
<div className="flex gap-2 items-center">
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => setValue(Math.max(props.min, value - props.step))}
disabled={value === props.min}
>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={props.min}
step={props.step}
onInvalid={(e) => {
e.preventDefault()
}}
onBlur={(event) => {
field.onBlur()
const nextValue = Number(event.target.value)
if (nextValue < props.min) {
setValue(props.min)
}
}}
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => setValue(value + props.step)}
>
<Plus/>
</Button>
</div>
)
}}
</FormField>
)
}

View File

@@ -0,0 +1,38 @@
import {CreateResourceReq} from '@/actions/resource'
export type PurchaseKind = 'short' | 'long'
export type PurchaseMode = '1' | '2'
export type PurchaseSelection = {
kind: PurchaseKind
mode: PurchaseMode
live: string
quota: number
expire: string
dailyLimit: number
}
function getPurchasePayload(selection: PurchaseSelection) {
return {
mode: Number(selection.mode),
live: Number(selection.live),
expire: selection.mode === '1' ? Number(selection.expire) : undefined,
quota: selection.mode === '1' ? Number(selection.dailyLimit) : Number(selection.quota),
}
}
export function buildPurchaseResource(selection: PurchaseSelection): CreateResourceReq {
const payload = getPurchasePayload(selection)
if (selection.kind === 'short') {
return {
type: 1,
short: payload,
}
}
return {
type: 2,
long: payload,
}
}

View File

@@ -0,0 +1,228 @@
'use client'
import {use, useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {buttonVariants} from '@/components/ui/button'
import {useProfileStore} from '@/components/stores/profile'
import Pay from '@/components/composites/purchase/pay'
import {FieldPayment} from './field-payment'
import {buildPurchaseResource, PurchaseKind, PurchaseSelection} from './resource'
import {getPrice, getPriceHome} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {formatPurchaseLiveLabel} from './sku'
import {User} from '@/lib/models'
import {PurchaseFormValues} from './form-values'
import {IdCard} from 'lucide-react'
import {Loader2} from 'lucide-react'
const emptyPrice: ExtraResp<typeof getPrice> = {
price: '0.00',
actual: '0.00',
discounted: '0.00',
}
export type PurchaseSidePanelProps = {
kind: PurchaseKind
}
export function PurchaseSidePanel(props: PurchaseSidePanelProps) {
const {control} = useFormContext<PurchaseFormValues>()
const method = useWatch<PurchaseFormValues>({control, name: 'pay_type'}) as PurchaseFormValues['pay_type']
const mode = useWatch<PurchaseFormValues>({control, name: 'type'}) as PurchaseFormValues['type']
const live = useWatch<PurchaseFormValues>({control, name: 'live'}) as PurchaseFormValues['live']
const quota = useWatch<PurchaseFormValues>({control, name: 'quota'}) as PurchaseFormValues['quota']
const expire = useWatch<PurchaseFormValues>({control, name: 'expire'}) as PurchaseFormValues['expire']
const dailyLimit = useWatch<PurchaseFormValues>({control, name: 'daily_limit'}) as PurchaseFormValues['daily_limit']
const profile = use(useProfileStore(store => store.profile))
const selection: PurchaseSelection = {
kind: props.kind,
mode,
live,
quota,
expire,
dailyLimit,
}
const {priceData, isLoading, isError} = usePurchasePrice(profile, selection)
const {price, actual: discountedPrice = '0.00'} = priceData
const totalDiscount = getTotalDiscount(price, discountedPrice)
const hasDiscount = Number(totalDiscount) > 0
const liveLabel = formatPurchaseLiveLabel(live, props.kind)
const resource = buildPurchaseResource(selection)
return (
<Card className="flex-none basis-90 p-6 flex flex-col gap-6 relative">
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{mode === '2' ? '包量套餐' : '包时套餐'}</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">{liveLabel}</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">{quota} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
{ isError ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400"/>
) : (
<span className="text-sm">{price}</span>
)}
</li>
{hasDiscount && !isError && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">-{totalDiscount}</span>
</li>
)}
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{expire} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{dailyLimit} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
{ isError ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400"/>
) : (
<span className="text-sm">{price}</span>
)}
</li>
{hasDiscount && !isError && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">-{totalDiscount}</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
{ isError ? (
<Loader2 className="h-5 w-5 animate-spin text-orange-500"/>
) : (
<span className="text-xl text-orange-500">{discountedPrice}</span>
)}
</p>
{profile ? (
profile.id_type !== 0 ? (
<>
<FieldPayment balance={profile.balance}/>
<Pay
method={method}
balance={profile.balance}
amount={discountedPrice}
resource={resource}
/>
</>
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-gray-500">
</p>
<Link
href="/admin/identify"
className={buttonVariants()}
>
<IdCard size={16} className="mr-1"/>
</Link>
</div>
)
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)}
</Card>
)
}
function usePurchasePrice(profile: User | null, selection: PurchaseSelection) {
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>(emptyPrice)
const [isLoading, setIsLoading] = useState(true)
const [isError, setIsError] = useState(false)
const requestIdRef = useRef(0)
const {kind, mode, live, quota, expire, dailyLimit} = selection
useEffect(() => {
const requestId = ++requestIdRef.current
const loadPrice = async () => {
setIsLoading(true)
setIsError(false)
try {
const resource = buildPurchaseResource({
kind,
mode,
live,
quota,
expire,
dailyLimit,
})
const response = profile
? await getPrice(resource)
: await getPriceHome(resource)
if (requestId !== requestIdRef.current) {
return
}
if (response.success) {
setPriceData({
price: response.data.price,
actual: response.data.actual ?? response.data.price ?? '0.00',
discounted: response.data.discounted ?? '0.00',
})
setIsError(false)
}
}
catch (error) {
if (requestId !== requestIdRef.current) {
return
}
console.error('获取价格失败:', error)
setPriceData(emptyPrice)
setIsError(true)
}
finally {
if (requestId === requestIdRef.current) {
setIsLoading(false)
}
}
}
loadPrice()
}, [dailyLimit, expire, kind, live, mode, profile, quota])
return {priceData, isLoading, isError}
}
function getTotalDiscount(price: string, discountedPrice: string) {
const originalPrice = Number.parseFloat(price)
const actualPrice = Number.parseFloat(discountedPrice)
if (Number.isNaN(originalPrice) || Number.isNaN(actualPrice)) {
return '0.00'
}
return (originalPrice - actualPrice).toFixed(2)
}

View File

@@ -0,0 +1,180 @@
import {ProductItem} from '@/actions/product'
import {PurchaseKind, PurchaseMode} from './resource'
export type PurchaseSkuItem = {
code: string
mode: PurchaseMode
live: string
expire: string
price: string
count_min: number
}
export type PurchaseSkuData = {
items: PurchaseSkuItem[]
priceMap: Map<string, string>
countMinMap: Map<string, number>
modeList: PurchaseMode[]
liveList: string[]
expireList: string[]
}
export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['skus']): PurchaseSkuData {
if (!skuList?.length) {
throw new Error('没有套餐数据')
}
const items: PurchaseSkuItem[] = []
const priceMap = new Map<string, string>()
const countMinMap = new Map<string, number>()
const modeSet = new Set<PurchaseMode>()
const liveSet = new Set<number>()
const expireSet = new Set<number>()
for (const sku of skuList) {
const params = new URLSearchParams(sku.code)
const mode = parsePurchaseSkuMode(params.get('mode'))
const live = Number(params.get('live') || '0')
const expire = Number(params.get('expire') || '0')
if (!mode || live <= 0) {
continue
}
const liveValue = String(live)
const expireValue = mode === '1' ? String(expire || '0') : '0'
const code = getPurchaseSkuKey({
mode,
live: liveValue,
expire: expireValue,
})
const countMin = typeof sku.count_min === 'number' ? sku.count_min : Number(sku.count_min) || 0
countMinMap.set(code, countMin)
items.push({
code,
mode,
live: liveValue,
expire: expireValue,
price: sku.price,
count_min: countMin,
})
priceMap.set(code, sku.price)
modeSet.add(mode)
liveSet.add(live)
if (kind === 'short') {
if (mode === '1' && expire > 0) {
expireSet.add(expire)
}
}
else if (expire > 0) {
expireSet.add(expire)
}
}
if (items.length === 0) {
throw new Error('没有可用的套餐数据')
}
return {
items,
priceMap,
countMinMap,
modeList: (['2', '1'] as const).filter(mode => modeSet.has(mode)),
liveList: sortNumericValues(liveSet),
expireList: sortNumericValues(expireSet),
}
}
function parsePurchaseSkuMode(mode: string | null): PurchaseMode | null {
if (mode === 'time') {
return '1'
}
if (mode === 'quota') {
return '2'
}
return null
}
function sortNumericValues(values: Iterable<number>) {
return Array.from(values).sort((a, b) => a - b).map(String)
}
export function getPurchaseSkuKey(props: {
mode: PurchaseMode
live: string
expire: string
}) {
const params = new URLSearchParams()
params.set('mode', props.mode === '1' ? 'time' : 'quota')
params.set('live', props.live || '0')
params.set('expire', props.mode === '1' ? props.expire || '0' : '0')
return params.toString()
}
export function getAvailablePurchaseLives(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
expire?: string
}) {
return sortNumericValues(new Set(
skuData.items
.filter(item => item.mode === props.mode)
.filter(item => !props.expire || item.expire === props.expire)
.map(item => Number(item.live)),
))
}
export function getAvailablePurchaseExpires(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
live?: string
}) {
return sortNumericValues(new Set(
skuData.items
.filter(item => item.mode === props.mode)
.filter(item => !props.live || item.live === props.live)
.filter(item => item.expire !== '0')
.map(item => Number(item.expire)),
))
}
export function hasPurchaseSku(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
live: string
expire: string
}) {
return skuData.priceMap.has(getPurchaseSkuKey(props))
}
export function getPurchaseSkuPrice(priceMap: Map<string, string>, props: {
mode: PurchaseMode
live: string
expire: string
}) {
return priceMap.get(getPurchaseSkuKey(props))
}
export function formatPurchaseLiveLabel(live: string, kind: PurchaseKind) {
const minutes = Number(live)
if (kind === 'long') {
return `${minutes / 60} 小时`
}
if (minutes % 60 === 0) {
return `${minutes / 60} 小时`
}
return `${minutes} 分钟`
}
export function getPurchaseSkuCountMin(
skuData: PurchaseSkuData,
props: {mode: PurchaseMode, live: string, expire: string},
): number {
const key = getPurchaseSkuKey(props)
return skuData.countMinMap.get(key) ?? 0
}

View File

@@ -1,231 +1,188 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react'
import FormOption from '@/components/composites/purchase/option' import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image' import {useEffect, useMemo} from 'react'
import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form' import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card' import {Card} from '@/components/ui/card'
import {useEffect} from 'react' import {BillingMethodField} from '../shared/billing-method-field'
import {FeatureList} from '../shared/feature-list'
import {NumberStepperField} from '../shared/number-stepper-field'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({
skuData,
}: {
skuData: PurchaseSkuData
}) {
const {setValue, getValues} = useFormContext<Schema>()
const type = useWatch<Schema>({name: 'type'}) as Schema['type']
const live = useWatch<Schema>({name: 'live'}) as Schema['live']
const expire = useWatch<Schema>({name: 'expire'}) as Schema['expire']
const {modeList, priceMap} = skuData
const liveList = type === '1'
? getAvailablePurchaseLives(skuData, {mode: type, expire})
: getAvailablePurchaseLives(skuData, {mode: type})
const expireList = type === '1'
? getAvailablePurchaseExpires(skuData, {mode: type, live})
: []
const currentCountMin = useMemo(() => {
if (!type || !live) return 0
const expireValue = type === '1' ? expire : '0'
return getPurchaseSkuCountMin(skuData, {mode: type, live, expire: expireValue})
}, [type, live, expire, skuData])
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => { useEffect(() => {
if (type === '1') { if (currentCountMin <= 0) return
form.setValue('daily_limit', 2000)
const targetField = type === '1' ? 'daily_limit' : 'quota'
const currentValue = getValues(targetField)
if (currentValue !== currentCountMin) {
setValue(targetField, currentCountMin, {shouldValidate: true})
} }
else { }, [currentCountMin, type, setValue, getValues])
form.setValue('quota', 10000) useEffect(() => {
const nextType = modeList.includes(type) ? type : modeList[0]
if (!nextType) {
return
} }
}, [type, form])
if (nextType !== type) {
setValue('type', nextType)
return
}
const nextLiveList = nextType === '1'
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
: getAvailablePurchaseLives(skuData, {mode: nextType})
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
if (nextLive && nextLive !== live) {
setValue('live', nextLive)
return
}
if (nextType === '2') {
if (expire !== '0') {
setValue('expire', '0')
}
return
}
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
setValue('expire', nextExpireList[0])
}
}, [expire, live, modeList, setValue, skuData, type])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-10 relative">
<div className="text-center -mb-10"> HTTPsocks5 </div>
<BillingMethodField modeList={modeList} timeDailyLimit={2000}/>
{/* 套餐时效 */}
{type === '1' && (
<FormField name="expire" label="套餐有效时间" description="有效时间内可用于提取 IP">
{({id, field}) => (
<RadioGroup
id={id}
value={field.value}
onValueChange={(value) => {
field.onChange(value)
{/* 计费方式 */} const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
<FormField<Schema, 'type'> if (!nextLiveList.includes(live) && nextLiveList[0]) {
className="flex flex-col gap-4" setValue('live', nextLiveList[0])
name="type" }
label="计费方式"> }}
{({id, field}) => ( className="flex gap-4 flex-wrap">
<RadioGroup {expireList.map(day => (
id={id} <FormOption
defaultValue={field.value} key={day}
onValueChange={field.onChange} id={`${id}-${day}`}
className="flex gap-4 max-md:flex-col"> value={day}
label={`${day}`}
<FormOption compare={field.value}
id={`${id}-2`} />
value="2" ))}
label="包量套餐" </RadioGroup>
description="适用于短期或不定期高提取业务场景" )}
compare={field.value}/> </FormField>
)}
<FormOption
id={`${id}-1`}
value="1"
label="包时套餐"
description="适用于每日提取量稳定的业务场景"
compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* IP 时效 */} {/* IP 时效 */}
<FormField<Schema, 'live'> <FormField<Schema, 'live'>
className="space-y-4"
name="live" name="live"
label="IP 时效"> label="IP 有效时间"
description="提取出的 IP 可用时间"
>
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={(value) => {
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> field.onChange(value)
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/> if (type !== '1') {
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/> return
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/> }
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/> const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
setValue('expire', nextExpireList[0])
}
}}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
{liveList.map((live) => {
const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
: String(expire)
const price = getPurchaseSkuPrice(priceMap, {
mode: type,
live,
expire: priceExpire,
})
const minutes = Number(live)
const hours = minutes / 60
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
return (
<FormOption
key={live}
id={`${id}-${live}`}
value={live}
label={label}
description={price && `${price}/IP`}
compare={field.value}
/>
)
})}
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 每日提取上限/购买数量 */}
{type === '2' ? ( {type === '1' ? (
/* 包量IP 购买数量 */ <NumberStepperField
<FormField name="daily_limit"
className="space-y-4" label="IP 每日提取上限"
name="quota" description="本套餐每日可提取 IP 的最大数量"
label="IP 购买数量"> min={currentCountMin}
{({id, field}) => ( step={1000}
<div className="flex gap-2 items-center"> />
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('quota', Math.max(10000, Number(field.value) - 5000))}
disabled={Number(field.value) === 10000}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={10000}
step={5000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 10000) {
form.setValue('quota', 10000)
}
}}
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('quota', Number(field.value) + 5000)}>
<Plus/>
</Button>
</div>
)}
</FormField>
) : ( ) : (
<> <NumberStepperField
{/* 包时:套餐时效 */} name="quota"
<FormField label="IP 总提取上限"
className="space-y-4" description="本套餐总计可提取 IP 的最大数量"
name="expire" min={currentCountMin}
label="套餐时效"> step={5000}
{({id, field}) => ( />
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex gap-4 flex-wrap">
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* 包时:每日提取上限 */}
<FormField
className="space-y-4"
name="daily_limit"
label="每日提取上限">
{({id, field}) => (
<div className="flex gap-2 items-center">
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('daily_limit', Math.max(2_000, Number(field.value) - 1_000))}
disabled={Number(field.value) === 2_000}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={2_000}
step={1_000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 2_000) {
form.setValue('daily_limit', 2_000)
}
}}
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('daily_limit', Number(field.value) + 1_000)}>
<Plus/>
</Button>
</div>
)}
</FormField>
</>
)} )}
{/* 产品特性 */} <FeatureList/>
<div className="space-y-6">
<h3></h3>
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500"></span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP时效3-30()</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP资源定期筛选</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">/</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">500</span>
</p>
</div>
</div>
</Card> </Card>
) )
} }

View File

@@ -1,51 +1,52 @@
'use client' 'use client'
import {createContext} from 'react' import {useForm} from 'react-hook-form'
import {useForm, UseFormReturn} from 'react-hook-form'
import Center from '@/components/composites/purchase/short/center' import Center from '@/components/composites/purchase/short/center'
import Right from '@/components/composites/purchase/short/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['180', '300', '600', '1200', '1800']), live: z.string(),
quota: z.number().min(10000, '购买数量不能少于10000个'), quota: z.number(),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.string(),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'), daily_limit: z.number(),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = { export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
form: UseFormReturn<Schema> const skuData = parsePurchaseSkuList('short', skuList)
onSubmit?: () => void const defaultMode = skuData.modeList.includes('1') ? '1' : '2'
} const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
const defaultExpire = defaultMode === '1'
? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
: '0'
const defaultCountMin = getPurchaseSkuCountMin(skuData, {
mode: defaultMode,
live: defaultLive,
expire: defaultExpire,
})
export const PurchaseFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
export type PurchaseFormProps = {}
export default function PurchaseForm(props: PurchaseFormProps) {
const form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: '180', // 分钟 live: defaultLive,
quota: 10_000, // >= 10000 expire: defaultExpire,
expire: '30', // 天 quota: defaultCountMin,
daily_limit: 2_000, // >= 2000 daily_limit: defaultCountMin,
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/> <Center skuData={skuData}/>
<Right/> <PurchaseSidePanel kind="short"/>
</Form> </Form>
) )
} }

View File

@@ -1,233 +0,0 @@
'use client'
import {Suspense, use, useEffect, useMemo, useState} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import Pay from '@/components/composites/purchase/pay'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {CreateResourceReq, getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
export default function Right() {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const live = useWatch({control, name: 'live'})
const mode = useWatch({control, name: 'type'})
const expire = useWatch({control, name: 'expire'})
const quota = useWatch({control, name: 'quota'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const priceResponse = await getPrice({
type: 1,
short: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!priceResponse.success) {
throw new Error('获取价格失败')
}
const data = priceResponse.data
setPriceData({
price: data.price,
discounted_price: data.discounted_price ?? data.price ?? '',
discounted: data.discounted,
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [expire, live, quota, mode, dailyLimit])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
)}>
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{mode === '2' ? `包量套餐` : `包时套餐`}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">
{Number(live) / 60}
{' '}
</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{expire}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{dailyLimit}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted === 1 ? '' : discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span className="text-xl text-orange-500">
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
discountedPrice: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500">账户余额</span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile.balance}</span>
<RechargeModal/>
</p>
</div> */}
{/* <FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span>余额</span>
</FormOption> */}
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay
method={props.method}
balance={profile.balance}
amount={props.discountedPrice}
resource={{
type: 1,
short: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

@@ -21,7 +21,6 @@ import {merge} from '@/lib/utils'
import { import {
TradePlatform, TradePlatform,
TradeMethod, TradeMethod,
TradeMethodDecoration,
} from '@/lib/models/trade' } from '@/lib/models/trade'
import {PaymentModal} from '@/components/composites/payment/payment-modal' import {PaymentModal} from '@/components/composites/payment/payment-modal'
import Image from 'next/image' import Image from 'next/image'
@@ -77,7 +76,6 @@ export default function RechargeModal(props: RechargeModelProps) {
amount: data.amount, amount: data.amount,
platform: platform, platform: platform,
method: method, method: method,
decoration: TradeMethodDecoration[data.method],
}) })
} }
else { else {

Some files were not shown because too many files have changed in this diff Show More