26 Commits

Author SHA1 Message Date
a0b0956677 免费与菜单提示文字 2026-04-21 16:19:11 +08:00
Eamon-meng
27e694ee0d 取消关闭支付弹窗调用关闭订单接口 & 添加未实名去支付时的拦截 2026-04-20 16:22:49 +08:00
Eamon-meng
74d53c619d 购买套餐添加count_min字段 2026-04-20 15:36:50 +08:00
8f8def3a87 完善异常跳转 2026-04-20 15:34:49 +08:00
Eamon-meng
ed73d8579f 更新发布v1.7.0 2026-04-18 17:36:22 +08:00
Eamon-meng
e3c61a77e6 修复未登录时提取ip时的套餐显示和不调用获取套餐接口 2026-04-18 17:13:26 +08:00
5f7756199a 修复 sku 缺失显示问题 2026-04-18 15:47:20 +08:00
6aa108e8d3 优化完善套餐购买页面 2026-04-18 15:35:35 +08:00
Eamon-meng
8b65a1745c 修复用户未/授权购买套餐价格计算 & 发布v1.6.0版本 2026-04-16 17:46:11 +08:00
Eamon-meng
5607217625 动态生成购买套餐 & 取消初次进后台修改密码的弹窗 & 添加总折扣字段 & 发布v1.5.0版本 2026-04-16 14:41:42 +08:00
319baea5e8 修复环境变量问题 2026-04-14 11:34:28 +08:00
Eamon-meng
9a8a1826c9 发布v1.4.0版本 2026-04-13 11:33:19 +08:00
Eamon-meng
c2a0310ee5 修复未认证提取ip的接口调用户会话 2026-04-13 10:23:19 +08:00
Eamon-meng
8ee8feb2bf 修改构建脚本路径 2026-04-02 17:56:48 +08:00
Eamon-meng
1e090f5c88 更新配置文件修复构建问题 2026-04-02 17:52:15 +08:00
Eamon-meng
665ce79e1d 发布v1.3.0版本 2026-04-02 17:35:51 +08:00
Eamon-meng
93110954bb 更新订单详情显示字段名称 2026-04-02 14:08:32 +08:00
Eamon-meng
8ce5f99a24 开启充值和余额支付功能 2026-03-31 16:11:47 +08:00
Eamon-meng
e27869fb4a 重新计算价格显示 2026-03-31 16:11:46 +08:00
Eamon-meng
01c4afd209 更新发布v1.2.3版本 2026-03-31 16:11:46 +08:00
2a959fa9cf 优化客户端请求机制 2026-03-31 16:09:43 +08:00
Eamon-meng
d9f267e257 调整桌面端产品订购的菜单栏布局 2026-03-14 18:00:27 +08:00
Eamon-meng
83530d7f1e 修改移动端菜单栏侧边栏布局 2026-03-14 15:25:23 +08:00
Eamon-meng
b2c36196b4 修复实名认证阶段的问题 2026-03-13 18:26:23 +08:00
Eamon-meng
d2d6c1709c 我的套餐新增状态筛选字段 2026-03-13 18:12:22 +08:00
Eamon-meng
a76e61beb0 补充按钮手形样式显示 2026-03-13 14:30:48 +08:00
63 changed files with 2036 additions and 1493 deletions

4
.env.example Normal file
View File

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

View File

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

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
# 生产阶段
@@ -31,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN rm .env
USER nextjs

View File

@@ -1,31 +1,11 @@
## TODO
- 导航栏
- 账单页面
- 实名认证响应
分离公共 api 接口 env 定义
统一前端基础库类型api
购买页固定套餐
优惠问题
### 禁止直接依赖 form
`\[(.*,)?form(,.*)?\]`
### 次要
业务定制页面每月需求用量,可选项需要确认是否合理
帮助中心文档优化
购买与提取手机端优化,尽量一页展示全部
全部替换封装时间范围组件,检查结束时间字段手机端适配问题(需要尾部对齐)
树组件优化
## 目录结构

View File

@@ -1,6 +1,6 @@
{
"name": "lanhu-web",
"version": "1.2.2",
"version": "1.8.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack",

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,24 @@
'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'
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
@@ -26,6 +42,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 +56,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}`)
})
// ======================
@@ -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>> {
let response: Response
try {
const reqHeaders = await headers()
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 (reqUA) callHeaders['User-Agent'] = reqUA
response = await fetch(url, {
const response = await fetch(url, {
method: 'POST',
headers: callHeaders,
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) {
console.error('后端请求失败', url, (e as Error).message)
throw new Error(`请求失败,网络错误`)
}
const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('text/plain') !== -1) {
const text = await response.text()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, text)
return {
success: false,
status: response.status,
message: text || '请求失败',
}
}
if (!!text?.trim()?.length) {
console.log('未处理的响应成功', `type=text`, `text=${text}`)
}
return {
success: true,
data: undefined as R, // 强转类型,考虑优化
}
}
else if (type.indexOf('application/json') !== -1) {
const json = await response.json()
if (!response.ok) {
console.log('后端请求失败', url, `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || json.error_description || '请求失败', // 业务错误message或者 oauth 错误error_description
}
}
return {
success: true,
data: json,
}
}
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
}
async function postCall<R = undefined>(rawResp: Promise<ApiResponse<R>>) {
const header = await headers()
const pathname = header.get('x-pathname') || '/'
const resp = await rawResp
// 重定向到登录页
const match = [
RegExp(`^/admin.*`),
].some(item => item.test(pathname))
if (match && !resp.success && resp.status === 401) {
console.log('🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗')
redirect('/login?force=true')
}
return resp
}
// 导出

View File

@@ -32,5 +32,5 @@ export async function createChannels(params: {
city?: string
isp?: number
}) {
return callPublic<CreateChannelsResp[]>('/api/channel/create', params)
return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
}

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

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

View File

@@ -24,6 +24,7 @@ export async function listResourceShort(props: {
size: number
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
@@ -87,9 +89,17 @@ export async function payClose(props: {
}
export async function getPrice(props: CreateResourceReq) {
return callByDevice<{
return callByUser<{
price: string
discounted_price?: string
discounted?: number
actual?: string
discounted?: string
}>('/api/resource/price', props)
}
export async function getPriceHome(props: CreateResourceReq) {
return callByDevice<{
price: string
actual?: string
discounted?: string
}>('/api/resource/price', props)
}

View File

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

View File

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

View File

@@ -34,20 +34,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard() {
const router = useRouter()
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 updateLoginMode = (mode: LoginMode) => {
sessionStorage.setItem('login_mode', mode)
}
useEffect(() => {
const mode = sessionStorage.getItem('login_mode')
if (mode) {
setMode(mode as LoginMode)
}
}, [])
const form = useForm<LoginSchema>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: {
@@ -55,7 +47,16 @@ export default function LoginCard() {
password: '',
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) => {
setSubmitting(true)
try {
@@ -93,13 +94,14 @@ export default function LoginCard() {
<Tabs
value={mode}
onValueChange={(val) => {
setMode(val as typeof mode)
form.reset({username: form.getValues('username'), password: '', remember: false})
setMode(val as LoginMode)
form.reset({username: '', password: '', remember: false})
form.clearErrors()
}}
className="mb-6">
<TabsList className="w-full p-0 bg-white">
<Tab value="password"></Tab>
<Tab value="phone_code"></Tab>
<Tab value="phone_code">/</Tab>
</TabsList>
</Tabs>
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
@@ -124,6 +126,7 @@ export default function LoginCard() {
className="h-10"
placeholder="请输入验证码"
autoComplete="one-time-code"
disabled={submitting}
/>
<SendMsgByUsername/>
</div>
@@ -137,6 +140,7 @@ export default function LoginCard() {
placeholder="至少6位密码需包含字母和数字"
autoComplete="current-password"
minLength={6}
disabled={submitting}
/>
<button
type="button"
@@ -162,6 +166,7 @@ export default function LoginCard() {
id={id}
checked={field.value}
onCheckedChange={field.onChange}
disabled={submitting}
/>
<div className="space-y-1 leading-none">
<Label></Label>

View File

@@ -29,7 +29,7 @@ export function HeroSection() {
<FreeTrial className={[
`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(' ')}/>
</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,313 @@
import ProductMenu from './menu-product'
import HelpMenu from './menu-help'
import SolutionMenu from './menu-solution'
'use client'
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 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 (
<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">
{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>
)
}
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,13 +1,17 @@
'use client'
import {ReactNode, useContext, useState} from 'react'
import {ReactNode, useContext, useEffect, 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'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type ProductItem = Product
type TabType = 'domestic' | 'oversea'
export default function ProductMenu() {
@@ -52,33 +56,51 @@ export function Tab(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 (
<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">
{shortProduct && (
<ProductCard
icon={prod}
label="短效动态 IP"
discount="最低4.5折"
desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
href={`/product?type=${shortProduct.code}`}
/>
)}
{longProduct && (
<ProductCard
icon={prod}
label="长效动态 IP"
discount="最低4.5折"
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
href={`/product?type=${longProduct.code}`}
/>
)}
</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 +114,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 +139,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

@@ -237,7 +237,7 @@ export default function CustomPage() {
<section className="relative rounded-lg overflow-hidden h-48 lg:h-56">
<Image
src={group}
alt="免费试用背景"
alt="立即试用背景"
fill
className="object-cover"
priority
@@ -251,9 +251,9 @@ export default function CustomPage() {
className={merge(
'bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-md whitespace-nowrap',
)}
onClick={() => router.push('/login')}
onClick={() => router.push('/product')}
>
</Button>
</div>
</div>

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>
@@ -269,7 +282,7 @@ function ProfileOrLogin() {
<span></span>
</Link>
<Link
href="/login"
href="/login?tab=sms"
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`,
`transition-colors duration-200 ease-in-out`,

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

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

View File

@@ -192,7 +192,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
<span ></span>
<div className="flex flex-wrap gap-1 max-w-[200px]">
{channel.whitelists.split(',').map((ip, index) => (
<Badge key={index} variant="secondary">
<Badge key={index} className="bg-green-100 text-green-700 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400">
{ip.trim()}
</Badge >
))}
@@ -201,7 +201,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
) : hasAuth ? (
<div className="flex flex-col">
<span></span>
<Badge variant="secondary">
<Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400">
{channel.username}:{channel.password}
</Badge >
</div>

View File

@@ -2,7 +2,6 @@
import {ReactNode, Suspense, use, useState} from 'react'
import Image from 'next/image'
import Link from 'next/link'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import UserCenter from '@/components/composites/user-center'
import {Button} from '@/components/ui/button'
@@ -75,6 +74,7 @@ export function Content(props: {children: ReactNode}) {
}
function ContentResolved() {
const profile = use(useProfileStore(store => store.profile))
if (profile)
return (
<>
@@ -82,10 +82,10 @@ function ContentResolved() {
triggerClassName="hidden"
defaultOpen={!profile.id_token}
/>
<ChangePasswordDialog
{/* <ChangePasswordDialog
triggerClassName="hidden"
defaultOpen={!profile.has_password}
/>
/> */}
</>
)
}
@@ -103,25 +103,33 @@ export function Header() {
<div className="flex-auto flex items-center gap-2">
<Button
theme="ghost"
className="w-9 h-9 ml-4 md:ml-0"
className="h-9 ml-4 md:ml-0"
onClick={toggleNavbar}>
{navbar
? <PanelLeftCloseIcon/>
: <PanelLeftOpenIcon/>
}
{navbar ? (
<>
<PanelLeftCloseIcon/>
<span className="text-foreground/90"></span>
</>
) : (
<>
<PanelLeftOpenIcon/>
<span className="text-foreground/90"></span>
</>
)}
</Button>
<span className="max-md:hidden">
</span>
<span className="max-md:hidden"></span>
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2"/>
<Link
href="/admin/identify"
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<IdCard size={16}/>
<span></span>
</Link>
</div>
{/* right */}
<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`,
)}>
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
<Link href="/" className="flex-none h-16 flex items-center justify-center text-sm">
</Link>
<Suspense>
@@ -176,7 +184,7 @@ export function Navbar() {
<TooltipProvider>
<NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/>
<NavTitle label="快速开始"/>
<NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/>
{/* <NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/> */}
<NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/>
<NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/>
<NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/>

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

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

View File

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

View File

@@ -164,16 +164,21 @@ export default function RecordPage(props: RecordPageProps) {
cell: ({row}) => <div>{row.original.prov}</div>,
accessorKey: 'prov',
},
{
header: '城市',
cell: ({row}) => <div>{row.original.city}</div>,
accessorKey: 'city',
},
{
header: '提取数量',
cell: ({row}) => <div>{row.original.count}</div>,
accessorKey: 'count',
},
{
header: '资源数量',
cell: ({row}) => <div>{row.original.resource_id}</div>,
accessorKey: 'resource_id',
},
// {
// header: '资源数量',
// cell: ({row}) => <div>{row.original.resource_id}</div>,
// accessorKey: 'resource_id',
// },
{
header: '提取时间',
cell: ({row}) => {

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>
},
},
{
@@ -213,6 +219,17 @@ export default function ResourceList({resourceType}: ResourceListProps) {
header: '开通时间',
cell: ({row}) => formatDateTime(row.original.created_at),
},
{
header: '状态',
cell: ({row}) => {
const isActive = row.original.active
return (
<span className={isActive ? 'text-green-500' : 'text-red-500'}>
{isActive ? '启用' : '禁用'}
</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) {

View File

@@ -8,6 +8,8 @@ import {LayoutStoreProvider} from '@/components/stores/layout'
import {ClientStoreProvider} from '@/components/stores/client'
import {getProfile} from '@/actions/auth'
import Script from 'next/script'
import {AppStoreProvider} from '@/components/stores/app'
import {getApiUrl} from '@/actions/base'
export async function generateMetadata(): Promise<Metadata> {
return {
@@ -30,12 +32,14 @@ export default async function RootLayout(props: Readonly<{
)
}
function StoreProviders(props: {children: ReactNode}) {
async function StoreProviders(props: {children: ReactNode}) {
return (
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
<LayoutStoreProvider>
<ClientStoreProvider>
{props.children}
<AppStoreProvider url={await getApiUrl().then(r => r.data)}>
{props.children}
</AppStoreProvider>
</ClientStoreProvider>
</LayoutStoreProvider>
</ProfileStoreProvider>

View File

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

View File

@@ -9,7 +9,7 @@ import {Button} from '@/components/ui/button'
import {useForm, useFormContext, useWatch} from 'react-hook-form'
import {Alert, AlertTitle} from '@/components/ui/alert'
import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react'
import {memo, ReactNode, useEffect, useRef, useState} from 'react'
import {memo, ReactNode, Suspense, use, useEffect, useRef, useState} from 'react'
import {useStatus} from '@/lib/states'
import {allResource} from '@/actions/resource'
import {Resource} from '@/lib/models'
@@ -113,7 +113,9 @@ const FormFields = memo(() => {
return (
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
{/* 选择套餐 */}
<SelectResource/>
<Suspense>
<SelectResource/>
</Suspense>
{/* 地区筛选 */}
<SelectRegion/>
@@ -332,12 +334,16 @@ FormFields.displayName = 'FormFields'
function SelectResource() {
const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
const profile = useProfileStore(state => state.profile)
const profile = use(useProfileStore(store => store.profile))
const getResources = async () => {
if (!profile) {
setStatus('done')
setResources([])
return
}
setStatus('load')
try {
const resp = await allResource()
if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试')
}
@@ -353,7 +359,7 @@ function SelectResource() {
useEffect(() => {
getResources().then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [profile])
return (
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
@@ -373,110 +379,111 @@ function SelectResource() {
</div>
) : !profile ? (
<div className="p-4 flex gap-1 items-center">
<Loader className="animate-spin" size={20}/>
<span><Link href="/login" className="text-blue-600 hover:underline"></Link></span>
{/* <Loader className="animate-spin" size={20}/> */}
<span className="text-gray-600"><Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium"></Link></span>
</div>
) : resources.length === 0 ? (
<div className="p-4 flex gap-1 items-center">
<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 +658,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

@@ -46,7 +46,7 @@ export function DesktopPayment(props: PaymentModalProps) {
<p className="text-sm text-gray-600">
使
{decoration.text}
{/* 扫码支付 */}
</p>
<div className="w-full text-center space-y-2">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,179 +0,0 @@
'use client'
import {Suspense, use, useEffect, useState} from 'react'
import {useProfileStore} from '@/components/stores/profile'
import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
import {merge} from '@/lib/utils'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/long/form'
import {Card} from '@/components/ui/card'
import {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment'
export default function Right() {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const mode = useWatch({control, name: 'type'})
const live = useWatch({control, name: 'live'})
const quota = useWatch({control, name: 'quota'})
const expire = useWatch({control, name: 'expire'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const resp = await getPrice({
type: 2,
long: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!resp.success) {
throw new Error('获取价格失败')
}
setPriceData({
price: resp.data.price,
discounted_price: resp.data.discounted_price ?? resp.data.price ?? '',
discounted: resp.data.discounted,
})
}
catch (error) {
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [dailyLimit, expire, live, quota, mode])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
)}>
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{mode === '2' ? `包量套餐` : `包时套餐`}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">
{live}
{' '}
</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{expire}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{dailyLimit}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span className="text-xl text-orange-500">
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
discountedPrice: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
{/* <FieldPayment/> */}
<Pay
method={props.method}
balance={profile.balance}
amount={props.discountedPrice}
resource={{
type: 2,
long: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

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

View File

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

View File

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

View File

@@ -4,36 +4,39 @@ import {FormField} from '@/components/ui/form'
import Image from 'next/image'
import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import RechargeModal from '@/components/composites/recharge'
export function FieldPayment() {
export function FieldPayment(props: {
balance: number
}) {
return (
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
value={field.value}
onValueChange={field.onChange}
className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<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>
<span className="text-xl">{props.balance}</span>
<RechargeModal/>
</p>
</div> */}
{/* <FormOption
</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>
<FormOption
id={`${id}-wechat`}
value="wechat"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,181 +0,0 @@
'use client'
import {Suspense, use, useEffect, useState} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {useProfileStore} from '@/components/stores/profile'
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 {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment'
export default function Right() {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const live = useWatch({control, name: 'live'})
const mode = useWatch({control, name: 'type'})
const expire = useWatch({control, name: 'expire'})
const quota = useWatch({control, name: 'quota'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const priceResponse = await getPrice({
type: 1,
short: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!priceResponse.success) {
throw new Error('获取价格失败')
}
const data = priceResponse.data
setPriceData({
price: data.price,
discounted_price: data.discounted_price ?? data.price ?? '',
discounted: data.discounted,
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [expire, live, quota, mode, dailyLimit])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
)}>
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{mode === '2' ? `包量套餐` : `包时套餐`}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">
{Number(live) / 60}
{' '}
</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{expire}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{dailyLimit}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted === 1 ? '' : discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span className="text-xl text-orange-500">
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
}
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
discountedPrice: string
mode: string
live: string
quota: number
expire: string
dailyLimit: number
}) {
const profile = use(useProfileStore(store => store.profile))
return profile ? (
<>
{/* <FieldPayment/> */}
<Pay
method={props.method}
balance={profile.balance}
amount={props.discountedPrice}
resource={{
type: 1,
short: {
mode: Number(props.mode),
live: Number(props.live),
expire: Number(props.expire),
quota: props.mode === '1' ? props.dailyLimit : props.quota,
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

@@ -26,7 +26,7 @@ function Resolved(props: {
router.push(profile ? '/admin/purchase' : '/product')
}}
>
</button>
)
}
@@ -42,7 +42,7 @@ function Pending(props: {
router.push('/product')
}}
>
</button>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import {createStore, StoreApi} from 'zustand/vanilla'
import {createContext, ReactNode, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
// store
type AppStoreState = {
apiUrl: string
}
export function createAppStore(url: string) {
return createStore<AppStoreState>()(() => ({
apiUrl: url,
}))
}
// provider
const AppStoreContext = createContext<StoreApi<AppStoreState> | null>(null)
export function AppStoreProvider(props: {
url: string
children: ReactNode
}) {
const [store] = useState(() => createAppStore(props.url))
return (
<AppStoreContext.Provider value={store}>
{props.children}
</AppStoreContext.Provider>
)
}
export function useAppStore(name: keyof AppStoreState) {
const context = useContext(AppStoreContext)
if (!context) {
throw new Error('AppStoreContext 没有正确初始化')
}
return useStore(context, c => c[name])
}

View File

@@ -0,0 +1,78 @@
'use client'
import {useEffect, useRef, useState} from 'react'
import Cap from '@cap.js/widget'
import {updateSendSMS} from '@/actions/verify'
import {toast} from 'sonner'
import {Button} from '@/components/ui/button'
import {LoaderIcon} from 'lucide-react'
export default function SendMsg() {
const [countdown, setCountdown] = useState(0)
const [progress, setProgress] = useState(0)
const [mode, setMode] = useState<'ready' | 'wait' | 'check'>('ready')
const cap = useRef(new Cap({apiEndpoint: '/'}))
cap.current.addEventListener('progress', (event) => {
setProgress(event.detail.progress)
})
// 发送验证码
const sendCode = async () => {
try {
setMode('check')
// 完成挑战
const result = await cap.current.solve()
if (!result.success || !cap.current.token) {
throw new Error('人机验证失败')
}
// 发送验证码
const resp = await updateSendSMS({
captcha: cap.current.token,
})
if (!resp.success) {
throw new Error(`验证码发送失败: ${resp.message}`)
}
setMode('wait')
setCountdown(60)
toast.success('验证码已发送')
}
catch (e) {
setMode('ready')
toast.error('验证码发送失败', {
description: (e as Error).message,
})
}
}
// 计时
useEffect(() => {
const interval = setInterval(() => {
if (countdown > 0) {
setCountdown(countdown - 1)
}
else if (mode === 'wait') {
setMode('ready')
}
}, 1000)
return () => clearInterval(interval)
}, [countdown, mode])
return (
<Button
type="button"
theme="outline"
className="whitespace-nowrap h-10"
disabled={countdown > 0}
onClick={sendCode}
>
{mode === 'check' && <LoaderIcon className="animate-spin"/>}
<span>
{mode === 'check' && '检查登录环境'}
{mode === 'wait' && `${countdown}秒后重发`}
{mode === 'ready' && '获取验证码'}
</span>
</Button>
)
}

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.API_BASE_URL
if (!_api_base_url) throw new Error('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

@@ -8,4 +8,5 @@ export type Batch = {
time: string
user_id: number
prov: string
city: string
}

View File

@@ -0,0 +1,11 @@
export type ProductSku = {
id: number
code: string
count_min: number
name: string
price: string
price_min: string
product_id: number
discount_id: number
status: number
}

11
src/lib/models/product.ts Normal file
View File

@@ -0,0 +1,11 @@
import {ProductSku} from './product-sku'
export type Product = {
id: number
code: string
name: string
description: string
sort: number
status: number
skus?: ProductSku[]
}