Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49725fd38e | ||
|
|
7947fc48a2 | ||
|
|
99039b6622 | ||
|
|
db1acf6f70 | ||
|
|
3a2fbe29fb | ||
|
|
5c236c0b01 | ||
|
|
fde097c601 | ||
|
|
670961c17d | ||
|
|
84a5e27c05 | ||
|
|
9dea370a87 | ||
|
|
602372e58d | ||
|
|
574ad0e662 | ||
|
|
78d916ade1 | ||
|
|
6a9e7289b5 | ||
| 6adcd33943 | |||
|
|
165151b9d2 | ||
|
|
1e76275f04 | ||
|
|
d9cd5eb41b | ||
|
|
7ff42861f1 | ||
|
|
eb4c2d2d5f | ||
| a0b0956677 | |||
|
|
27e694ee0d | ||
|
|
74d53c619d | ||
| 8f8def3a87 | |||
|
|
ed73d8579f | ||
|
|
e3c61a77e6 | ||
| 5f7756199a | |||
| 6aa108e8d3 | |||
|
|
8b65a1745c | ||
|
|
5607217625 | ||
| 319baea5e8 | |||
|
|
9a8a1826c9 | ||
|
|
c2a0310ee5 | ||
|
|
8ee8feb2bf | ||
|
|
1e090f5c88 | ||
|
|
665ce79e1d | ||
|
|
93110954bb | ||
|
|
8ce5f99a24 | ||
|
|
e27869fb4a | ||
|
|
01c4afd209 | ||
| 2a959fa9cf | |||
|
|
d9f267e257 | ||
|
|
83530d7f1e | ||
|
|
b2c36196b4 | ||
|
|
d2d6c1709c | ||
|
|
a76e61beb0 | ||
|
|
d83ad11241 | ||
|
|
bce7e41adf | ||
| 2b77ea189b | |||
|
|
82bd8051d8 | ||
|
|
4e27d707ec | ||
|
|
32c08d96d4 | ||
|
|
1031630712 | ||
|
|
31c26e9636 | ||
|
|
333bd3f686 | ||
|
|
9201a819be | ||
|
|
a2187adb05 | ||
|
|
4b18c91157 | ||
|
|
2125f1ef9e | ||
|
|
85f241e8e3 | ||
|
|
671ad8ab9d | ||
|
|
fc47ec9d18 | ||
|
|
7dc562aad0 |
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
API_BASE_URL=http://192.168.0.15:8080
|
||||||
|
CLIENT_ID=web
|
||||||
|
CLIENT_SECRET=web
|
||||||
2
.github/copilot-instructions.md
vendored
@@ -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
@@ -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
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
@@ -7,4 +7,7 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
},
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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) 实现纯客户端组件
|
||||||
|
|||||||
6
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lanhu-web",
|
"name": "lanhu-web",
|
||||||
"version": "1.1.0",
|
"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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
src/app/(auth)/privacyPolicy/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
17
src/app/(auth)/userAgreement/layout.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -17,26 +17,26 @@ 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
|
||||||
icon={h02}
|
icon={h02}
|
||||||
title="操作指南"
|
title="操作指南"
|
||||||
items={[
|
items={[
|
||||||
{lead: '修改信息', href: '/docs/operation/profile-settings'},
|
{lead: '修改信息', href: '/docs/profile-settings'},
|
||||||
{lead: '提取链接', href: '/docs/operation/extract-link'},
|
{lead: '提取链接', href: '/docs/extract-link'},
|
||||||
{lead: '查看记录', href: '/docs/operation/payment-records'},
|
{lead: '查看记录', href: '/docs/payment-records'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Column
|
<Column
|
||||||
icon={h03}
|
icon={h03}
|
||||||
title="平台教程"
|
title="平台教程"
|
||||||
items={[
|
items={[
|
||||||
{lead: 'iOS 设置', href: '/docs/client/ios-proxy'},
|
{lead: 'iOS 设置', href: '/docs/ios-proxy'},
|
||||||
{lead: 'Android 设置', href: '/docs/client/android-proxy'},
|
{lead: 'Android 设置', href: '/docs/android-proxy'},
|
||||||
{lead: 'Windows 设置', href: '/docs/client/windows10-proxy'},
|
{lead: 'Windows 设置', href: '/docs/windows10-proxy'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Image src={banner} alt="banner" className="hidden lg:block"/>
|
<Image src={banner} alt="banner" className="hidden lg:block"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/>
|
||||||
|
|||||||
264
src/app/(home)/custom/_client.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -14,7 +14,8 @@
|
|||||||
| b | string | 否 | 归属地城市。默认全局随机 |
|
| b | string | 否 | 归属地城市。默认全局随机 |
|
||||||
| s | string | 否 | 归属地运营商。默认全局随机 |
|
| s | string | 否 | 归属地运营商。默认全局随机 |
|
||||||
| d | string | 否 | 是否去重:1 - 是,0 - 否。默认为是 |
|
| d | string | 否 | 是否去重:1 - 是,0 - 否。默认为是 |
|
||||||
| rt | string | 否 | 返回类型:1 - TXT,2 - JSON。默认 TXT |
|
| rt | string | 否 | 返回类型:1 - TXT,2 - 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -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}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/app/(home)/docs/[groupCode]/[articleId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/app/(home)/docs/sidebar-drawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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/product-overview', label: '产品介绍'},
|
|
||||||
{key: 'product/choose-product', label: '如何选择产品'},
|
|
||||||
{key: 'product/why-verify', label: '为什么需要实名认证'},
|
|
||||||
{key: 'product/city-lines', label: '有哪些城市线路'},
|
|
||||||
{key: 'product/api-docs', label: 'ip提取接口文档'},
|
|
||||||
// 服务条款
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: '操作指南',
|
|
||||||
items: [
|
|
||||||
{key: 'operation/profile-settings', label: '修改个人信息和重置密码'},
|
|
||||||
{key: 'operation/whitelist-guide', label: '如何添加白名单'},
|
|
||||||
{key: 'operation/verify-guide', label: '如何进行实名认证'},
|
|
||||||
{key: 'operation/extract-link', label: '如何生成提取链接'},
|
|
||||||
{key: 'operation/payment-records', label: '查看支付和使用记录'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: '客户端教程',
|
|
||||||
items: [
|
|
||||||
{key: 'client/browser-proxy', label: '浏览器设置代理教程'},
|
|
||||||
{key: 'client/ios-proxy', label: 'iOS设置代理教程'},
|
|
||||||
{key: 'client/android-proxy', label: '安卓手机设置代理教程'},
|
|
||||||
{key: 'client/windows10-proxy', label: 'Windows10设置代理教程'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: '常见问题',
|
|
||||||
items: [
|
|
||||||
{key: 'faqs/faq-general', label: '常见问题总览'},
|
|
||||||
{key: 'faqs/faq-billing', label: '计费与套餐问题'},
|
|
||||||
// 业务场景集成方案
|
|
||||||
// 故障排查
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: '新闻资讯',
|
|
||||||
items: [
|
|
||||||
{key: 'news/news-latest', label: '了解代理服务器的工作原理'},
|
|
||||||
{key: 'news/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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default async function UserCenter() {
|
|||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardContent className="flex-auto flex flex-col justify-between gap-4">
|
<CardContent className="flex-auto flex flex-col justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p>{profile.username?.trim() || profile.email || profile.phone}</p>
|
<p>{profile.username?.trim() || profile.phone }</p>
|
||||||
<p className="text-sm text-weak">{`最后登录:${format(profile.last_login, 'yyyy-MM-dd HH:mm')}`}</p>
|
<p className="text-sm text-weak">{`最后登录:${format(profile.last_login, 'yyyy-MM-dd HH:mm')}`}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={merge(
|
<div className={merge(
|
||||||
@@ -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">
|
||||||
|
|||||||