23 Commits

Author SHA1 Message Date
Eamon-meng
8ee8feb2bf 修改构建脚本路径 2026-04-02 17:56:48 +08:00
Eamon-meng
1e090f5c88 更新配置文件修复构建问题 2026-04-02 17:52:15 +08:00
Eamon-meng
665ce79e1d 发布v1.3.0版本 2026-04-02 17:35:51 +08:00
Eamon-meng
93110954bb 更新订单详情显示字段名称 2026-04-02 14:08:32 +08:00
Eamon-meng
8ce5f99a24 开启充值和余额支付功能 2026-03-31 16:11:47 +08:00
Eamon-meng
e27869fb4a 重新计算价格显示 2026-03-31 16:11:46 +08:00
Eamon-meng
01c4afd209 更新发布v1.2.3版本 2026-03-31 16:11:46 +08:00
2a959fa9cf 优化客户端请求机制 2026-03-31 16:09:43 +08:00
Eamon-meng
d9f267e257 调整桌面端产品订购的菜单栏布局 2026-03-14 18:00:27 +08:00
Eamon-meng
83530d7f1e 修改移动端菜单栏侧边栏布局 2026-03-14 15:25:23 +08:00
Eamon-meng
b2c36196b4 修复实名认证阶段的问题 2026-03-13 18:26:23 +08:00
Eamon-meng
d2d6c1709c 我的套餐新增状态筛选字段 2026-03-13 18:12:22 +08:00
Eamon-meng
a76e61beb0 补充按钮手形样式显示 2026-03-13 14:30:48 +08:00
Eamon-meng
d83ad11241 我的账单页面取消退款和操作列 & 后台Header添加返回首页功能 2026-03-13 14:15:29 +08:00
Eamon-meng
bce7e41adf 修改网站图标显示 & 所以按钮添加手形样式 2026-03-13 14:15:28 +08:00
2b77ea189b 支付组件统一使用二维码展示 2026-03-13 14:13:06 +08:00
Eamon-meng
82bd8051d8 更新发布v1.2.2版本 2026-03-11 17:34:21 +08:00
Eamon-meng
4e27d707ec 手机端支付修改为桌面支付方式 2026-03-11 17:29:29 +08:00
Eamon-meng
32c08d96d4 调整帮助中心移动端文档布局 2026-03-10 17:15:30 +08:00
Eamon-meng
1031630712 更新README.md文档 2026-03-10 17:06:54 +08:00
Eamon-meng
31c26e9636 更新README.md文档项目目录目录结构 2026-03-03 15:47:34 +08:00
Eamon-meng
333bd3f686 更新README.md文档 2026-03-03 14:28:53 +08:00
Eamon-meng
9201a819be 更新README.md文档 2026-03-03 13:23:18 +08:00
51 changed files with 1112 additions and 490 deletions

4
.env.example Normal file
View File

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

2
.gitignore vendored
View File

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

View File

@@ -16,6 +16,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN mv .env.example .env
RUN bun run build
# 生产阶段

133
README.md
View File

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

View File

@@ -45,6 +45,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76",
"zustand": "^5.0.9",
},
@@ -1399,6 +1400,8 @@
"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-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],

View File

@@ -1,6 +1,6 @@
{
"name": "lanhu-web",
"version": "1.1.2",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack",
@@ -51,6 +51,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.25.76",
"zustand": "^5.0.9"
},

View File

@@ -9,8 +9,8 @@ if ($confrim -ne "y") {
exit 0
}
docker build -t 43.226.58.254:53000/lanhu/web:latest .
docker build -t 43.226.58.254:53000/lanhu/web:$($args[0]) .
docker build -t repo.lanhuip.com:8554/lanhu/web:latest .
docker build -t repo.lanhuip.com:8554/lanhu/web:$($args[0]) .
docker push 43.226.58.254:53000/lanhu/web:latest
docker push 43.226.58.254:53000/lanhu/web:$($args[0])
docker push repo.lanhuip.com:8554/lanhu/web:latest
docker push repo.lanhuip.com:8554/lanhu/web:$($args[0])

View File

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

View File

@@ -1,8 +1,16 @@
'use server'
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 {cache} from 'react'
import {redirect} from 'next/navigation'
export type TokenResp = {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope?: string
}
// ======================
// public
@@ -26,6 +34,9 @@ const _callPublic = cache(async <R = undefined>(
// device
// ======================
let token: string | null = null
let token_expire: Date | null = null
async function callByDevice<R = undefined>(
endpoint: string,
data: unknown,
@@ -37,18 +48,20 @@ const _callByDevice = cache(async <R = undefined>(
endpoint: string,
data?: string,
): Promise<ApiResponse<R>> => {
// 获取设备令牌
if (!CLIENT_ID || !CLIENT_SECRET) {
return {
success: false,
status: 401,
message: '未配置 CLIENT_ID 或 CLIENT_SECRET',
if (!token || !token_expire || isBefore(token_expire, new Date())) {
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
const resp = await call<TokenResp>(`${API_BASE_URL}/api/auth/token`, JSON.stringify({
grant_type: 'client_credentials',
}), `Basic ${basic}`)
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}`)
})
// ======================
@@ -149,24 +162,6 @@ async function call<R = undefined>(url: string, body: RequestInit['body'], auth?
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
}
// 导出
export {
callPublic,

View File

@@ -24,6 +24,7 @@ export async function listResourceShort(props: {
size: number
resource_no?: string
type?: number
status?: number
create_after?: Date
create_before?: Date
expire_after?: Date
@@ -37,6 +38,7 @@ export async function listResourceLong(props: {
size: number
resource_no?: string
type?: number
status?: number
create_after?: Date
create_before?: Date
expire_after?: Date

View File

@@ -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,
purpose: 0,
})

View File

@@ -141,7 +141,7 @@ export default function LoginCard() {
<button
type="button"
tabIndex={-1}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
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 ? '隐藏密码' : '显示密码'}
>

View File

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

View File

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

View File

@@ -1,31 +1,296 @@
import ProductMenu from './menu-product'
import HelpMenu from './menu-help'
import SolutionMenu from './menu-solution'
'use client'
import {useContext, 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'
export type 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)
}
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<ProductMenu/>
<div className="h-full flex flex-col bg-white">
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
{/* 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 className="flex flex-col gap-4">
<MenuTitle title="帮助中心"/>
<HelpMenu/>
</div>
<div className="flex flex-col gap-4">
<MenuTitle title="业务场景"/>
<SolutionMenu/>
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-8">
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-500 tracking-wide">
</h3>
<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">
<ProductItem
icon={prod}
label="短效动态IP"
badge="最低4.5折"
href="/product?type=short"
onNavigate={navigate}
/>
<ProductItem
icon={prod}
label="长效静态IP"
badge="最低4.5折"
href="/product?type=long"
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>
)
}
function MenuTitle(props: {title: string}) {
function MenuSection(props: {title: string, children: React.ReactNode}) {
return (
<h3 className="text-xl text-weak px-4">
{props.title}
</h3>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-500 tracking-wide">
{props.title}
</h3>
{props.children}
</div>
)
}
function ProductItem(props: {
icon: StaticImageData
label: string
badge?: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-3 text-left transition-all hover:border-blue-200 hover:shadow-sm"
onClick={() => props.onNavigate(props.href)}
>
<div className="shrink-0 w-8 h-8 bg-linear-to-br from-blue-50 to-cyan-50 rounded-lg flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="flex-1 font-medium text-sm text-gray-900">{props.label}</span>
{props.badge && (
<span className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.badge}
</span>
)}
</button>
)
}
function SolutionItem(props: {
icon: StaticImageData
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-gray-100 hover:border-blue-200 hover:bg-blue-50/50 transition-all"
onClick={() => props.onNavigate(props.href)}
>
<div className="w-10 h-10 bg-linear-to-br from-blue-50 to-cyan-50 rounded-full flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="text-xs font-medium text-gray-700">{props.label}</span>
</button>
)
}
function HelpItem(props: {
icon: StaticImageData
label: string
onClick: () => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors"
onClick={props.onClick}
>
<Image src={props.icon} alt="" width={20} height={20} className="opacity-70"/>
<span className="text-sm text-gray-700">{props.label}</span>
</button>
)
}
function OtherLink(props: {
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center px-3 py-2.5 text-sm text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => props.onNavigate(props.href)}
>
{props.label}
</button>
)
}

View File

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

View File

@@ -16,7 +16,7 @@ export default function CollectPage(props: CollectPageProps) {
// </Wrap>
// </main>
<HomePage path={[
{label: '短效IP 提取', href: '/collect'},
{label: '短效/长效IP 提取', href: '/collect'},
]}>
<Wrap>
<Extract/>

View File

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

View File

@@ -1,5 +1,8 @@
export default function DocsIndexPage() {
return (
<div></div>
<div className="text-center text-slate-500 py-12">
<p className="text-lg"></p>
<p className="text-sm mt-2"></p>
</div>
)
}

View File

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

View File

@@ -3,10 +3,7 @@ import {useState, useMemo, useCallback} from 'react'
import Link from 'next/link'
import {usePathname} from 'next/navigation'
import {ChevronRight} from 'lucide-react'
type Props = {
collapsed?: boolean
}
import {merge} from '@/lib/utils'
// 菜单配置
const MENU_ITEMS = [
@@ -58,7 +55,12 @@ const MENU_ITEMS = [
},
]
export default function Sidebar({collapsed = false}: Props) {
type Props = {
className?: string
onClose?: () => void
}
export default function Sidebar({className, onClose}: Props) {
const pathname = usePathname()
// 获取当前文档 key
@@ -100,9 +102,7 @@ export default function Sidebar({collapsed = false}: Props) {
return (
<aside
className={`bg-white rounded-lg p-3 transition-all duration-200 shrink-0 ${
collapsed ? 'w-20' : 'w-68'
}`}
className={merge(`bg-white rounded-lg p-3 transition-all duration-200 shrink-0`, className)}
>
<nav className="space-y-2">
{MENU_ITEMS.map(section => (
@@ -110,9 +110,7 @@ export default function Sidebar({collapsed = false}: Props) {
<div
onClick={() => toggleGroup(section.group)}
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-sm transition-colors ${
finalExpandedGroups[section.group] && !collapsed
? 'bg-blue-50'
: 'hover:bg-slate-50'
finalExpandedGroups[section.group] && 'bg-blue-50'
}`}
>
<div
@@ -123,15 +121,13 @@ export default function Sidebar({collapsed = false}: Props) {
<ChevronRight size={16}/>
</div>
{!collapsed && (
<div className="text-lg font-semibold text-slate-900">
{section.group}
</div>
)}
<div className="text-lg font-semibold text-slate-900">
{section.group}
</div>
</div>
{finalExpandedGroups[section.group] && (
<ul className={`mt-1 text-base ${collapsed ? 'hidden' : 'block'}`}>
<ul className="mt-1 text-base">
{section.items.map((item) => {
const isActive = currentKey === item.key
const href = getItemHref(item.key)
@@ -140,6 +136,7 @@ export default function Sidebar({collapsed = false}: Props) {
<li key={item.key}>
<Link
href={href}
onClick={() => onClose?.()}
className={`block pl-8 py-2 text-base cursor-pointer transition-colors ${
isActive
? 'bg-blue-50 font-semibold'

View File

@@ -164,10 +164,10 @@ export default function Header(props: HeaderProps) {
</Wrap>
</div>
{/* 下拉菜单 */}
{/* 桌面端下拉菜单 */}
<div
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`,
`transition-[opacity,padding] transition-discrete duration-200 ease-in-out`,
menu
@@ -180,7 +180,20 @@ export default function Header(props: HeaderProps) {
{pages[page]}
</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}/>
</HeaderContext.Provider>
@@ -225,7 +238,7 @@ function MenuItem(props: {
onPointerLeave={props.onPointerLeave}
className={[
`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
? `text-blue-500`
: ``,

View File

@@ -2,7 +2,7 @@ import {ReactNode} from 'react'
import Header from './header'
import Footer from './footer'
import Script from 'next/script'
import {MessageCircleMoreIcon} from 'lucide-react'
export type HomeLayoutProps = {
children: ReactNode
}
@@ -19,7 +19,21 @@ export default function HomeLayout(props: HomeLayoutProps) {
{/* 页脚 */}
<Footer/>
<Script id="qd2852138148beb7882a4a6a3e5ff5b569436003e7dc" src="https://wp.qiye.qq.com/qidian/2852138148/beb7882a4a6a3e5ff5b569436003e7dc" async defer></Script>
{/* <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>
)
}

View File

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

View File

@@ -99,7 +99,7 @@ export default function BillsPage(props: BillsPageProps) {
<SelectItem value="all"></SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2">退</SelectItem>
{/* <SelectItem value="2">退款</SelectItem> */}
</SelectContent>
</Select>
)}
@@ -284,13 +284,13 @@ export default function BillsPage(props: BillsPageProps) {
format(new Date(row.original.created_at), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: item => (
<div className="flex gap-2">
-
</div>
),
},
// {
// accessorKey: 'action', header: `操作`, cell: item => (
// <div className="flex gap-2">
// -
// </div>
// ),
// },
]}
/>
</Page>

View File

@@ -116,7 +116,14 @@ export function Header() {
</div>
{/* right */}
<div className="flex-none flex items-center justify-end pr-4">
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-2">
<Link
href="/"
className={merge(
`flex-none h-16 flex items-center justify-center`,
)}>
</Link>
<Suspense>
<HeaderUserCenter/>
</Suspense>

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import {
ExpireBadge,
formatDateTime,
getTodayUsage,
isValidResourcestatus,
isValidResourceType,
ResourceTypeBadge,
} from './utils'
@@ -24,6 +25,7 @@ import {
const filterSchema = zod.object({
resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
status: zod.enum(['0', '1', '2']).default('1'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
@@ -47,12 +49,13 @@ export default function ResourceList({resourceType}: ResourceListProps) {
// 从 URL 参数初始化筛选条件
const params = useSearchParams()
const paramType = params.get('type')
const paramStatus = params.get('status')
const form = useForm<ResourceFilterValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: isValidResourceType(paramType) ? paramType : 'all',
status: isValidResourcestatus(paramStatus) ? paramStatus : '1',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
@@ -71,6 +74,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
expire: 1,
quota: 2,
}[getValues('type')]
const status = getValues('status')
const create_after = getValues('create_after')
const create_before = getValues('create_before')
const expire_after = getValues('expire_after')
@@ -82,6 +86,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
page,
size,
type,
status: Number(status),
create_after,
create_before,
expire_after,
@@ -116,6 +121,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const handleReset = () => {
form.reset({
type: 'all',
status: '1',
resource_no: '',
create_after: undefined,
create_before: undefined,
@@ -159,7 +165,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const live = resourceKey === 'long'
? (row.original as Resource<2>).long.live
: (row.original as Resource<1>).short.live
return <span>{isLong ? `${live}小时` : `${live / 60}分钟`}</span>
return <span>{isLong ? `${live}小时` : `${live}分钟`}</span>
},
},
{

View File

@@ -7,6 +7,11 @@ export function isValidResourceType(type: string | null): type is 'expire' | 'qu
return type === 'expire' || type === 'quota' || type === 'all'
}
// 状态
export function isValidResourcestatus(status: string | null): status is '0' | '1' | '2' {
return status === '0' || status === '1' || status === '2'
}
// 资源类型徽章
export function ResourceTypeBadge({type}: {type: number}) {
if (type === 1) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -337,6 +337,7 @@ function SelectResource() {
setStatus('load')
try {
const resp = await allResource()
console.log(resp, '/api/resource/all')
if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试')
@@ -381,102 +382,103 @@ function SelectResource() {
<Loader className="animate-spin" size={20}/>
<span></span>
</div>
) : resources.map((resource, i) => (
) : (
<>
<SelectItem
key={`${resource.id}`}
value={String(resource.id)}
className="p-3">
<div className="flex flex-col gap-2 w-72">
{resource.type === 1 && resource.short.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
</div>
</>
)}
{resource.type === 1 && resource.short.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.short.used}
{' '}
/
{resource.short.quota}
</span>
<span>
{resource.short.quota - resource.short.used}
</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.long.used}
{' '}
/
{resource.long.quota}
</span>
<span>
{resource.long.quota - resource.long.used}
</span>
</div>
</>
)}
</div>
</SelectItem>
{i < resources.length - 1 && <SelectSeparator className="m-1"/>}
{resources.map(resource => (
<SelectItem
key={resource.id}
value={String(resource.id)}
className="p-3">
<div className="flex flex-col gap-2 w-72">
{resource.type === 1 && resource.short.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
</div>
</>
)}
{resource.type === 1 && resource.short.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.short.used}
{' '}
/
{resource.short.quota}
</span>
<span>
{resource.short.quota - resource.short.used}
</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex text-xs text-weak">
<span>{resource.resource_no}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.long.used}
{' '}
/
{resource.long.quota}
</span>
<span>
{resource.long.quota - resource.long.used}
</span>
</div>
</>
)}
</div>
</SelectItem>
))}
</>
))}
)}
</SelectContent>
</Select>
)}
@@ -651,9 +653,9 @@ function name(resource: Resource) {
// 短效套餐
switch (resource.short.type) {
case 1:
return `短效包时 ${resource.short.live / 60} 分钟`
return `短效包时 ${resource.short.live} 分钟`
case 2:
return `短效包量 ${resource.short.live / 60} 分钟`
return `短效包量 ${resource.short.live} 分钟`
}
break

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {Dialog} from '@/components/ui/dialog'
import {PaymentProps} from './type'
import {payClose} from '@/actions/resource'
import {useEffect} from 'react'
import {useRouter} from 'next/navigation'
import {UniversalDesktopPayment} from './universal-desktop-payment'
export type PaymentModalProps = {
onConfirm: (showFail: boolean) => Promise<void>
@@ -61,17 +61,13 @@ export function PaymentModal(props: PaymentModalProps) {
onOpenChange={(open) => {
if (!open) handleClose()
}}>
{props.platform === TradePlatform.Mobile ? (
<MobilePayment
{...props}
onClose={handleClose}
/>
) : (
<DesktopPayment
{...props}
onClose={handleClose}
/>
)}
{props.platform === TradePlatform.Mobile
? <MobilePayment {...props} onClose={handleClose}/>
: <DesktopPayment {...props} onClose={handleClose}/>
}
{/* <UniversalDesktopPayment {...props} onClose={handleClose}/> */}
</Dialog>
)
}

View File

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

View File

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

View File

@@ -234,7 +234,7 @@ export default function CollectPage() {
<div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left">
5000IP
</div>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}/>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap cursor-pointer')}/>
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
'use client'
import {createContext} from 'react'
import {useForm, UseFormReturn} from 'react-hook-form'
import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/long/center'
import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form'
@@ -20,13 +19,6 @@ const schema = z.object({
// 从架构中推断类型
export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = {
form: UseFormReturn<Schema>
onSubmit?: () => void
}
export const LongFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
export default function LongForm() {
const form = useForm<Schema>({
resolver: zodResolver(schema),
@@ -42,10 +34,8 @@ export default function LongForm() {
return (
<Form form={form} className="flex flex-col lg:flex-row gap-4">
<LongFormContext.Provider value={{form}}>
<Center/>
<Right/>
</LongFormContext.Provider>
<Center/>
<Right/>
</Form>
)
}

View File

@@ -1,15 +1,6 @@
'use client'
import {Suspense, use, useContext, useEffect, useMemo, useState} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import {Suspense, use, useEffect, useState} from 'react'
import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge'
import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
@@ -17,8 +8,9 @@ import {merge} from '@/lib/utils'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/long/form'
import {Card} from '@/components/ui/card'
import {getPrice, CreateResourceReq} from '@/actions/resource'
import {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment'
export default function Right() {
const {control} = useFormContext<Schema>()
@@ -40,12 +32,13 @@ export default function Right() {
const resp = await getPrice({
type: 2,
long: {
live: Number(live),
live: Number(live) * 60,
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!resp.success) {
throw new Error('获取价格失败')
}
@@ -57,6 +50,7 @@ export default function Right() {
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
@@ -122,19 +116,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
{/* {discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">总折扣</span>
<span className="text-sm">
-¥{discounted}
</span>
</li>
)}
)} */}
</>
)}
</ul>
@@ -164,52 +158,7 @@ function BalanceOrLogin(props: {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500">账户余额</span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile?.balance}</span>
<RechargeModal/>
</p>
</div> */}
{/* <FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span>余额</span>
</FormOption> */}
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<FieldPayment/>
<Pay
method={props.method}
balance={profile.balance}
@@ -218,9 +167,9 @@ function BalanceOrLogin(props: {
type: 2,
long: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
live: Number(props.live) * 60,
expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
},
}}/>
</>

View File

@@ -11,7 +11,7 @@ import {useRouter} from 'next/navigation'
import {completeResource, createResource, CreateResourceReq, prepareResource} from '@/actions/resource'
import {
TradeMethod,
TradeMethodDecoration,
TradePlatform,
} from '@/lib/models/trade'
import {PaymentModal} from '@/components/composites/payment/payment-modal'
import {PaymentProps} from '@/components/composites/payment/type'
@@ -32,7 +32,7 @@ export default function Pay(props: PayProps) {
const [open, setOpen] = useState(false)
const [trade, setTrade] = useState<PaymentProps | null>(null)
const router = useRouter()
const platform = usePlatformType()
// const platform = usePlatformType()
const onOpen = async () => {
setOpen(true)
@@ -45,8 +45,9 @@ export default function Pay(props: PayProps) {
const req = {
...props.resource,
payment_method: method,
payment_platform: platform,
payment_platform: TradePlatform.Desktop,
}
console.log(req, 'req')
const resp = await prepareResource(req)
@@ -60,9 +61,8 @@ export default function Pay(props: PayProps) {
inner_no: resp.data.trade_no,
pay_url: resp.data.pay_url,
amount: Number(props.amount),
platform: platform,
platform: TradePlatform.Desktop,
method: method,
decoration: TradeMethodDecoration[props.method],
})
}

View File

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

View File

@@ -68,11 +68,11 @@ export default function Center() {
onValueChange={field.onChange}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="15" label="15 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
</RadioGroup>
)}
</FormField>

View File

@@ -1,6 +1,5 @@
'use client'
import {createContext} from 'react'
import {useForm, UseFormReturn} from 'react-hook-form'
import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/short/center'
import Right from '@/components/composites/purchase/short/right'
import {Form} from '@/components/ui/form'
@@ -10,7 +9,7 @@ import {zodResolver} from '@hookform/resolvers/zod'
// 定义表单验证架构
const schema = z.object({
type: z.enum(['1', '2']).default('2'),
live: z.enum(['180', '300', '600', '1200', '1800']),
live: z.enum(['3', '5', '10', '15', '30']),
quota: z.number().min(10000, '购买数量不能少于10000个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
@@ -20,21 +19,12 @@ const schema = z.object({
// 从架构中推断类型
export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = {
form: UseFormReturn<Schema>
onSubmit?: () => void
}
export const PurchaseFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
export type PurchaseFormProps = {}
export default function PurchaseForm(props: PurchaseFormProps) {
export default function ShortForm() {
const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
type: '2', // 默认为包量套餐
live: '180', // 分钟
live: '3', // 分钟
quota: 10_000, // >= 10000
expire: '30', // 天
daily_limit: 2_000, // >= 2000

View File

@@ -1,23 +1,16 @@
'use client'
import {Suspense, use, useEffect, useMemo, useState} from 'react'
import {Suspense, use, useEffect, useState} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import {useProfileStore} from '@/components/stores/profile'
import RechargeModal from '@/components/composites/recharge'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import Pay from '@/components/composites/purchase/pay'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {CreateResourceReq, getPrice} from '@/actions/resource'
import {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment'
export default function Right() {
const {control} = useFormContext<Schema>()
@@ -45,6 +38,7 @@ export default function Right() {
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!priceResponse.success) {
throw new Error('获取价格失败')
}
@@ -85,7 +79,7 @@ export default function Right() {
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">
{Number(live) / 60}
{live}
{' '}
</span>
@@ -123,19 +117,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
{/* {discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">总折扣</span>
<span className="text-sm">
-¥{discounted === 1 ? '' : discounted}
</span>
</li>
)}
)} */}
</>
)}
</ul>
@@ -165,52 +159,7 @@ function BalanceOrLogin(props: {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500">账户余额</span>
</p>
<p className="flex justify-between items-center">
<span className="text-xl">{profile.balance}</span>
<RechargeModal/>
</p>
</div> */}
{/* <FormOption
id={`${id}-balance`}
value="balance"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/>
<span>余额</span>
</FormOption> */}
<FormOption
id={`${id}-wechat`}
value="wechat"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={wechat} alt="微信 logo"/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value="alipay"
compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center">
<Image src={alipay} alt="支付宝 logo"/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<FieldPayment/>
<Pay
method={props.method}
balance={profile.balance}
@@ -220,8 +169,8 @@ function BalanceOrLogin(props: {
short: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
},
}}/>
</>

View File

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

View File

@@ -11,6 +11,7 @@ export const buttonVariants = cva(
'aria-invalid:ring-fail/20 dark:aria-invalid:ring-fail/40 aria-invalid:border-fail', // 无效状态样式
'inline-flex items-center justify-center gap-2', // 布局
'[&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0 ',
'cursor-pointer',
],
{
variants: {

View File

@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import {Drawer as DrawerPrimitive} from 'vaul'
import {merge} from '@/lib/utils/index'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props}/>
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props}/>
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props}/>
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props}/>
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={merge(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay/>
<DrawerPrimitive.Content
data-slot="drawer-content"
className={merge(
'group/drawer-content fixed z-50 flex h-auto flex-col bg-background',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block"/>
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({className, ...props}: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={merge(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
)
}
function DrawerFooter({className, ...props}: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={merge('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={merge('font-semibold text-foreground', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={merge('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -1,7 +1,15 @@
// 定义后端服务URL和OAuth2配置
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID
const CLIENT_SECRET = process.env.CLIENT_SECRET
const _api_base_url = process.env.NEXT_PUBLIC_API_BASE_URL
if (!_api_base_url) throw new Error('NEXT_PUBLIC_API_BASE_URL is not set')
const API_BASE_URL = _api_base_url
const _client_id = process.env.CLIENT_ID
if (!_client_id) throw new Error('CLIENT_ID is not set')
const CLIENT_ID = _client_id
const _client_secret = process.env.CLIENT_SECRET
if (!_client_secret) throw new Error('CLIENT_SECRET is not set')
const CLIENT_SECRET = _client_secret
// 统一的API响应类型
type ApiResponse<T = undefined> = {

View File

@@ -1,16 +1,26 @@
import {StaticImageData} from 'next/image'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg'
export const TradeMethodDecoration = {
alipay: {
text: '支付宝',
icon: alipay,
},
wechat: {
text: '微信支付',
icon: wechat,
},
export function getTradeMethodDecoration(method: TradeMethod) {
switch (method) {
case TradeMethod.Alipay:
return {
text: '支付宝',
icon: alipay,
}
case TradeMethod.Wechat:
return {
text: '微信支付',
icon: wechat,
}
default:
return {
text: '扫码支付',
icon: balance,
}
}
}
// 支付方法枚举