Compare commits
20 Commits
2a959fa9cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b0956677 | |||
|
|
27e694ee0d | ||
|
|
74d53c619d | ||
| 8f8def3a87 | |||
|
|
ed73d8579f | ||
|
|
e3c61a77e6 | ||
| 5f7756199a | |||
| 6aa108e8d3 | |||
|
|
8b65a1745c | ||
|
|
5607217625 | ||
| 319baea5e8 | |||
|
|
9a8a1826c9 | ||
|
|
c2a0310ee5 | ||
|
|
8ee8feb2bf | ||
|
|
1e090f5c88 | ||
|
|
665ce79e1d | ||
|
|
93110954bb | ||
|
|
8ce5f99a24 | ||
|
|
e27869fb4a | ||
|
|
01c4afd209 |
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
API_BASE_URL=http://192.168.3.42:8080
|
||||||
|
CLIENT_ID=web
|
||||||
|
CLIENT_SECRET=web
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -122,7 +122,7 @@ type UserProfile = ExtraResp<typeof getProfile>
|
|||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
需要配置:
|
需要配置:
|
||||||
- `NEXT_PUBLIC_API_BASE_URL` - 后端 API 地址
|
- `API_BASE_URL` - 后端 API 地址
|
||||||
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
|
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ COPY . .
|
|||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN mv .env.example .env
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# 生产阶段
|
# 生产阶段
|
||||||
@@ -31,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
RUN rm .env
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lanhu-web",
|
"name": "lanhu-web",
|
||||||
"version": "1.2.2",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0 --turbopack",
|
"dev": "next dev -H 0.0.0.0 --turbopack",
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ if ($confrim -ne "y") {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
docker build -t 43.226.58.254:53000/lanhu/web:latest .
|
docker build -t repo.lanhuip.com:8554/lanhu/web:latest .
|
||||||
docker build -t 43.226.58.254:53000/lanhu/web:$($args[0]) .
|
docker build -t repo.lanhuip.com:8554/lanhu/web:$($args[0]) .
|
||||||
|
|
||||||
docker push 43.226.58.254:53000/lanhu/web:latest
|
docker push repo.lanhuip.com:8554/lanhu/web:latest
|
||||||
docker push 43.226.58.254:53000/lanhu/web:$($args[0])
|
docker push repo.lanhuip.com:8554/lanhu/web:$($args[0])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api'
|
import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api'
|
||||||
import {add, isBefore} from 'date-fns'
|
import {add, isBefore} from 'date-fns'
|
||||||
import {cookies, headers} from 'next/headers'
|
import {cookies, headers} from 'next/headers'
|
||||||
|
import {redirect} from 'next/navigation'
|
||||||
import {cache} from 'react'
|
import {cache} from 'react'
|
||||||
|
|
||||||
export type TokenResp = {
|
export type TokenResp = {
|
||||||
@@ -12,6 +13,13 @@ export type TokenResp = {
|
|||||||
scope?: string
|
scope?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getApiUrl() {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: API_BASE_URL,
|
||||||
|
} satisfies ApiResponse<string>
|
||||||
|
}
|
||||||
|
|
||||||
// ======================
|
// ======================
|
||||||
// public
|
// public
|
||||||
// ======================
|
// ======================
|
||||||
@@ -99,7 +107,6 @@ const _callByUser = cache(async <R = undefined>(
|
|||||||
// ======================
|
// ======================
|
||||||
|
|
||||||
async function call<R = undefined>(url: string, body: RequestInit['body'], auth?: string): Promise<ApiResponse<R>> {
|
async function call<R = undefined>(url: string, body: RequestInit['body'], auth?: string): Promise<ApiResponse<R>> {
|
||||||
let response: Response
|
|
||||||
try {
|
try {
|
||||||
const reqHeaders = await headers()
|
const reqHeaders = await headers()
|
||||||
const reqIP = reqHeaders.get('x-forwarded-for')
|
const reqIP = reqHeaders.get('x-forwarded-for')
|
||||||
@@ -111,55 +118,59 @@ async function call<R = undefined>(url: string, body: RequestInit['body'], auth?
|
|||||||
if (reqIP) callHeaders['X-Forwarded-For'] = reqIP
|
if (reqIP) callHeaders['X-Forwarded-For'] = reqIP
|
||||||
if (reqUA) callHeaders['User-Agent'] = reqUA
|
if (reqUA) callHeaders['User-Agent'] = reqUA
|
||||||
|
|
||||||
response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: callHeaders,
|
headers: callHeaders,
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return redirect('/login?redirect=' + encodeURIComponent(url.replace(API_BASE_URL, '')))
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||||
|
if (type.indexOf('text/plain') !== -1) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('后端请求失败', url, `status=${response.status}`, text)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: text || '请求失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!!text?.trim()?.length) {
|
||||||
|
console.log('未处理的响应成功', `type=text`, `text=${text}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: undefined as R, // 强转类型,考虑优化
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type.indexOf('application/json') !== -1) {
|
||||||
|
const json = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('后端请求失败', url, `status=${response.status}`, json)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: json.message || json.error_description || '请求失败', // 业务错误(message)或者 oauth 错误(error_description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error('后端请求失败', url, (e as Error).message)
|
console.error('后端请求失败', url, (e as Error).message)
|
||||||
throw new Error(`请求失败,网络错误`)
|
throw new Error(`请求失败,网络错误`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
|
||||||
if (type.indexOf('text/plain') !== -1) {
|
|
||||||
const text = await response.text()
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log('后端请求失败', url, `status=${response.status}`, text)
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: response.status,
|
|
||||||
message: text || '请求失败',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!!text?.trim()?.length) {
|
|
||||||
console.log('未处理的响应成功', `type=text`, `text=${text}`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: undefined as R, // 强转类型,考虑优化
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (type.indexOf('application/json') !== -1) {
|
|
||||||
const json = await response.json()
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log('后端请求失败', url, `status=${response.status}`, json)
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
status: response.status,
|
|
||||||
message: json.message || json.error_description || '请求失败', // 业务错误(message)或者 oauth 错误(error_description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
|
|||||||
@@ -32,5 +32,5 @@ export async function createChannels(params: {
|
|||||||
city?: string
|
city?: string
|
||||||
isp?: number
|
isp?: number
|
||||||
}) {
|
}) {
|
||||||
return callPublic<CreateChannelsResp[]>('/api/channel/create', params)
|
return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/actions/product.ts
Normal file
12
src/actions/product.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {callByUser, callPublic} from './base'
|
||||||
|
import {Product} from '@/lib/models/product'
|
||||||
|
|
||||||
|
export type ProductItem = Product
|
||||||
|
|
||||||
|
export async function listProduct(props: {}) {
|
||||||
|
return callByUser<Product[]>('/api/product/list', props)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listProductHome(props: {}) {
|
||||||
|
return callPublic<Product[]>('/api/product/list', props)
|
||||||
|
}
|
||||||
@@ -89,9 +89,17 @@ export async function payClose(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPrice(props: CreateResourceReq) {
|
export async function getPrice(props: CreateResourceReq) {
|
||||||
return callByDevice<{
|
return callByUser<{
|
||||||
price: string
|
price: string
|
||||||
discounted_price?: string
|
actual?: string
|
||||||
discounted?: number
|
discounted?: string
|
||||||
|
}>('/api/resource/price', props)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPriceHome(props: CreateResourceReq) {
|
||||||
|
return callByDevice<{
|
||||||
|
price: string
|
||||||
|
actual?: string
|
||||||
|
discounted?: string
|
||||||
}>('/api/resource/price', props)
|
}>('/api/resource/price', props)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,16 +38,16 @@ export async function Identify(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function update(props: {
|
export async function update(props: {
|
||||||
username: string
|
username?: string
|
||||||
email: string
|
email?: string
|
||||||
contact_qq: string
|
contact_qq?: string
|
||||||
contact_wechat: string
|
contact_wechat?: string
|
||||||
}) {
|
}) {
|
||||||
return await callByUser('/api/user/update', props)
|
return await callByUser('/api/user/update', props)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePassword(props: {
|
export async function updatePassword(props: {
|
||||||
phone: string
|
// phone: string
|
||||||
code: string
|
code: string
|
||||||
password: string
|
password: string
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use server'
|
'use server'
|
||||||
import {ApiResponse} from '@/lib/api'
|
import {ApiResponse} from '@/lib/api'
|
||||||
import {callByDevice} from '@/actions/base'
|
import {callByDevice, callByUser} from '@/actions/base'
|
||||||
import {getCap} from '@/lib/cap'
|
import {getCap} from '@/lib/cap'
|
||||||
|
|
||||||
export async function sendSMS(props: {
|
export async function sendSMS(props: {
|
||||||
@@ -38,3 +38,35 @@ export async function sendSMS(props: {
|
|||||||
throw new Error('验证码验证失败', {cause: error})
|
throw new Error('验证码验证失败', {cause: error})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSendSMS(props: {
|
||||||
|
captcha: string
|
||||||
|
}): Promise<ApiResponse> {
|
||||||
|
try {
|
||||||
|
// 人机验证
|
||||||
|
if (!props.captcha?.length) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 400,
|
||||||
|
message: '请输入验证码',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = await getCap()
|
||||||
|
const valid = await cap.validateToken(props.captcha)
|
||||||
|
if (!valid) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 400,
|
||||||
|
message: '验证码错误或已过期',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求发送短信
|
||||||
|
return await callByUser('/api/verify/sms/password', {})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('验证码验证失败:', error)
|
||||||
|
throw new Error('验证码验证失败', {cause: error})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,20 +34,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
|
|||||||
export default function LoginCard() {
|
export default function LoginCard() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||||
const [mode, setMode] = useState<LoginMode>('phone_code')
|
const [mode, setMode] = useState<LoginMode>('password')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const updateLoginMode = (mode: LoginMode) => {
|
const updateLoginMode = (mode: LoginMode) => {
|
||||||
sessionStorage.setItem('login_mode', mode)
|
sessionStorage.setItem('login_mode', mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mode = sessionStorage.getItem('login_mode')
|
|
||||||
if (mode) {
|
|
||||||
setMode(mode as LoginMode)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const form = useForm<LoginSchema>({
|
const form = useForm<LoginSchema>({
|
||||||
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -55,7 +47,16 @@ export default function LoginCard() {
|
|||||||
password: '',
|
password: '',
|
||||||
remember: false,
|
remember: false,
|
||||||
},
|
},
|
||||||
|
mode: 'onChange',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedMode = sessionStorage.getItem('login_mode') as LoginMode
|
||||||
|
if (savedMode && savedMode === 'phone_code') {
|
||||||
|
setMode(savedMode)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handler = form.handleSubmit(async (data) => {
|
const handler = form.handleSubmit(async (data) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
@@ -93,13 +94,14 @@ export default function LoginCard() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
value={mode}
|
value={mode}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
setMode(val as typeof mode)
|
setMode(val as LoginMode)
|
||||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
form.reset({username: '', password: '', remember: false})
|
||||||
|
form.clearErrors()
|
||||||
}}
|
}}
|
||||||
className="mb-6">
|
className="mb-6">
|
||||||
<TabsList className="w-full p-0 bg-white">
|
<TabsList className="w-full p-0 bg-white">
|
||||||
<Tab value="password">密码登录</Tab>
|
<Tab value="password">密码登录</Tab>
|
||||||
<Tab value="phone_code">验证码登录</Tab>
|
<Tab value="phone_code">验证码登录/注册</Tab>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
|
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
|
||||||
@@ -124,6 +126,7 @@ export default function LoginCard() {
|
|||||||
className="h-10"
|
className="h-10"
|
||||||
placeholder="请输入验证码"
|
placeholder="请输入验证码"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<SendMsgByUsername/>
|
<SendMsgByUsername/>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +140,7 @@ export default function LoginCard() {
|
|||||||
placeholder="至少6位密码,需包含字母和数字"
|
placeholder="至少6位密码,需包含字母和数字"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
minLength={6}
|
minLength={6}
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -162,6 +166,7 @@ export default function LoginCard() {
|
|||||||
id={id}
|
id={id}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1 leading-none">
|
<div className="space-y-1 leading-none">
|
||||||
<Label>保持登录</Label>
|
<Label>保持登录</Label>
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default function HelpMenu() {
|
|||||||
icon={h01}
|
icon={h01}
|
||||||
title="提取 IP"
|
title="提取 IP"
|
||||||
items={[
|
items={[
|
||||||
{lead: '短效 IP 提取', href: '/collect?type=short'},
|
{lead: '短效/长效 IP 提取', href: '/collect?type=short'},
|
||||||
{lead: '长效 IP 提取', href: '/collect?type=long'},
|
// {lead: '长效 IP 提取', href: '/collect?type=long'},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Column
|
<Column
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {useContext, useState} from 'react'
|
import {useContext, useEffect, useState} from 'react'
|
||||||
import {useRouter} from 'next/navigation'
|
import {useRouter} from 'next/navigation'
|
||||||
import {X} from 'lucide-react'
|
import {X} from 'lucide-react'
|
||||||
import {HeaderContext} from './common'
|
import {HeaderContext} from './common'
|
||||||
@@ -21,6 +21,8 @@ import h03 from '@/assets/header/help/03.svg'
|
|||||||
import {merge} from '@/lib/utils'
|
import {merge} from '@/lib/utils'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import logo from '@/assets/logo.webp'
|
import logo from '@/assets/logo.webp'
|
||||||
|
import {Product} from '@/lib/models/product'
|
||||||
|
import {listProductHome} from '@/actions/product'
|
||||||
|
|
||||||
export type MobileMenuProps = {}
|
export type MobileMenuProps = {}
|
||||||
|
|
||||||
@@ -37,7 +39,18 @@ export default function MobileMenu(props: MobileMenuProps) {
|
|||||||
ctx.setMenu(false)
|
ctx.setMenu(false)
|
||||||
router.push(href)
|
router.push(href)
|
||||||
}
|
}
|
||||||
|
const [productList, setProductList] = useState<Product[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
const res = await listProductHome({})
|
||||||
|
if (res.success) {
|
||||||
|
setProductList(res.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProducts()
|
||||||
|
}, [])
|
||||||
|
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
|
||||||
|
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-white">
|
<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">
|
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
|
||||||
@@ -87,20 +100,24 @@ export default function MobileMenu(props: MobileMenuProps) {
|
|||||||
|
|
||||||
{productTab === 'domestic' && (
|
{productTab === 'domestic' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<ProductItem
|
{shortProduct && (
|
||||||
icon={prod}
|
<ProductItem
|
||||||
label="短效动态IP"
|
icon={prod}
|
||||||
badge="最低4.5折"
|
label="短效动态IP"
|
||||||
href="/product?type=short"
|
badge="最低4.5折"
|
||||||
onNavigate={navigate}
|
href={`/product?type=${shortProduct.code}`}
|
||||||
/>
|
onNavigate={navigate}
|
||||||
<ProductItem
|
/>
|
||||||
icon={prod}
|
)}
|
||||||
label="长效静态IP"
|
{longProduct && (
|
||||||
badge="最低4.5折"
|
<ProductItem
|
||||||
href="/product?type=long"
|
icon={prod}
|
||||||
onNavigate={navigate}
|
label="长效静态IP"
|
||||||
/>
|
badge="最低4.5折"
|
||||||
|
href={`/product?type=${longProduct.code}`}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ProductItem
|
<ProductItem
|
||||||
icon={custom}
|
icon={custom}
|
||||||
label="优质/企业/精选IP"
|
label="优质/企业/精选IP"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {ReactNode, useContext, useState} from 'react'
|
import {ReactNode, useContext, useEffect, useState} from 'react'
|
||||||
import Wrap from '@/components/wrap'
|
import Wrap from '@/components/wrap'
|
||||||
import Image, {StaticImageData} from 'next/image'
|
import Image, {StaticImageData} from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -8,7 +8,10 @@ import prod from '@/assets/header/product/prod.svg'
|
|||||||
import custom from '@/assets/header/product/custom.svg'
|
import custom from '@/assets/header/product/custom.svg'
|
||||||
import {useRouter} from 'next/navigation'
|
import {useRouter} from 'next/navigation'
|
||||||
import {HeaderContext} from './common'
|
import {HeaderContext} from './common'
|
||||||
|
import {Product} from '@/lib/models/product'
|
||||||
|
import {listProductHome} from '@/actions/product'
|
||||||
|
|
||||||
|
export type ProductItem = Product
|
||||||
type TabType = 'domestic' | 'oversea'
|
type TabType = 'domestic' | 'oversea'
|
||||||
|
|
||||||
export default function ProductMenu() {
|
export default function ProductMenu() {
|
||||||
@@ -53,24 +56,41 @@ export function Tab(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Domestic(props: {}) {
|
export function Domestic(props: {}) {
|
||||||
|
const [productList, setProductList] = useState<Product[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
const res = await listProductHome({})
|
||||||
|
if (res.success) {
|
||||||
|
setProductList(res.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProducts()
|
||||||
|
}, [])
|
||||||
|
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
|
||||||
|
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section role="tabpanel" className="flex-auto">
|
<section role="tabpanel" className="flex-auto">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<ProductCard
|
{shortProduct && (
|
||||||
icon={prod}
|
<ProductCard
|
||||||
label="短效动态 IP"
|
icon={prod}
|
||||||
discount="最低4.5折"
|
label="短效动态 IP"
|
||||||
desc="全国 300+ 城市级定位节点,IP 池资源充足,自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
|
discount="最低4.5折"
|
||||||
href="/product?type=short"
|
desc="全国 300+ 城市级定位节点,IP 池资源充足,自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
|
||||||
/>
|
href={`/product?type=${shortProduct.code}`}
|
||||||
<ProductCard
|
/>
|
||||||
icon={prod}
|
)}
|
||||||
label="长效静态 IP"
|
{longProduct && (
|
||||||
discount="最低4.5折"
|
<ProductCard
|
||||||
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
|
icon={prod}
|
||||||
href="/product?type=long"
|
label="长效动态 IP"
|
||||||
/>
|
discount="最低4.5折"
|
||||||
|
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
|
||||||
|
href={`/product?type=${longProduct.code}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<ProductCard
|
<ProductCard
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function CollectPage(props: CollectPageProps) {
|
|||||||
// </Wrap>
|
// </Wrap>
|
||||||
// </main>
|
// </main>
|
||||||
<HomePage path={[
|
<HomePage path={[
|
||||||
{label: '短效IP 提取', href: '/collect'},
|
{label: '短效/长效IP 提取', href: '/collect'},
|
||||||
]}>
|
]}>
|
||||||
<Wrap>
|
<Wrap>
|
||||||
<Extract/>
|
<Extract/>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function CustomPage() {
|
|||||||
<section className="relative rounded-lg overflow-hidden h-48 lg:h-56">
|
<section className="relative rounded-lg overflow-hidden h-48 lg:h-56">
|
||||||
<Image
|
<Image
|
||||||
src={group}
|
src={group}
|
||||||
alt="免费试用背景"
|
alt="立即试用背景"
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority
|
priority
|
||||||
@@ -251,9 +251,9 @@ export default function CustomPage() {
|
|||||||
className={merge(
|
className={merge(
|
||||||
'bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-md whitespace-nowrap',
|
'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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ function ProfileOrLogin() {
|
|||||||
<span>登录</span>
|
<span>登录</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login?tab=sms"
|
||||||
className={[
|
className={[
|
||||||
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
|
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
|
||||||
`transition-colors duration-200 ease-in-out`,
|
`transition-colors duration-200 ease-in-out`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {ReactNode} from 'react'
|
|||||||
import Header from './header'
|
import Header from './header'
|
||||||
import Footer from './footer'
|
import Footer from './footer'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
|
import {MessageCircleMoreIcon} from 'lucide-react'
|
||||||
export type HomeLayoutProps = {
|
export type HomeLayoutProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,21 @@ export default function HomeLayout(props: HomeLayoutProps) {
|
|||||||
{/* 页脚 */}
|
{/* 页脚 */}
|
||||||
<Footer/>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default async function UserCenter() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h4 className="text-sm text-weak">账户余额</h4>
|
<h4 className="text-sm text-weak">账户余额</h4>
|
||||||
<div className="flex justify-between items-baseline">
|
<div className="flex justify-between items-baseline">
|
||||||
<p className="text-xl text-accent">
|
<p className="text-xl text-accent">
|
||||||
@@ -68,7 +68,7 @@ export default async function UserCenter() {
|
|||||||
</p>
|
</p>
|
||||||
<RechargeModal/>
|
<RechargeModal/>
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h4 className="text-sm text-weak">快捷入口</h4>
|
<h4 className="text-sm text-weak">快捷入口</h4>
|
||||||
<div className="flex justify-around gap-2">
|
<div className="flex justify-around gap-2">
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ export default function Addr({channel}: {
|
|||||||
const expired = isBefore(channel.expired_at, new Date())
|
const expired = isBefore(channel.expired_at, new Date())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${expired ? 'text-weak' : ''}`}>
|
<>
|
||||||
<span>{ip}:{port}</span>
|
<span>{ip}:{port}</span>
|
||||||
{expired && (
|
{expired && (
|
||||||
<Badge variant="secondary">
|
<Badge className="ml-2 bg-orange-100 text-orange-700 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400">
|
||||||
已过期
|
已过期
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
|
|||||||
<span >白名单</span>
|
<span >白名单</span>
|
||||||
<div className="flex flex-wrap gap-1 max-w-[200px]">
|
<div className="flex flex-wrap gap-1 max-w-[200px]">
|
||||||
{channel.whitelists.split(',').map((ip, index) => (
|
{channel.whitelists.split(',').map((ip, index) => (
|
||||||
<Badge key={index} variant="secondary">
|
<Badge key={index} className="bg-green-100 text-green-700 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400">
|
||||||
{ip.trim()}
|
{ip.trim()}
|
||||||
</Badge >
|
</Badge >
|
||||||
))}
|
))}
|
||||||
@@ -201,7 +201,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
|
|||||||
) : hasAuth ? (
|
) : hasAuth ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>账号密码</span>
|
<span>账号密码</span>
|
||||||
<Badge variant="secondary">
|
<Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400">
|
||||||
{channel.username}:{channel.password}
|
{channel.username}:{channel.password}
|
||||||
</Badge >
|
</Badge >
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {ReactNode, Suspense, use, useState} from 'react'
|
import {ReactNode, Suspense, use, useState} from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
|
||||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
||||||
import UserCenter from '@/components/composites/user-center'
|
import UserCenter from '@/components/composites/user-center'
|
||||||
import {Button} from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
@@ -75,6 +74,7 @@ export function Content(props: {children: ReactNode}) {
|
|||||||
}
|
}
|
||||||
function ContentResolved() {
|
function ContentResolved() {
|
||||||
const profile = use(useProfileStore(store => store.profile))
|
const profile = use(useProfileStore(store => store.profile))
|
||||||
|
|
||||||
if (profile)
|
if (profile)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -82,10 +82,10 @@ function ContentResolved() {
|
|||||||
triggerClassName="hidden"
|
triggerClassName="hidden"
|
||||||
defaultOpen={!profile.id_token}
|
defaultOpen={!profile.id_token}
|
||||||
/>
|
/>
|
||||||
<ChangePasswordDialog
|
{/* <ChangePasswordDialog
|
||||||
triggerClassName="hidden"
|
triggerClassName="hidden"
|
||||||
defaultOpen={!profile.has_password}
|
defaultOpen={!profile.has_password}
|
||||||
/>
|
/> */}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -103,25 +103,33 @@ export function Header() {
|
|||||||
<div className="flex-auto flex items-center gap-2">
|
<div className="flex-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
theme="ghost"
|
theme="ghost"
|
||||||
className="w-9 h-9 ml-4 md:ml-0"
|
className="h-9 ml-4 md:ml-0"
|
||||||
onClick={toggleNavbar}>
|
onClick={toggleNavbar}>
|
||||||
{navbar
|
{navbar ? (
|
||||||
? <PanelLeftCloseIcon/>
|
<>
|
||||||
: <PanelLeftOpenIcon/>
|
<PanelLeftCloseIcon/>
|
||||||
}
|
<span className="text-foreground/90">关闭菜单</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PanelLeftOpenIcon/>
|
||||||
|
<span className="text-foreground/90">打开菜单</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="max-md:hidden">
|
<span className="max-md:hidden">欢迎来到,蓝狐代理</span>
|
||||||
欢迎来到,蓝狐代理
|
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2"/>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* right */}
|
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-2">
|
<Link href="/" className="flex-none h-16 flex items-center justify-center text-sm">
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className={merge(
|
|
||||||
`flex-none h-16 flex items-center justify-center`,
|
|
||||||
)}>
|
|
||||||
返回首页
|
返回首页
|
||||||
</Link>
|
</Link>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -176,7 +184,7 @@ export function Navbar() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/>
|
<NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/>
|
||||||
<NavTitle label="快速开始"/>
|
<NavTitle label="快速开始"/>
|
||||||
<NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/>
|
{/* <NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/> */}
|
||||||
<NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/>
|
<NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/>
|
||||||
<NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/>
|
<NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/>
|
||||||
<NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/>
|
<NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ export function BasicForm(props: {
|
|||||||
}) {
|
}) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
email: z.string(),
|
email: z.string().email('请输入正确的邮箱格式').or(z.literal('')),
|
||||||
contact_qq: z.string(),
|
contact_qq: z.string().regex(/^\d*$/, 'QQ号只能包含数字'),
|
||||||
contact_wechat: z.string(),
|
contact_wechat: z.string(),
|
||||||
})
|
})
|
||||||
type Schema = z.infer<typeof schema>
|
type Schema = z.infer<typeof schema>
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
|
|||||||
import {CheckCircle} from 'lucide-react'
|
import {CheckCircle} from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import banner from '@/app/admin/identify/_assets/banner.webp'
|
import banner from '@/app/admin/identify/_assets/banner.webp'
|
||||||
import RechargeModal from '@/components/composites/recharge'
|
|
||||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
|
||||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
||||||
import {getProfile} from '@/actions/auth'
|
import {getProfile} from '@/actions/auth'
|
||||||
import {Aftersale, BasicForm} from './clients'
|
import {Aftersale, BasicForm} from './clients'
|
||||||
|
import {Button} from '@/components/ui/button'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export type ProfilePageProps = {}
|
export type ProfilePageProps = {}
|
||||||
|
|
||||||
export default async function ProfilePage(props: ProfilePageProps) {
|
export default async function ProfilePage(props: ProfilePageProps) {
|
||||||
const profile = await getProfile()
|
const profile = await getProfile()
|
||||||
|
|
||||||
if (!profile.success) {
|
if (!profile.success) {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -22,6 +23,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = profile.data
|
const user = profile.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
|
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
|
||||||
<div className="flex-3/4 flex flex-col gap-4">
|
<div className="flex-3/4 flex flex-col gap-4">
|
||||||
@@ -34,26 +36,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
|||||||
|
|
||||||
{/* 块信息 */}
|
{/* 块信息 */}
|
||||||
<div className="flex gap-4 max-md:flex-col max-sm:flex-col">
|
<div className="flex gap-4 max-md:flex-col max-sm:flex-col">
|
||||||
|
|
||||||
{/* <Card className="flex-1 ">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="font-normal">账户余额(元)</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-auto flex justify-between items-center px-8">
|
|
||||||
<p className="text-xl">{user.balance}</p>
|
|
||||||
<RechargeModal classNames={{
|
|
||||||
trigger: `h-10 px-6`,
|
|
||||||
}}/>
|
|
||||||
</CardContent>
|
|
||||||
</Card> */}
|
|
||||||
|
|
||||||
<Card className="flex-1 ">
|
<Card className="flex-1 ">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="font-normal">修改密码</CardTitle>
|
<CardTitle className="font-normal">修改密码</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-auto flex justify-between items-center px-8">
|
<CardContent className="flex-auto flex justify-between items-center px-8">
|
||||||
<p>{user.phone}</p>
|
<p>{user.phone}</p>
|
||||||
<ChangePasswordDialog triggerClassName="w-24 h-9"/>
|
<ChangePasswordDialog triggerClassName="w-24 h-9" phone={user?.phone}/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -66,10 +55,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
|||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm">为了保障您的账户安全和正常使用服务,请您尽快完成实名认证</p>
|
<p className="text-sm">为了保障您的账户安全和正常使用服务,请您尽快完成实名认证</p>
|
||||||
<RealnameAuthDialog
|
{/* <RealnameAuthDialog
|
||||||
defaultOpen={!user.id_token}
|
// defaultOpen={!user.id_token}
|
||||||
triggerClassName="w-24"
|
triggerClassName="w-24"
|
||||||
/>
|
/> */}
|
||||||
|
<Link href="/admin/identify">
|
||||||
|
<Button>立即认证</Button>
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -164,16 +164,21 @@ export default function RecordPage(props: RecordPageProps) {
|
|||||||
cell: ({row}) => <div>{row.original.prov}</div>,
|
cell: ({row}) => <div>{row.original.prov}</div>,
|
||||||
accessorKey: 'prov',
|
accessorKey: 'prov',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: '城市',
|
||||||
|
cell: ({row}) => <div>{row.original.city}</div>,
|
||||||
|
accessorKey: 'city',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: '提取数量',
|
header: '提取数量',
|
||||||
cell: ({row}) => <div>{row.original.count}</div>,
|
cell: ({row}) => <div>{row.original.count}</div>,
|
||||||
accessorKey: 'count',
|
accessorKey: 'count',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
header: '资源数量',
|
// header: '资源数量',
|
||||||
cell: ({row}) => <div>{row.original.resource_id}</div>,
|
// cell: ({row}) => <div>{row.original.resource_id}</div>,
|
||||||
accessorKey: 'resource_id',
|
// accessorKey: 'resource_id',
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
header: '提取时间',
|
header: '提取时间',
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
|
|||||||
const live = resourceKey === 'long'
|
const live = resourceKey === 'long'
|
||||||
? (row.original as Resource<2>).long.live
|
? (row.original as Resource<2>).long.live
|
||||||
: (row.original as Resource<1>).short.live
|
: (row.original as Resource<1>).short.live
|
||||||
return <span>{isLong ? `${live}小时` : `${live / 60}分钟`}</span>
|
return <span>{isLong ? `${live}小时` : `${live}分钟`}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -219,6 +219,17 @@ export default function ResourceList({resourceType}: ResourceListProps) {
|
|||||||
header: '开通时间',
|
header: '开通时间',
|
||||||
cell: ({row}) => formatDateTime(row.original.created_at),
|
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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 短效资源增加到期时间列
|
// 短效资源增加到期时间列
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {LayoutStoreProvider} from '@/components/stores/layout'
|
|||||||
import {ClientStoreProvider} from '@/components/stores/client'
|
import {ClientStoreProvider} from '@/components/stores/client'
|
||||||
import {getProfile} from '@/actions/auth'
|
import {getProfile} from '@/actions/auth'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
|
import {AppStoreProvider} from '@/components/stores/app'
|
||||||
|
import {getApiUrl} from '@/actions/base'
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return {
|
return {
|
||||||
@@ -30,12 +32,14 @@ export default async function RootLayout(props: Readonly<{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StoreProviders(props: {children: ReactNode}) {
|
async function StoreProviders(props: {children: ReactNode}) {
|
||||||
return (
|
return (
|
||||||
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
|
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
|
||||||
<LayoutStoreProvider>
|
<LayoutStoreProvider>
|
||||||
<ClientStoreProvider>
|
<ClientStoreProvider>
|
||||||
{props.children}
|
<AppStoreProvider url={await getApiUrl().then(r => r.data)}>
|
||||||
|
{props.children}
|
||||||
|
</AppStoreProvider>
|
||||||
</ClientStoreProvider>
|
</ClientStoreProvider>
|
||||||
</LayoutStoreProvider>
|
</LayoutStoreProvider>
|
||||||
</ProfileStoreProvider>
|
</ProfileStoreProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTr
|
|||||||
import {Button} from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import {Form, FormField} from '@/components/ui/form'
|
import {Form, FormField} from '@/components/ui/form'
|
||||||
import {Input} from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import {useForm, useFormContext, useWatch} from 'react-hook-form'
|
import {useForm, useFormContext} from 'react-hook-form'
|
||||||
import {zodResolver} from '@hookform/resolvers/zod'
|
import {zodResolver} from '@hookform/resolvers/zod'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import {toast} from 'sonner'
|
import {toast} from 'sonner'
|
||||||
@@ -14,7 +14,6 @@ import dynamic from 'next/dynamic'
|
|||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
|
|
||||||
captcha: z.string().nonempty('请输入验证码'),
|
captcha: z.string().nonempty('请输入验证码'),
|
||||||
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
|
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
|
||||||
password: z.string().min(6, `密码至少6位`),
|
password: z.string().min(6, `密码至少6位`),
|
||||||
@@ -32,6 +31,7 @@ interface ChangePasswordDialogProps {
|
|||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
|
phone?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangePasswordDialog({
|
export function ChangePasswordDialog({
|
||||||
@@ -40,12 +40,28 @@ export function ChangePasswordDialog({
|
|||||||
defaultOpen,
|
defaultOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
phone,
|
||||||
}: ChangePasswordDialogProps) {
|
}: ChangePasswordDialogProps) {
|
||||||
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
|
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const actualOpen = open !== undefined ? open : internalOpen
|
const actualOpen = open !== undefined ? open : internalOpen
|
||||||
const actualOnOpenChange = onOpenChange || setInternalOpen
|
const actualOnOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset({
|
||||||
|
captcha: '',
|
||||||
|
code: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setInternalOpen(open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 表单初始化
|
// 表单初始化
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
@@ -59,7 +75,6 @@ export function ChangePasswordDialog({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
phone: '',
|
|
||||||
captcha: '',
|
captcha: '',
|
||||||
code: '',
|
code: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -71,7 +86,6 @@ export function ChangePasswordDialog({
|
|||||||
const handler = async (value: Schema) => {
|
const handler = async (value: Schema) => {
|
||||||
try {
|
try {
|
||||||
const resp = await updatePassword({
|
const resp = await updatePassword({
|
||||||
phone: value.phone,
|
|
||||||
code: value.code,
|
code: value.code,
|
||||||
password: value.password,
|
password: value.password,
|
||||||
})
|
})
|
||||||
@@ -108,14 +122,12 @@ export function ChangePasswordDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>修改密码</DialogTitle>
|
<DialogTitle>修改密码</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{/* 手机号输入 */}
|
{phone && (
|
||||||
<FormField<Schema> name="phone" label="手机号" className="flex-auto">
|
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||||
{({field}) => (
|
<span className="text-sm text-gray-500">当前用户手机号:</span>
|
||||||
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/>
|
<span className="text-sm font-medium">{phone}</span>
|
||||||
)}
|
</div>
|
||||||
</FormField>
|
)}
|
||||||
|
|
||||||
{/* 短信验证码 */}
|
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex gap-4 items-end">
|
||||||
<FormField<Schema> name="code" label="验证码" className="flex-auto">
|
<FormField<Schema> name="code" label="验证码" className="flex-auto">
|
||||||
{({field}) => (
|
{({field}) => (
|
||||||
@@ -124,15 +136,11 @@ export function ChangePasswordDialog({
|
|||||||
</FormField>
|
</FormField>
|
||||||
<SendMsgByPhone/>
|
<SendMsgByPhone/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新密码 */}
|
|
||||||
<FormField<Schema> name="password" label="新密码" className="flex-auto">
|
<FormField<Schema> name="password" label="新密码" className="flex-auto">
|
||||||
{({field}) => (
|
{({field}) => (
|
||||||
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
|
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 确认密码 */}
|
|
||||||
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
|
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
|
||||||
{({field}) => (
|
{({field}) => (
|
||||||
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
|
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
|
||||||
@@ -144,8 +152,13 @@ export function ChangePasswordDialog({
|
|||||||
theme="outline"
|
theme="outline"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
captcha: '',
|
||||||
|
code: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
})
|
||||||
actualOnOpenChange(false)
|
actualOnOpenChange(false)
|
||||||
form.reset()
|
|
||||||
}}>
|
}}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,8 +172,7 @@ export function ChangePasswordDialog({
|
|||||||
|
|
||||||
function SendMsgByPhone() {
|
function SendMsgByPhone() {
|
||||||
const {control} = useFormContext<Schema>()
|
const {control} = useFormContext<Schema>()
|
||||||
const phone = useWatch({control, name: 'phone'})
|
return <SendMsg/>
|
||||||
return <SendMsg phone={phone}/>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SendMsg = dynamic(() => import('@/components/send-msg'), {ssr: false})
|
const SendMsg = dynamic(() => import('@/components/updateSend-msg'), {ssr: false})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {Button} from '@/components/ui/button'
|
|||||||
import {useForm, useFormContext, useWatch} from 'react-hook-form'
|
import {useForm, useFormContext, useWatch} from 'react-hook-form'
|
||||||
import {Alert, AlertTitle} from '@/components/ui/alert'
|
import {Alert, AlertTitle} from '@/components/ui/alert'
|
||||||
import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react'
|
import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react'
|
||||||
import {memo, ReactNode, useEffect, useRef, useState} from 'react'
|
import {memo, ReactNode, Suspense, use, useEffect, useRef, useState} from 'react'
|
||||||
import {useStatus} from '@/lib/states'
|
import {useStatus} from '@/lib/states'
|
||||||
import {allResource} from '@/actions/resource'
|
import {allResource} from '@/actions/resource'
|
||||||
import {Resource} from '@/lib/models'
|
import {Resource} from '@/lib/models'
|
||||||
@@ -113,7 +113,9 @@ const FormFields = memo(() => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
|
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
|
||||||
{/* 选择套餐 */}
|
{/* 选择套餐 */}
|
||||||
<SelectResource/>
|
<Suspense>
|
||||||
|
<SelectResource/>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* 地区筛选 */}
|
{/* 地区筛选 */}
|
||||||
<SelectRegion/>
|
<SelectRegion/>
|
||||||
@@ -332,12 +334,16 @@ FormFields.displayName = 'FormFields'
|
|||||||
function SelectResource() {
|
function SelectResource() {
|
||||||
const [resources, setResources] = useState<Resource[]>([])
|
const [resources, setResources] = useState<Resource[]>([])
|
||||||
const [status, setStatus] = useStatus()
|
const [status, setStatus] = useStatus()
|
||||||
const profile = useProfileStore(state => state.profile)
|
const profile = use(useProfileStore(store => store.profile))
|
||||||
const getResources = async () => {
|
const getResources = async () => {
|
||||||
|
if (!profile) {
|
||||||
|
setStatus('done')
|
||||||
|
setResources([])
|
||||||
|
return
|
||||||
|
}
|
||||||
setStatus('load')
|
setStatus('load')
|
||||||
try {
|
try {
|
||||||
const resp = await allResource()
|
const resp = await allResource()
|
||||||
|
|
||||||
if (!resp.success) {
|
if (!resp.success) {
|
||||||
throw new Error('获取套餐失败,请稍后再试')
|
throw new Error('获取套餐失败,请稍后再试')
|
||||||
}
|
}
|
||||||
@@ -353,7 +359,7 @@ function SelectResource() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getResources().then()
|
getResources().then()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [profile])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
|
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
|
||||||
@@ -373,110 +379,111 @@ function SelectResource() {
|
|||||||
</div>
|
</div>
|
||||||
) : !profile ? (
|
) : !profile ? (
|
||||||
<div className="p-4 flex gap-1 items-center">
|
<div className="p-4 flex gap-1 items-center">
|
||||||
<Loader className="animate-spin" size={20}/>
|
{/* <Loader className="animate-spin" size={20}/> */}
|
||||||
<span>请先登录账号,<Link href="/login" className="text-blue-600 hover:underline">去登录</Link></span>
|
<span className="text-gray-600">请先登录账号,<Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium">去登录</Link></span>
|
||||||
</div>
|
</div>
|
||||||
) : resources.length === 0 ? (
|
) : resources.length === 0 ? (
|
||||||
<div className="p-4 flex gap-1 items-center">
|
<div className="p-4 flex gap-1 items-center">
|
||||||
<Loader className="animate-spin" size={20}/>
|
<Loader className="animate-spin" size={20}/>
|
||||||
<span>暂无可用套餐</span>
|
<span>暂无可用套餐</span>
|
||||||
</div>
|
</div>
|
||||||
) : resources.map((resource, i) => (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SelectItem
|
{resources.map(resource => (
|
||||||
key={`${resource.id}`}
|
<SelectItem
|
||||||
value={String(resource.id)}
|
key={resource.id}
|
||||||
className="p-3">
|
value={String(resource.id)}
|
||||||
<div className="flex flex-col gap-2 w-72">
|
className="p-3">
|
||||||
{resource.type === 1 && resource.short.type === 1 && (
|
<div className="flex flex-col gap-2 w-72">
|
||||||
<>
|
{resource.type === 1 && resource.short.type === 1 && (
|
||||||
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
|
<>
|
||||||
<Timer size={20}/>
|
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
|
||||||
<span>{name(resource)}</span>
|
<Timer size={20}/>
|
||||||
</div>
|
<span>{name(resource)}</span>
|
||||||
<div className="flex text-xs text-weak">
|
</div>
|
||||||
<span>{resource.resource_no}</span>
|
<div className="flex text-xs text-weak">
|
||||||
</div>
|
<span>{resource.resource_no}</span>
|
||||||
<div className="flex justify-between gap-2 text-xs text-weak">
|
</div>
|
||||||
<span>
|
<div className="flex justify-between gap-2 text-xs text-weak">
|
||||||
到期时间:
|
<span>
|
||||||
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
|
到期时间:
|
||||||
</span>
|
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
|
||||||
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
|
</span>
|
||||||
</div>
|
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
{resource.type === 1 && resource.short.type === 2 && (
|
)}
|
||||||
<>
|
{resource.type === 1 && resource.short.type === 2 && (
|
||||||
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
|
<>
|
||||||
<Box size={20}/>
|
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
|
||||||
<span>{name(resource)}</span>
|
<Box size={20}/>
|
||||||
</div>
|
<span>{name(resource)}</span>
|
||||||
<div className="flex text-xs text-weak">
|
</div>
|
||||||
<span>{resource.resource_no}</span>
|
<div className="flex text-xs text-weak">
|
||||||
</div>
|
<span>{resource.resource_no}</span>
|
||||||
<div className="flex justify-between gap-2 text-xs text-weak">
|
</div>
|
||||||
<span>
|
<div className="flex justify-between gap-2 text-xs text-weak">
|
||||||
提取数量:
|
<span>
|
||||||
{resource.short.used}
|
提取数量:
|
||||||
{' '}
|
{resource.short.used}
|
||||||
/
|
{' '}
|
||||||
{resource.short.quota}
|
/
|
||||||
</span>
|
{resource.short.quota}
|
||||||
<span>
|
</span>
|
||||||
剩余
|
<span>
|
||||||
{resource.short.quota - resource.short.used}
|
剩余
|
||||||
</span>
|
{resource.short.quota - resource.short.used}
|
||||||
</div>
|
</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
{resource.type === 2 && resource.long.type === 1 && (
|
)}
|
||||||
<>
|
{resource.type === 2 && resource.long.type === 1 && (
|
||||||
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
|
<>
|
||||||
<Timer size={20}/>
|
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
|
||||||
<span>{name(resource)}</span>
|
<Timer size={20}/>
|
||||||
</div>
|
<span>{name(resource)}</span>
|
||||||
<div className="flex text-xs text-weak">
|
</div>
|
||||||
<span>{resource.resource_no}</span>
|
<div className="flex text-xs text-weak">
|
||||||
</div>
|
<span>{resource.resource_no}</span>
|
||||||
<div className="flex justify-between gap-2 text-xs text-weak">
|
</div>
|
||||||
<span>
|
<div className="flex justify-between gap-2 text-xs text-weak">
|
||||||
到期时间:
|
<span>
|
||||||
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
|
到期时间:
|
||||||
</span>
|
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
|
||||||
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
|
</span>
|
||||||
</div>
|
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
{resource.type === 2 && resource.long.type === 2 && (
|
)}
|
||||||
<>
|
{resource.type === 2 && resource.long.type === 2 && (
|
||||||
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
|
<>
|
||||||
<Box size={20}/>
|
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
|
||||||
<span>{name(resource)}</span>
|
<Box size={20}/>
|
||||||
</div>
|
<span>{name(resource)}</span>
|
||||||
<div className="flex text-xs text-weak">
|
</div>
|
||||||
<span>{resource.resource_no}</span>
|
<div className="flex text-xs text-weak">
|
||||||
</div>
|
<span>{resource.resource_no}</span>
|
||||||
<div className="flex justify-between gap-2 text-xs text-weak">
|
</div>
|
||||||
<span>
|
<div className="flex justify-between gap-2 text-xs text-weak">
|
||||||
提取数量:
|
<span>
|
||||||
{resource.long.used}
|
提取数量:
|
||||||
{' '}
|
{resource.long.used}
|
||||||
/
|
{' '}
|
||||||
{resource.long.quota}
|
/
|
||||||
</span>
|
{resource.long.quota}
|
||||||
<span>
|
</span>
|
||||||
剩余
|
<span>
|
||||||
{resource.long.quota - resource.long.used}
|
剩余
|
||||||
</span>
|
{resource.long.quota - resource.long.used}
|
||||||
</div>
|
</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</SelectItem>
|
</div>
|
||||||
{i < resources.length - 1 && <SelectSeparator className="m-1"/>}
|
</SelectItem>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
@@ -651,9 +658,9 @@ function name(resource: Resource) {
|
|||||||
// 短效套餐
|
// 短效套餐
|
||||||
switch (resource.short.type) {
|
switch (resource.short.type) {
|
||||||
case 1:
|
case 1:
|
||||||
return `短效包时 ${resource.short.live / 60} 分钟`
|
return `短效包时 ${resource.short.live} 分钟`
|
||||||
case 2:
|
case 2:
|
||||||
return `短效包量 ${resource.short.live / 60} 分钟`
|
return `短效包量 ${resource.short.live} 分钟`
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function DesktopPayment(props: PaymentModalProps) {
|
|||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
请使用
|
请使用
|
||||||
{decoration.text}
|
{decoration.text}
|
||||||
扫码支付
|
{/* 扫码支付 */}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="w-full text-center space-y-2">
|
<div className="w-full text-center space-y-2">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {PaymentProps} from './type'
|
|||||||
import {payClose} from '@/actions/resource'
|
import {payClose} from '@/actions/resource'
|
||||||
import {useEffect} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {UniversalDesktopPayment} from './universal-desktop-payment'
|
import {UniversalDesktopPayment} from './universal-desktop-payment'
|
||||||
|
import {useAppStore} from '@/components/stores/app'
|
||||||
|
|
||||||
export type PaymentModalProps = {
|
export type PaymentModalProps = {
|
||||||
onConfirm: (showFail: boolean) => Promise<void>
|
onConfirm: (showFail: boolean) => Promise<void>
|
||||||
@@ -16,27 +17,28 @@ export type PaymentModalProps = {
|
|||||||
export function PaymentModal(props: PaymentModalProps) {
|
export function PaymentModal(props: PaymentModalProps) {
|
||||||
// 手动关闭时的处理
|
// 手动关闭时的处理
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
try {
|
// try {
|
||||||
const res = await payClose({
|
// const res = await payClose({
|
||||||
trade_no: props.inner_no,
|
// trade_no: props.inner_no,
|
||||||
method: props.method,
|
// method: props.method,
|
||||||
})
|
// })
|
||||||
if (!res.success) {
|
// if (!res.success) {
|
||||||
throw new Error(res.message)
|
// throw new Error(res.message)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
catch (error) {
|
// catch (error) {
|
||||||
console.error('关闭订单失败:', error)
|
// console.error('关闭订单失败:', error)
|
||||||
}
|
// }
|
||||||
finally {
|
// finally {
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE处理方式检查支付状态
|
// SSE处理方式检查支付状态
|
||||||
|
const apiUrl = useAppStore('apiUrl')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventSource = new EventSource(
|
const eventSource = new EventSource(
|
||||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
|
`${apiUrl}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
|
||||||
)
|
)
|
||||||
eventSource.onmessage = async (event) => {
|
eventSource.onmessage = async (event) => {
|
||||||
switch (event.data) {
|
switch (event.data) {
|
||||||
@@ -53,7 +55,7 @@ export function PaymentModal(props: PaymentModalProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
eventSource.close()
|
eventSource.close()
|
||||||
}
|
}
|
||||||
}, [props])
|
}, [apiUrl, props])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -62,12 +64,12 @@ export function PaymentModal(props: PaymentModalProps) {
|
|||||||
if (!open) handleClose()
|
if (!open) handleClose()
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* {props.platform === TradePlatform.Mobile
|
{props.platform === TradePlatform.Mobile
|
||||||
? <MobilePayment {...props} onClose={handleClose}/>
|
? <MobilePayment {...props} onClose={handleClose}/>
|
||||||
: <DesktopPayment {...props} onClose={handleClose}/>
|
: <DesktopPayment {...props} onClose={handleClose}/>
|
||||||
} */}
|
}
|
||||||
|
|
||||||
<UniversalDesktopPayment {...props} onClose={handleClose}/>
|
{/* <UniversalDesktopPayment {...props} onClose={handleClose}/> */}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 cursor-pointer')}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {ReactNode} from 'react'
|
import {ReactNode, use, useEffect, useState} from 'react'
|
||||||
import {merge} from '@/lib/utils'
|
import {merge} from '@/lib/utils'
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||||
import LongForm from '@/components/composites/purchase/long/form'
|
import LongForm from '@/components/composites/purchase/long/form'
|
||||||
import ShortForm from '@/components/composites/purchase/short/form'
|
import ShortForm from '@/components/composites/purchase/short/form'
|
||||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation'
|
import {usePathname, useRouter, useSearchParams} from 'next/navigation'
|
||||||
import SelfDesc from '@/components/features/self-desc'
|
import SelfDesc from '@/components/features/self-desc'
|
||||||
|
import {listProduct, listProductHome, ProductItem} from '@/actions/product'
|
||||||
|
import {useProfileStore} from '@/components/stores/profile'
|
||||||
export type TabType = 'short' | 'long' | 'fixed' | 'custom'
|
export type TabType = 'short' | 'long' | 'fixed' | 'custom'
|
||||||
|
|
||||||
export default function Purchase() {
|
export default function Purchase() {
|
||||||
@@ -13,35 +15,58 @@ export default function Purchase() {
|
|||||||
const path = usePathname()
|
const path = usePathname()
|
||||||
const params = useSearchParams()
|
const params = useSearchParams()
|
||||||
|
|
||||||
const tab = params.get('type') as TabType || 'short'
|
const [productList, setProductList] = useState<ProductItem[]>([])
|
||||||
|
const tab = (params.get('type') as TabType) || productList[0]?.code || 'short'
|
||||||
|
|
||||||
const updateTab = (tab: string) => {
|
const updateTab = (tab: string) => {
|
||||||
const newParams = new URLSearchParams(params)
|
const newParams = new URLSearchParams(params)
|
||||||
newParams.set('type', tab)
|
newParams.set('type', tab)
|
||||||
router.push(`${path}?${newParams.toString()}`)
|
router.push(`${path}?${newParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
const profile = use(useProfileStore(store => store.profile))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
const res = profile
|
||||||
|
? await listProduct({})
|
||||||
|
: await listProductHome({})
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Tabs value={tab} onValueChange={updateTab} className="gap-4">
|
<Tabs value={tab} onValueChange={updateTab} className="gap-4">
|
||||||
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
|
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
|
||||||
<Tab value="short">短效动态</Tab>
|
{productList.map(item => (
|
||||||
<Tab value="long">长效静态</Tab>
|
<Tab key={item.code} value={item.code}>
|
||||||
<Tab value="fixed">固定套餐</Tab>
|
{item.name}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
{/* 固定的定制套餐tab */}
|
||||||
<Tab value="custom">定制套餐</Tab>
|
<Tab value="custom">定制套餐</Tab>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="short">
|
{productList.map((item) => {
|
||||||
<ShortForm/>
|
const Component = componentMap[item.code]
|
||||||
</TabsContent>
|
const skuList = item.skus || []
|
||||||
<TabsContent value="long">
|
return (
|
||||||
<LongForm/>
|
<TabsContent key={item.code} value={item.code}>
|
||||||
</TabsContent>
|
{Component ? <Component skuList={skuList}/> : <div>页面待开发中</div>}
|
||||||
<TabsContent value="fixed">
|
</TabsContent>
|
||||||
</TabsContent>
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<TabsContent value="custom">
|
<TabsContent value="custom">
|
||||||
<SelfDesc onInquiry={() => {
|
<SelfDesc onInquiry={() => router.push('/custom')}/>
|
||||||
router.push('/custom')
|
|
||||||
}}/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,246 +1,185 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {FormField} from '@/components/ui/form'
|
import {FormField} from '@/components/ui/form'
|
||||||
import {RadioGroup} from '@/components/ui/radio-group'
|
import {RadioGroup} from '@/components/ui/radio-group'
|
||||||
import {Input} from '@/components/ui/input'
|
|
||||||
import {Button} from '@/components/ui/button'
|
|
||||||
import {Minus, Plus} from 'lucide-react'
|
|
||||||
import FormOption from '@/components/composites/purchase/option'
|
import FormOption from '@/components/composites/purchase/option'
|
||||||
import Image from 'next/image'
|
|
||||||
import check from '../_assets/check.svg'
|
|
||||||
import {Schema} from '@/components/composites/purchase/long/form'
|
import {Schema} from '@/components/composites/purchase/long/form'
|
||||||
|
import {useEffect, useMemo} from 'react'
|
||||||
import {useFormContext, useWatch} from 'react-hook-form'
|
import {useFormContext, useWatch} from 'react-hook-form'
|
||||||
import {Card} from '@/components/ui/card'
|
import {Card} from '@/components/ui/card'
|
||||||
import {useEffect} from 'react'
|
import {BillingMethodField} from '../shared/billing-method-field'
|
||||||
|
import {FeatureList} from '../shared/feature-list'
|
||||||
|
import {NumberStepperField} from '../shared/number-stepper-field'
|
||||||
|
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
|
||||||
|
|
||||||
|
export default function Center({skuData}: {
|
||||||
|
skuData: PurchaseSkuData
|
||||||
|
}) {
|
||||||
|
const {setValue, getValues} = useFormContext<Schema>()
|
||||||
|
const type = useWatch<Schema>({name: 'type'}) as Schema['type']
|
||||||
|
const live = useWatch<Schema>({name: 'live'}) as Schema['live']
|
||||||
|
const expire = useWatch<Schema>({name: 'expire'}) as Schema['expire']
|
||||||
|
const {modeList, priceMap} = skuData
|
||||||
|
const liveList = type === '1'
|
||||||
|
? getAvailablePurchaseLives(skuData, {mode: type, expire})
|
||||||
|
: getAvailablePurchaseLives(skuData, {mode: type})
|
||||||
|
const expireList = type === '1'
|
||||||
|
? getAvailablePurchaseExpires(skuData, {mode: type, live})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const currentCountMin = useMemo(() => {
|
||||||
|
if (!type || !live) return 0
|
||||||
|
const expireValue = type === '1' ? expire : '0'
|
||||||
|
return getPurchaseSkuCountMin(skuData, {mode: type, live, expire: expireValue})
|
||||||
|
}, [type, live, expire, skuData])
|
||||||
|
|
||||||
export default function Center() {
|
|
||||||
const form = useFormContext<Schema>()
|
|
||||||
const type = useWatch({name: 'type'})
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === '1') {
|
if (type === '1') {
|
||||||
form.setValue('daily_limit', 100)
|
const current = getValues('daily_limit')
|
||||||
|
if (current < currentCountMin) {
|
||||||
|
setValue('daily_limit', currentCountMin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
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 (
|
return (
|
||||||
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
||||||
|
<BillingMethodField modeList={modeList} timeDailyLimit={100}/>
|
||||||
{/* 计费方式 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* IP 时效 */}
|
{/* IP 时效 */}
|
||||||
<FormField
|
<FormField<Schema, 'live'>
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
name="live"
|
name="live"
|
||||||
label="IP 时效">
|
label="IP 时效">
|
||||||
{({id, field}) => (
|
{({id, field}) => (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
id={id}
|
id={id}
|
||||||
defaultValue={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
field.onChange(value)
|
||||||
|
|
||||||
<FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/>
|
if (type !== '1') {
|
||||||
<FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/>
|
return
|
||||||
<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}/>
|
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>
|
</RadioGroup>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 根据套餐类型显示不同表单项 */}
|
{/* 套餐时效 */}
|
||||||
{type === '2' ? (
|
{type === '1' && (
|
||||||
/* 包量:IP 购买数量 */
|
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||||
<FormField
|
{({id, field}) => (
|
||||||
className="space-y-4"
|
<RadioGroup
|
||||||
name="quota"
|
id={id}
|
||||||
label="IP 购买数量">
|
value={field.value}
|
||||||
{({id, field}) => {
|
onValueChange={(value) => {
|
||||||
const value = Number(field.value) || 500
|
field.onChange(value)
|
||||||
const minValue = 500
|
|
||||||
const step = 100
|
const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
|
||||||
return (
|
if (!nextLiveList.includes(live) && nextLiveList[0]) {
|
||||||
<div className="flex gap-2 items-center">
|
setValue('live', nextLiveList[0])
|
||||||
<Button
|
}
|
||||||
theme="outline"
|
}}
|
||||||
type="button"
|
className="flex gap-4 flex-wrap">
|
||||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
{expireList.map(day => (
|
||||||
onClick={() => form.setValue('quota', Math.max(minValue, value - step))}
|
<FormOption
|
||||||
disabled={value === minValue}>
|
key={day}
|
||||||
<Minus/>
|
id={`${id}-${day}`}
|
||||||
</Button>
|
value={day}
|
||||||
<Input
|
label={`${day} 天`}
|
||||||
{...field}
|
compare={field.value}
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
|
||||||
min={minValue}
|
|
||||||
step={step}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = Number(e.target.value)
|
|
||||||
if (value < 500) {
|
|
||||||
form.setValue('quota', 500)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
))}
|
||||||
theme="outline"
|
</RadioGroup>
|
||||||
type="button"
|
)}
|
||||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
|
||||||
onClick={() => form.setValue('quota', value + step)}>
|
|
||||||
<Plus/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</FormField>
|
</FormField>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 包时:套餐时效 */}
|
|
||||||
<FormField
|
|
||||||
className="space-y-4"
|
|
||||||
name="expire"
|
|
||||||
label="套餐时效">
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
id={id}
|
|
||||||
defaultValue={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
className="flex gap-4 flex-wrap">
|
|
||||||
|
|
||||||
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{/* 包时:每日提取上限 */}
|
|
||||||
<FormField
|
|
||||||
className="space-y-4"
|
|
||||||
name="daily_limit"
|
|
||||||
label="每日提取上限">
|
|
||||||
{({id, field}) => {
|
|
||||||
const value = Number(field.value) || 100
|
|
||||||
const minValue = 100
|
|
||||||
const step = 100
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Button
|
|
||||||
theme="outline"
|
|
||||||
type="button"
|
|
||||||
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg ${
|
|
||||||
value === minValue ? 'opacity-50 cursor-not-allowed' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => form.setValue('daily_limit', Math.max(minValue, value - step))}
|
|
||||||
disabled={value === minValue}>
|
|
||||||
<Minus/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
|
||||||
min={100}
|
|
||||||
step={100}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const value = Number(e.target.value)
|
|
||||||
if (value < 100) {
|
|
||||||
form.setValue('daily_limit', 100)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
theme="outline"
|
|
||||||
type="button"
|
|
||||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
|
||||||
onClick={() => form.setValue('daily_limit', value + step)}>
|
|
||||||
<Plus/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 产品特性 */}
|
{/* 每日提取上限/购买数量 */}
|
||||||
<div className="space-y-6">
|
{type === '1' ? (
|
||||||
<h3>产品特性</h3>
|
<NumberStepperField
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 gap-y-6">
|
name="daily_limit"
|
||||||
<p className="flex gap-2 items-center">
|
label="每日提取上限"
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
min={currentCountMin || 100}
|
||||||
<span className="text-sm text-gray-500">支持高并发提取</span>
|
step={100}
|
||||||
</p>
|
/>
|
||||||
<p className="flex gap-2 items-center">
|
) : (
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
<NumberStepperField
|
||||||
<span className="text-sm text-gray-500">指定省份、城市或混播</span>
|
name="quota"
|
||||||
</p>
|
label="IP 购买数量"
|
||||||
<p className="flex gap-2 items-center">
|
min={currentCountMin || 500}
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
step={100}
|
||||||
<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"/>
|
<FeatureList/>
|
||||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">IP时效3-30分钟(可定制)</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">IP资源定期筛选</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">包量/包时计费方式</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">每日去重量:500万</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {useForm} from 'react-hook-form'
|
import {useForm} from 'react-hook-form'
|
||||||
import Center from '@/components/composites/purchase/long/center'
|
import Center from '@/components/composites/purchase/long/center'
|
||||||
import Right from '@/components/composites/purchase/long/right'
|
|
||||||
import {Form} from '@/components/ui/form'
|
import {Form} from '@/components/ui/form'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import {zodResolver} from '@hookform/resolvers/zod'
|
import {zodResolver} from '@hookform/resolvers/zod'
|
||||||
|
import {ProductItem} from '@/actions/product'
|
||||||
|
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
|
||||||
|
import {PurchaseSidePanel} from '../shared/side-panel'
|
||||||
|
|
||||||
// 定义表单验证架构
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['1', '2']).default('2'),
|
type: z.enum(['1', '2']).default('2'),
|
||||||
live: z.enum(['1', '4', '8', '12', '24']),
|
live: z.string(),
|
||||||
quota: z.number().min(500, '购买数量不能少于 500 个'),
|
quota: z.number().min(500, '购买数量不能少于 500 个'),
|
||||||
expire: z.enum(['7', '15', '30', '90', '180', '365']),
|
expire: z.string(),
|
||||||
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
|
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
|
||||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从架构中推断类型
|
|
||||||
export type Schema = z.infer<typeof schema>
|
export type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
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>({
|
const form = useForm<Schema>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: '2', // 默认为包量套餐
|
type: defaultMode,
|
||||||
live: '1', // 小时
|
live: defaultLive,
|
||||||
quota: 500,
|
expire: defaultExpire,
|
||||||
expire: '30', // 天
|
quota: defaultMode === '2' ? Math.max(defaultCountMin, 500) : 500,
|
||||||
daily_limit: 100,
|
daily_limit: defaultMode === '1' ? Math.max(defaultCountMin, 100) : 100,
|
||||||
pay_type: 'wechat', // 余额支付
|
pay_type: 'balance', // 余额支付
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||||
<Center/>
|
<Center skuData={skuData}/>
|
||||||
<Right/>
|
<PurchaseSidePanel kind="long"/>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from '@/lib/models/trade'
|
} from '@/lib/models/trade'
|
||||||
import {PaymentModal} from '@/components/composites/payment/payment-modal'
|
import {PaymentModal} from '@/components/composites/payment/payment-modal'
|
||||||
import {PaymentProps} from '@/components/composites/payment/type'
|
import {PaymentProps} from '@/components/composites/payment/type'
|
||||||
import {usePlatformType} from '@/lib/hooks'
|
|
||||||
|
|
||||||
export type PayProps = {
|
export type PayProps = {
|
||||||
amount: string
|
amount: string
|
||||||
@@ -32,36 +31,35 @@ export default function Pay(props: PayProps) {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [trade, setTrade] = useState<PaymentProps | null>(null)
|
const [trade, setTrade] = useState<PaymentProps | null>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// const platform = usePlatformType()
|
|
||||||
|
|
||||||
const onOpen = async () => {
|
const onOpen = async () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
|
|
||||||
if (props.method === 'balance') return
|
if (props.method === 'balance') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const method = props.method === 'alipay'
|
const method = props.method === 'alipay'
|
||||||
? TradeMethod.SftAlipay
|
? TradeMethod.SftAlipay
|
||||||
: TradeMethod.SftWechat
|
: TradeMethod.SftWechat
|
||||||
const req = {
|
const response = await prepareResource({
|
||||||
...props.resource,
|
...props.resource,
|
||||||
payment_method: method,
|
payment_method: method,
|
||||||
payment_platform: TradePlatform.Desktop,
|
payment_platform: TradePlatform.Desktop,
|
||||||
}
|
})
|
||||||
|
|
||||||
const resp = await prepareResource(req)
|
if (!response.success) {
|
||||||
|
toast.error(`创建订单失败: ${response.message}`)
|
||||||
if (!resp.success) {
|
|
||||||
toast.error(`创建订单失败: ${resp.message}`)
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrade({
|
setTrade({
|
||||||
inner_no: resp.data.trade_no,
|
inner_no: response.data.trade_no,
|
||||||
pay_url: resp.data.pay_url,
|
pay_url: response.data.pay_url,
|
||||||
amount: Number(props.amount),
|
amount: Number(props.amount),
|
||||||
platform: TradePlatform.Desktop,
|
platform: TradePlatform.Desktop,
|
||||||
method: method,
|
method,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +110,6 @@ export default function Pay(props: PayProps) {
|
|||||||
立即支付
|
立即支付
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 余额支付对话框 */}
|
|
||||||
{props.method === 'balance' && (
|
{props.method === 'balance' && (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -178,7 +175,6 @@ export default function Pay(props: PayProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 支付宝/微信支付 */}
|
|
||||||
{props.method !== 'balance' && trade && (
|
{props.method !== 'balance' && trade && (
|
||||||
<PaymentModal
|
<PaymentModal
|
||||||
{...trade}
|
{...trade}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
src/components/composites/purchase/shared/feature-list.tsx
Normal file
35
src/components/composites/purchase/shared/feature-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,36 +4,39 @@ import {FormField} from '@/components/ui/form'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import alipay from '../_assets/alipay.svg'
|
import alipay from '../_assets/alipay.svg'
|
||||||
import wechat from '../_assets/wechat.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 (
|
return (
|
||||||
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
|
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
|
||||||
{({id, field}) => (
|
{({id, field}) => (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
id={id}
|
id={id}
|
||||||
defaultValue={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
className="flex flex-col gap-3">
|
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">
|
<p className="flex items-center gap-3">
|
||||||
<Image src={balance} alt="余额icon"/>
|
<Image src={balance} alt="余额icon"/>
|
||||||
<span className="text-sm text-gray-500">账户余额</span>
|
<span className="text-sm text-gray-500">账户余额</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="flex justify-between items-center">
|
<p className="flex justify-between items-center">
|
||||||
<span className="text-xl">{profile.balance}</span>
|
<span className="text-xl">{props.balance}</span>
|
||||||
<RechargeModal/>
|
<RechargeModal/>
|
||||||
</p>
|
</p>
|
||||||
</div> */}
|
</div>
|
||||||
|
<FormOption
|
||||||
{/* <FormOption
|
|
||||||
id={`${id}-balance`}
|
id={`${id}-balance`}
|
||||||
value="balance"
|
value="balance"
|
||||||
compare={field.value}
|
compare={field.value}
|
||||||
className="p-3 w-full flex-row gap-2 justify-center">
|
className="p-3 w-full flex-row gap-2 justify-center">
|
||||||
<Image src={balance} alt="余额 icon"/>
|
<Image src={balance} alt="余额 icon"/>
|
||||||
<span>余额</span>
|
<span>余额</span>
|
||||||
</FormOption> */}
|
</FormOption>
|
||||||
<FormOption
|
<FormOption
|
||||||
id={`${id}-wechat`}
|
id={`${id}-wechat`}
|
||||||
value="wechat"
|
value="wechat"
|
||||||
|
|||||||
8
src/components/composites/purchase/shared/form-values.ts
Normal file
8
src/components/composites/purchase/shared/form-values.ts
Normal 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'
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/components/composites/purchase/shared/resource.ts
Normal file
38
src/components/composites/purchase/shared/resource.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/components/composites/purchase/shared/side-panel.tsx
Normal file
205
src/components/composites/purchase/shared/side-panel.tsx
Normal 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)
|
||||||
|
}
|
||||||
180
src/components/composites/purchase/shared/sku.ts
Normal file
180
src/components/composites/purchase/shared/sku.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,60 +1,91 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {FormField} from '@/components/ui/form'
|
import {FormField} from '@/components/ui/form'
|
||||||
import {RadioGroup} from '@/components/ui/radio-group'
|
import {RadioGroup} from '@/components/ui/radio-group'
|
||||||
import {Input} from '@/components/ui/input'
|
|
||||||
import {Button} from '@/components/ui/button'
|
|
||||||
import {Minus, Plus} from 'lucide-react'
|
|
||||||
import FormOption from '@/components/composites/purchase/option'
|
import FormOption from '@/components/composites/purchase/option'
|
||||||
import Image from 'next/image'
|
import {useEffect, useMemo} from 'react'
|
||||||
import check from '../_assets/check.svg'
|
|
||||||
import {useFormContext, useWatch} from 'react-hook-form'
|
import {useFormContext, useWatch} from 'react-hook-form'
|
||||||
import {Schema} from '@/components/composites/purchase/short/form'
|
import {Schema} from '@/components/composites/purchase/short/form'
|
||||||
import {Card} from '@/components/ui/card'
|
import {Card} from '@/components/ui/card'
|
||||||
import {useEffect} from 'react'
|
import {BillingMethodField} from '../shared/billing-method-field'
|
||||||
|
import {FeatureList} from '../shared/feature-list'
|
||||||
|
import {NumberStepperField} from '../shared/number-stepper-field'
|
||||||
|
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
|
||||||
|
|
||||||
export default function Center() {
|
export default function Center({
|
||||||
const form = useFormContext<Schema>()
|
skuData,
|
||||||
const type = useWatch({name: 'type'})
|
}: {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (type === '1') {
|
if (type === '1') {
|
||||||
form.setValue('daily_limit', 2000)
|
const current = getValues('daily_limit')
|
||||||
|
if (current < currentCountMin) {
|
||||||
|
setValue('daily_limit', currentCountMin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
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 (
|
return (
|
||||||
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
||||||
|
<BillingMethodField modeList={modeList} timeDailyLimit={2000}/>
|
||||||
{/* 计费方式 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* IP 时效 */}
|
{/* IP 时效 */}
|
||||||
<FormField<Schema, 'live'>
|
<FormField<Schema, 'live'>
|
||||||
@@ -64,168 +95,95 @@ export default function Center() {
|
|||||||
{({id, field}) => (
|
{({id, field}) => (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
id={id}
|
id={id}
|
||||||
defaultValue={field.value}
|
value={field.value}
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
field.onChange(value)
|
||||||
|
|
||||||
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
|
if (type !== '1') {
|
||||||
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
|
return
|
||||||
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
|
}
|
||||||
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/>
|
|
||||||
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
|
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
|
||||||
|
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
|
||||||
|
setValue('expire', nextExpireList[0])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||||
|
{liveList.map((live) => {
|
||||||
|
const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
|
||||||
|
? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
|
||||||
|
: String(expire)
|
||||||
|
const price = getPurchaseSkuPrice(priceMap, {
|
||||||
|
mode: type,
|
||||||
|
live,
|
||||||
|
expire: priceExpire,
|
||||||
|
})
|
||||||
|
const minutes = Number(live)
|
||||||
|
const hours = minutes / 60
|
||||||
|
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
|
||||||
|
return (
|
||||||
|
<FormOption
|
||||||
|
key={live}
|
||||||
|
id={`${id}-${live}`}
|
||||||
|
value={live}
|
||||||
|
label={label}
|
||||||
|
description={price && `¥${price}/IP`}
|
||||||
|
compare={field.value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{/* 根据套餐类型显示不同表单项 */}
|
{/* 套餐时效 */}
|
||||||
{type === '2' ? (
|
{type === '1' && (
|
||||||
/* 包量:IP 购买数量 */
|
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||||
<FormField
|
|
||||||
className="space-y-4"
|
|
||||||
name="quota"
|
|
||||||
label="IP 购买数量">
|
|
||||||
{({id, field}) => (
|
{({id, field}) => (
|
||||||
<div className="flex gap-2 items-center">
|
<RadioGroup
|
||||||
<Button
|
id={id}
|
||||||
theme="outline"
|
value={field.value}
|
||||||
type="button"
|
onValueChange={(value) => {
|
||||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
field.onChange(value)
|
||||||
onClick={() => form.setValue('quota', Math.max(10000, Number(field.value) - 5000))}
|
|
||||||
disabled={Number(field.value) === 10000}>
|
const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
|
||||||
<Minus/>
|
if (!nextLiveList.includes(live) && nextLiveList[0]) {
|
||||||
</Button>
|
setValue('live', nextLiveList[0])
|
||||||
<Input
|
}
|
||||||
{...field}
|
}}
|
||||||
id={id}
|
className="flex gap-4 flex-wrap">
|
||||||
type="number"
|
{expireList.map(day => (
|
||||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
<FormOption
|
||||||
min={10000}
|
key={day}
|
||||||
step={5000}
|
id={`${id}-${day}`}
|
||||||
onBlur={(e) => {
|
value={day}
|
||||||
const value = Number(e.target.value)
|
label={`${day} 天`}
|
||||||
if (value < 10000) {
|
compare={field.value}
|
||||||
form.setValue('quota', 10000)
|
/>
|
||||||
}
|
))}
|
||||||
}}
|
</RadioGroup>
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
theme="outline"
|
|
||||||
type="button"
|
|
||||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
|
||||||
onClick={() => form.setValue('quota', Number(field.value) + 5000)}>
|
|
||||||
<Plus/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</FormField>
|
</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">
|
{type === '1' ? (
|
||||||
<h3>产品特性</h3>
|
<NumberStepperField
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
|
name="daily_limit"
|
||||||
<p className="flex gap-2 items-center">
|
label="每日提取上限"
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
min={currentCountMin || 2000}
|
||||||
<span className="text-sm text-gray-500">支持高并发提取</span>
|
step={1000}
|
||||||
</p>
|
/>
|
||||||
<p className="flex gap-2 items-center">
|
) : (
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
<NumberStepperField
|
||||||
<span className="text-sm text-gray-500">指定省份、城市或混播</span>
|
name="quota"
|
||||||
</p>
|
label="IP 购买数量"
|
||||||
<p className="flex gap-2 items-center">
|
min={currentCountMin || 10000}
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
step={5000}
|
||||||
<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"/>
|
<FeatureList/>
|
||||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">IP时效3-30分钟(可定制)</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">IP资源定期筛选</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">包量/包时计费方式</span>
|
|
||||||
</p>
|
|
||||||
<p className="flex gap-2 items-center">
|
|
||||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
|
||||||
<span className="text-sm text-gray-500">每日去重量:500万</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,51 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {useForm} from 'react-hook-form'
|
import {useForm} from 'react-hook-form'
|
||||||
import Center from '@/components/composites/purchase/short/center'
|
import Center from '@/components/composites/purchase/short/center'
|
||||||
import Right from '@/components/composites/purchase/short/right'
|
|
||||||
import {Form} from '@/components/ui/form'
|
import {Form} from '@/components/ui/form'
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import {zodResolver} from '@hookform/resolvers/zod'
|
import {zodResolver} from '@hookform/resolvers/zod'
|
||||||
|
import {ProductItem} from '@/actions/product'
|
||||||
|
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
|
||||||
|
import {PurchaseSidePanel} from '../shared/side-panel'
|
||||||
|
|
||||||
// 定义表单验证架构
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['1', '2']).default('2'),
|
type: z.enum(['1', '2']).default('2'),
|
||||||
live: z.enum(['180', '300', '600', '1200', '1800']),
|
live: z.string(),
|
||||||
quota: z.number().min(10000, '购买数量不能少于10000个'),
|
quota: z.number().min(10000, '购买数量不能少于 10000 个'),
|
||||||
expire: z.enum(['7', '15', '30', '90', '180', '365']),
|
expire: z.string(),
|
||||||
daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
|
daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
|
||||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从架构中推断类型
|
|
||||||
export type Schema = z.infer<typeof schema>
|
export type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
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>({
|
const form = useForm<Schema>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
type: '2', // 默认为包量套餐
|
type: defaultMode,
|
||||||
live: '180', // 分钟
|
live: defaultLive,
|
||||||
quota: 10_000, // >= 10000
|
expire: defaultExpire,
|
||||||
expire: '30', // 天
|
quota: defaultMode === '2' ? defaultCountMin || 10000 : 10000,
|
||||||
daily_limit: 2_000, // >= 2000
|
daily_limit: defaultMode === '1' ? defaultCountMin || 2000 : 2000,
|
||||||
pay_type: 'wechat', // 余额支付
|
pay_type: 'balance', // 余额支付
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||||
<Center/>
|
<Center skuData={skuData}/>
|
||||||
<Right/>
|
<PurchaseSidePanel kind="short"/>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,7 @@ function Resolved(props: {
|
|||||||
router.push(profile ? '/admin/purchase' : '/product')
|
router.push(profile ? '/admin/purchase' : '/product')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
免费试用
|
立即试用
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ function Pending(props: {
|
|||||||
router.push('/product')
|
router.push('/product')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
免费试用
|
立即试用
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/components/stores/app.tsx
Normal file
38
src/components/stores/app.tsx
Normal 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])
|
||||||
|
}
|
||||||
78
src/components/updateSend-msg.tsx
Normal file
78
src/components/updateSend-msg.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// 定义后端服务URL和OAuth2配置
|
// 定义后端服务URL和OAuth2配置
|
||||||
const _api_base_url = process.env.NEXT_PUBLIC_API_BASE_URL
|
const _api_base_url = process.env.API_BASE_URL
|
||||||
if (!_api_base_url) throw new Error('NEXT_PUBLIC_API_BASE_URL is not set')
|
if (!_api_base_url) throw new Error('API_BASE_URL is not set')
|
||||||
const API_BASE_URL = _api_base_url
|
const API_BASE_URL = _api_base_url
|
||||||
|
|
||||||
const _client_id = process.env.CLIENT_ID
|
const _client_id = process.env.CLIENT_ID
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export type Batch = {
|
|||||||
time: string
|
time: string
|
||||||
user_id: number
|
user_id: number
|
||||||
prov: string
|
prov: string
|
||||||
|
city: string
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/lib/models/product-sku.ts
Normal file
11
src/lib/models/product-sku.ts
Normal 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
11
src/lib/models/product.ts
Normal 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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user