完善产品购买页面,抽取公共组件,优化导航链接
@@ -2,6 +2,10 @@
|
||||
|
||||
保存客户端信息时用 jwt 序列化
|
||||
|
||||
登录后刷新 profile
|
||||
|
||||
区分调用方式,提供 callByDevice callByUser call 三种调用方式
|
||||
|
||||
---
|
||||
|
||||
页面数据:
|
||||
|
||||
105
src/actions/auth/auth.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
'use server'
|
||||
import {cookies} from 'next/headers'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import {AuthContext} from '@/lib/auth'
|
||||
import {User} from '@/lib/models'
|
||||
import {callByDevice, callByUser, getUserToken} from '@/actions/base'
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
type LoginResp = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires: number
|
||||
auth: AuthContext
|
||||
profile: User
|
||||
}
|
||||
|
||||
export async function login(props: LoginParams): Promise<ApiResponse> {
|
||||
// 尝试登录
|
||||
const result = await callByDevice<LoginResp>('/api/auth/login/sms', {
|
||||
username: props.username,
|
||||
password: props.password,
|
||||
remember: props.remember ?? false,
|
||||
})
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
const data = result.data
|
||||
|
||||
// 保存到 cookies
|
||||
const current = Math.floor(Date.now() / 1000)
|
||||
const future = data.expires - current
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('auth_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(future, 0),
|
||||
})
|
||||
cookieStore.set('auth_refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
cookieStore.set('auth_info', JSON.stringify(data.auth), {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
cookieStore.set('auth_profile', JSON.stringify(data.profile), {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile(refresh: boolean = false) {
|
||||
const cookie = await cookies()
|
||||
|
||||
// 获取缓存的用户信息
|
||||
if (!refresh) {
|
||||
const profile = cookie.get('auth_profile')?.value
|
||||
if (profile) {
|
||||
return JSON.parse(profile) as User
|
||||
}
|
||||
}
|
||||
|
||||
// 获取缓存的 token
|
||||
let token: string
|
||||
try {
|
||||
token = await getUserToken()
|
||||
}
|
||||
catch (e) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果没有缓存,则请求用户信息
|
||||
const result = await callByUser<User>('/api/user/get/token', {token})
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 保存用户信息到cookie
|
||||
cookie.set('auth_profile', JSON.stringify(result.data), {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
|
||||
return result.data
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use server'
|
||||
import {cookies} from 'next/headers'
|
||||
import {ApiResponse, call} from '@/lib/api'
|
||||
import {AuthContext} from '@/lib/auth'
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
remember?: boolean
|
||||
}
|
||||
|
||||
type LoginResp = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires: number
|
||||
auth: AuthContext
|
||||
}
|
||||
|
||||
export async function login(props: LoginParams): Promise<ApiResponse> {
|
||||
try {
|
||||
// 尝试登录
|
||||
const result = await call<LoginResp>('/api/auth/login/sms', {
|
||||
username: props.username,
|
||||
password: props.password,
|
||||
remember: props.remember ?? false,
|
||||
})
|
||||
if (!result.success) {
|
||||
return result
|
||||
}
|
||||
const data = result.data
|
||||
|
||||
// 计算过期时间
|
||||
const current = Math.floor(Date.now() / 1000)
|
||||
const future = data.expires - current
|
||||
|
||||
// 保存到 cookies
|
||||
console.log("token!!!!", data)
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('auth_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(future, 0),
|
||||
})
|
||||
cookieStore.set('auth_refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
cookieStore.set('auth_info', JSON.stringify(data.auth), {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(future, 0),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: undefined,
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error('请求登陆失败', {cause: e})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
// 验证验证码函数
|
||||
import {cookies} from 'next/headers'
|
||||
import crypto from 'crypto'
|
||||
import {ApiResponse, call} from '@/lib/api'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import { callByDevice } from '@/actions/base'
|
||||
|
||||
|
||||
export interface VerifyParams {
|
||||
@@ -30,7 +31,7 @@ export default async function verify(props: VerifyParams): Promise<ApiResponse>
|
||||
}
|
||||
|
||||
// 请求发送短信
|
||||
return await call('/api/auth/verify/sms', {
|
||||
return await callByDevice('/api/auth/verify/sms', {
|
||||
phone: props.phone,
|
||||
purpose: 0,
|
||||
})
|
||||
|
||||
289
src/actions/base.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
'use server'
|
||||
import {API_BASE_URL, CLIENT_ID, CLIENT_SECRET, ApiResponse, UnauthorizedError} from '@/lib/api'
|
||||
import {cookies} from 'next/headers'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
// OAuth令牌缓存
|
||||
interface TokenCache {
|
||||
token: string
|
||||
expires: number // 过期时间戳
|
||||
}
|
||||
|
||||
// ======================
|
||||
// region device token
|
||||
// ======================
|
||||
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
// 获取OAuth2访问令牌
|
||||
async function getDeviceToken(forceRefresh = false): Promise<string> {
|
||||
try {
|
||||
// 检查缓存的令牌是否可用
|
||||
if (!forceRefresh && tokenCache && tokenCache.expires > Date.now()) {
|
||||
return tokenCache.token
|
||||
}
|
||||
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
grant_type: 'client_credentials',
|
||||
}
|
||||
|
||||
const response = await fetch(addr, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OAuth token request failed: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 缓存令牌和过期时间
|
||||
// 通常后端会返回expires_in(秒为单位)
|
||||
tokenCache = {
|
||||
token: data.access_token,
|
||||
expires: Date.now() + data.expires_in * 1000,
|
||||
}
|
||||
|
||||
return tokenCache.token
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to get access token:', error)
|
||||
throw new Error('认证服务暂时不可用')
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的API调用函数
|
||||
async function callByDevice<R = undefined>(endpoint: string, data: unknown): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
// 发送请求
|
||||
let accessToken = getDeviceToken()
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${await accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
let response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
// 如果返回401未授权,尝试刷新令牌并重试一次
|
||||
if (response.status === 401) {
|
||||
accessToken = getDeviceToken(true) // 强制刷新令牌
|
||||
|
||||
// 使用新令牌重试请求
|
||||
requestOptions.headers['Authorization'] = `Bearer ${await accessToken}`
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, await response.text())
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('API call failed:', e)
|
||||
throw new Error('服务调用失败', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
|
||||
// ======================
|
||||
// region user token
|
||||
// ======================
|
||||
|
||||
async function getUserToken(refresh = false): Promise<string> {
|
||||
// 从 cookie 中获取用户令牌
|
||||
const cookie = await cookies()
|
||||
const userToken = cookie.get('auth_token')?.value
|
||||
const userRefresh = cookie.get('auth_refresh')?.value
|
||||
|
||||
// 检查缓存的令牌是否可用
|
||||
if (!refresh && userToken) {
|
||||
return userToken
|
||||
}
|
||||
|
||||
// 如果没有刷新令牌,抛出异常
|
||||
if (!userRefresh) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 请求刷新访问令牌
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: userRefresh,
|
||||
}
|
||||
|
||||
const response = await fetch(addr, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('刷新令牌失败', `status=${response.status}`, await response.text())
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 保存新的用户令牌到 cookie
|
||||
const data = await response.json()
|
||||
const nextAccessToken = data.access_token
|
||||
const nextRefreshToken = data.refresh_token
|
||||
const expiresIn = data.expires_in
|
||||
|
||||
cookie.set('auth_token', nextAccessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(expiresIn, 0),
|
||||
})
|
||||
cookie.set('auth_refresh', nextRefreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600, // 7天
|
||||
})
|
||||
|
||||
return nextAccessToken
|
||||
}
|
||||
|
||||
// 使用用户令牌的API调用函数
|
||||
async function callByUser<R = undefined>(
|
||||
endpoint: string,
|
||||
data: unknown,
|
||||
): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
let token = await getUserToken()
|
||||
|
||||
// 发送请求
|
||||
let response: Response
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
if (response.status === 401) {
|
||||
token = await getUserToken(true)
|
||||
requestOptions.headers['Authorization'] = `Bearer ${token}`
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
console.log('响应不成功', `status=${response.status}`, body)
|
||||
|
||||
if (response.status === 401) {
|
||||
return redirect('/login')
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return {
|
||||
status: response.status,
|
||||
success: false,
|
||||
message: body,
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return {
|
||||
status: response.status,
|
||||
success: false,
|
||||
message: '服务器错误',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
success: false,
|
||||
message: `请求失败,status = ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('API call with user token failed:', e)
|
||||
throw new Error('服务调用失败', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// 统一响应解析
|
||||
async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> {
|
||||
|
||||
// 解析响应数据
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, json)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 预定义错误
|
||||
|
||||
// 导出
|
||||
export {
|
||||
getDeviceToken,
|
||||
getUserToken,
|
||||
callByDevice,
|
||||
callByUser,
|
||||
}
|
||||
3
src/actions/fake/tradeCallback.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function tradeCallbackByAlipay() {
|
||||
|
||||
}
|
||||
27
src/actions/resource.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
'use server'
|
||||
|
||||
import {callByUser} from '@/actions/base'
|
||||
|
||||
async function createResourceByBalance(props: {
|
||||
type: number
|
||||
live: number
|
||||
quota: number
|
||||
expire: number
|
||||
daily_limit: number
|
||||
}) {
|
||||
return await callByUser('/api/resource/create/balance', props)
|
||||
}
|
||||
|
||||
async function createResourceByAlipay() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
async function createResourceByWechat() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
export {
|
||||
createResourceByBalance,
|
||||
createResourceByAlipay,
|
||||
createResourceByWechat,
|
||||
}
|
||||
27
src/actions/trade.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
'use server'
|
||||
|
||||
import { callByUser } from "@/actions/base"
|
||||
|
||||
export async function tradeRecharge(props: {
|
||||
amount: number
|
||||
method: string
|
||||
}) {
|
||||
|
||||
let method: number
|
||||
switch (props.method) {
|
||||
case 'alipay':
|
||||
method = 1
|
||||
break
|
||||
case 'wechat':
|
||||
method = 2
|
||||
break
|
||||
default:
|
||||
throw new Error(`${props.method} is not a valid method`)
|
||||
}
|
||||
|
||||
return await callByUser('/api/trade/create', {
|
||||
subject: '余额充值',
|
||||
amount: Number(props.amount * 100),
|
||||
method: method,
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use server'
|
||||
import {callWithUserToken, PageRecord} from '@/lib/api'
|
||||
import { PageRecord} from '@/lib/api'
|
||||
import { callByUser } from '@/actions/base'
|
||||
|
||||
type Whitelist = {
|
||||
id: number
|
||||
@@ -13,7 +14,7 @@ async function listWhitelist(props: {
|
||||
page: number
|
||||
size: number
|
||||
}) {
|
||||
return await callWithUserToken<PageRecord<Whitelist>>('/api/whitelist/list', props)
|
||||
return await callByUser<PageRecord<Whitelist>>('/api/whitelist/list', props)
|
||||
}
|
||||
|
||||
async function createWhitelist(props: {
|
||||
@@ -21,7 +22,7 @@ async function createWhitelist(props: {
|
||||
remark?: string
|
||||
}) {
|
||||
console.log(props)
|
||||
return await callWithUserToken('/api/whitelist/create', props)
|
||||
return await callByUser('/api/whitelist/create', props)
|
||||
}
|
||||
|
||||
async function updateWhitelist(props: {
|
||||
@@ -30,13 +31,13 @@ async function updateWhitelist(props: {
|
||||
remark?: string
|
||||
}) {
|
||||
console.log(props)
|
||||
return await callWithUserToken('/api/whitelist/update', props)
|
||||
return await callByUser('/api/whitelist/update', props)
|
||||
}
|
||||
|
||||
async function removeWhitelist(props: {
|
||||
id: number
|
||||
}[]) {
|
||||
return await callWithUserToken('/api/whitelist/remove', props)
|
||||
return await callByUser('/api/whitelist/remove', props)
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import {useState, useCallback, useRef} from 'react'
|
||||
import {useState, useCallback, useRef, useContext} from 'react'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Checkbox} from '@/components/ui/checkbox'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -21,11 +20,13 @@ import {useForm} from 'react-hook-form'
|
||||
import zod from 'zod'
|
||||
import Captcha from './captcha'
|
||||
import verify from '@/actions/auth/verify'
|
||||
import {login} from '@/actions/auth/login'
|
||||
import {login} from '@/actions/auth/auth'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {toast} from 'sonner'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import bg from './_assets/bg.webp'
|
||||
|
||||
export type LoginPageProps = {}
|
||||
|
||||
@@ -162,7 +163,7 @@ export default function LoginPage(props: LoginPageProps) {
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功
|
||||
toast.success('登陆成功', {
|
||||
toast.success('登录成功', {
|
||||
description: '欢迎回来!',
|
||||
})
|
||||
|
||||
@@ -190,10 +191,12 @@ export default function LoginPage(props: LoginPageProps) {
|
||||
return (
|
||||
<main className={merge(
|
||||
`relative`,
|
||||
`h-screen w-screen xl:pr-64 bg-[url(/login/bg.webp)] bg-cover bg-left`,
|
||||
`h-screen w-screen xl:pr-64 bg-cover bg-left`,
|
||||
`flex justify-center xl:justify-end items-center`,
|
||||
)}>
|
||||
<Image src={logo} alt={`logo`} height={64} className={`absolute top-8 left-8`}/>
|
||||
<Image src={bg} alt={`背景图`} fill priority className={`absolute -z-10 object-cover`}/>
|
||||
|
||||
<Image src={logo} alt={`logo`} priority height={64} className={`absolute top-8 left-8`}/>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Card className="w-96 mx-4 shadow-lg">
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function UserCenter(props: UserCenterProps) {
|
||||
</Link>
|
||||
</>
|
||||
: <>
|
||||
<Link href={`/admin/dashboard`}>
|
||||
<Link href={`/admin`}>
|
||||
<Button variant={`gradient`}>
|
||||
进入控制台
|
||||
</Button>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client'
|
||||
import {useState} from 'react'
|
||||
|
||||
export function Combo(props: {
|
||||
name: string
|
||||
level?: {
|
||||
number: number
|
||||
discount: number
|
||||
}[]
|
||||
}) {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<p className={`flex justify-between items-center`}>
|
||||
<span>{props.name}</span>
|
||||
<button
|
||||
className={`text-gray-500 text-sm`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? '收起' : '展开'}
|
||||
</button>
|
||||
</p>
|
||||
{props.level && (
|
||||
<ul className={[
|
||||
`flex flex-col gap-3 overflow-hidden`,
|
||||
`transition-[opacity,padding,max-height] transition-discrete duration-200 ease-in-out`,
|
||||
open
|
||||
? 'delay-[0s, 0s] opacity-100 py-3 max-h-80'
|
||||
: 'delay-[0s, 0.2s] opacity-0 p-0 max-h-0',
|
||||
].join(' ')}>
|
||||
{props.level.map((item, index) => (
|
||||
<li key={index} className={`flex flex-row justify-between items-center`}>
|
||||
<span className={`text-gray-500 text-sm`}>{item.number}</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>赠送 {item.discount} %</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,265 +1,19 @@
|
||||
import BreadCrumb from '@/components/bread-crumb'
|
||||
import Wrap from '@/components/wrap'
|
||||
import {Combo} from '@/app/(root)/product/_client/combo'
|
||||
|
||||
import Purchase from '@/components/composites/purchase'
|
||||
|
||||
export type ProductPageProps = {}
|
||||
|
||||
export default function ProductPage(props: ProductPageProps) {
|
||||
return (
|
||||
<main className={`mt-20`}>
|
||||
<Wrap className="flex flex-col py-8 gap-8">
|
||||
<Wrap className="flex flex-col py-8 gap-4">
|
||||
<BreadCrumb items={[
|
||||
{label: '产品中心', href: '/product'},
|
||||
]}/>
|
||||
|
||||
<ul role={`tablist`} className={`flex justify-center items-stretch bg-white rounded-lg`}>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>短效动态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>长效静态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>固定套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>定制套餐</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section role={`tabpanel`} className={`flex flex-row bg-white rounded-lg`}>
|
||||
<Left/>
|
||||
<Center/>
|
||||
<Right/>
|
||||
</section>
|
||||
<Purchase/>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Left() {
|
||||
return (
|
||||
<div className="flex-none basis-56 p-8 flex flex-col gap-4">
|
||||
<img src={`/product/banner.webp`} alt={`banner`} className={`w-full`}/>
|
||||
<h3 className={`text-lg`}>包量套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<Combo name={`3分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`5分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`10分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`15分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`30分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
</ul>
|
||||
<div className={`border-b border-gray-200`}></div>
|
||||
<h3 className={`text-lg`}>包时套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>7天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>9折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>30天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>8折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>90天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>7折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>180天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>6折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>360天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>5折</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Center() {
|
||||
return (
|
||||
<div className={`flex-auto p-8 flex flex-col relative gap-8`}>
|
||||
|
||||
<div className={`space-y-4`}>
|
||||
<h3>计费方式</h3>
|
||||
<div className={`grid grid-cols-2 auto-cols-fr place-items-stretch gap-4`}>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
|
||||
<h4>包量套餐</h4>
|
||||
<p className={`text-sm text-gray-500`}>适用于短期或不定期高提取业务场景</p>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
|
||||
<h4>包时套餐</h4>
|
||||
<p className={`text-sm text-gray-500`}>适用于每日提取量稳定的业务场景</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4`}>
|
||||
<h3>IP 时效</h3>
|
||||
<div className={`grid grid-cols-5 auto-cols-fr place-items-stretch gap-4`}>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 赠送 IP 数 */}
|
||||
<div className={`space-y-4`}>
|
||||
<h3>赠送IP总数</h3>
|
||||
<div className={`flex gap-4`}>
|
||||
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>-</button>
|
||||
<input type="number" className={`w-40 h-10 border border-gray-200 rounded-sm`}/>
|
||||
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品特性 */}
|
||||
<div className={`space-y-6`}>
|
||||
<h3>产品特性</h3>
|
||||
<div className={`grid grid-cols-3 auto-rows-fr gap-y-6`}>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} 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`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>每日去重量:500万</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 左右的边框 */}
|
||||
<div className={`absolute inset-0 my-8 border-l border-r border-gray-200 pointer-events-none`}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Right() {
|
||||
return (
|
||||
<div className={`flex-none basis-80 p-8 flex flex-col gap-4`}>
|
||||
<h3>订单详情</h3>
|
||||
<ul className={`flex flex-col gap-4`}>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>套餐名称</span>
|
||||
<span className={`text-sm`}>包量套餐</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>套餐时长</span>
|
||||
<span className={`text-sm`}>3分钟</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>购买 IP 量</span>
|
||||
<span className={`text-sm`}>1000个</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>实到 IP 量</span>
|
||||
<span className={`text-sm`}>1000个</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>原价</span>
|
||||
<span className={`text-sm`}>¥50</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`}>¥50</span>
|
||||
</p>
|
||||
<div className={`flex gap-4`}>
|
||||
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
|
||||
<img src={`/product/alipay.svg`} alt={`alipay`} className={`w-5 h-5`}/>
|
||||
<span className={`text-sm`}>支付宝</span>
|
||||
</button>
|
||||
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
|
||||
<img src={`/product/wechat.svg`} alt={`wechat`} className={`w-5 h-5`}/>
|
||||
<span className={`text-sm`}>微信</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className={`mt-4 h-12 bg-blue-500 text-white rounded-lg`}>
|
||||
立即支付
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
src/app/(temp)/pay/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import {Button} from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type PayPageProps = {}
|
||||
|
||||
export default async function PayPage(props: PayPageProps) {
|
||||
return (
|
||||
<div className={`w-screen h-screen items-center justify-start pt-60 flex flex-col gap-8`}>
|
||||
<div className={`w-36 h-36 bg-gray-50 rounded-lg flex items-center justify-center`}>
|
||||
模拟支付页
|
||||
</div>
|
||||
<Link href={'/admin/resources'} className={`p-4 bg-blue-500 text-white rounded-md`}>
|
||||
模拟支付成功
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export type DashboardPageProps = {}
|
||||
export default async function DashboardPage(props: DashboardPageProps) {
|
||||
|
||||
return (
|
||||
<main className={`flex-auto overflow-hidden grid grid-cols-4 grid-rows-4 p-4 pt-0 gap-4`}>
|
||||
<main className={`flex-auto overflow-hidden grid grid-cols-4 grid-rows-4 gap-4 mr-4 mb-4`}>
|
||||
{/* banner */}
|
||||
<section className={`col-start-1 row-start-1 col-span-3 bg-red-200`}>
|
||||
|
||||
10
src/app/admin/extract/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
export type ExtractPageProps = {
|
||||
}
|
||||
|
||||
export default async function ExtractPage(props: ExtractPageProps) {
|
||||
return (
|
||||
<main></main>
|
||||
)
|
||||
}
|
||||
BIN
src/app/admin/identify/_assets/banner.webp
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
src/app/admin/identify/_assets/personal.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
39
src/app/admin/identify/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import {Button} from '@/components/ui/button'
|
||||
import banner from './_assets/banner.webp'
|
||||
import personal from './_assets/personal.webp'
|
||||
import Image from 'next/image'
|
||||
|
||||
export type IdentifyPageProps = {}
|
||||
|
||||
export default async function IdentifyPage(props: IdentifyPageProps) {
|
||||
return (
|
||||
<main className={`flex-auto flex items-stretch gap-4 pb-4 pr-4`}>
|
||||
<div className={`flex-3/4 flex flex-col bg-white rounded-lg overflow-hidden gap-16`}>
|
||||
|
||||
{/* banner */}
|
||||
<section className={`flex-none basis-40 relative flex flex-col gap-4 pl-8 justify-center`}>
|
||||
<Image src={banner} alt={`背景图`} aria-hidden className={`absolute inset-0 w-full h-full object-cover`}/>
|
||||
<h3 className={`text-lg font-bold z-10 relative`}>蓝狐HTTP邀请您参与【先测后买】服务</h3>
|
||||
<p className={`text-sm text-gray-600 z-10 relative`}>为了保障您的账户安全,请先完成实名认证,即可获取福利套餐测试资格</p>
|
||||
</section>
|
||||
|
||||
<div className={`flex-auto flex justify-center items-start`}>
|
||||
{/* 个人认证 */}
|
||||
<section className={`w-96 bg-gray-50 p-8 rounded-md flex flex-col gap-4 items-center`}>
|
||||
<Image src={personal} alt={`个人认证`}/>
|
||||
<div>
|
||||
<h3 className={`text-center text-lg font-bold`}>个人认证</h3>
|
||||
<p className={`text-sm text-gray-600`}>平台授权支付宝安全认证,不会泄露您的认证信息</p>
|
||||
</div>
|
||||
<Button className={`w-full`}>去认证</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className={`flex-1/4 flex flex-col bg-white rounded-lg`}>
|
||||
<h3>操作引导</h3>
|
||||
<p>为响应国家相关规定,使用HTTP代理需完成实名认证。认证服务由支付宝提供,您的个人信息将受到严格保护,仅用于账户安全认证</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,22 +2,30 @@ import {ReactNode} from 'react'
|
||||
import Image from 'next/image'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import Profile from '@/app/admin/_server/profile'
|
||||
import Wrap from '@/components/wrap'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import {getProfile} from '@/actions/auth/auth'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
export type DashboardLayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default async function DashboardLayout(props: DashboardLayoutProps) {
|
||||
|
||||
const profile = await getProfile()
|
||||
if (!profile) {
|
||||
return redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col overflow-hidden`}>
|
||||
<div className={`h-screen flex flex-col overflow-hidden min-w-7xl overflow-y-hidden relative`}>
|
||||
{/* background */}
|
||||
<div className={`-z-10 relative`}>
|
||||
<div className={`-z-10 absolute inset-0 overflow-hidden`}>
|
||||
<div className={`absolute w-screen h-screen bg-gray-50`}></div>
|
||||
<div className={`absolute w-[2000px] h-[2000px] -left-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
|
||||
<div className={`absolute w-[2000px] h-[2000px] -right-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
|
||||
<div className={`absolute w-[2000px] h-[2000px] left-[calc(50%-1000px)] -bottom-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
@@ -43,20 +51,20 @@ export default async function DashboardLayout(props: DashboardLayoutProps) {
|
||||
`flex-none basis-60 rounded-tr-xl bg-white p-4`,
|
||||
`flex flex-col overflow-auto`,
|
||||
)}>
|
||||
<NavItem href={'/admin/dashboard'} icon={`🏠`} label={`账户总览`}/>
|
||||
<NavTitle label={`套餐管理`}/>
|
||||
<NavItem href={`/admin/package`} icon={`🛒`} label={`购买套餐`}/>
|
||||
<NavItem href={`/admin/package/my`} icon={`📦`} label={`我的套餐`}/>
|
||||
<NavTitle label={`IP 管理`}/>
|
||||
<NavItem href={`/admin/ip/extract`} icon={`📤`} label={`IP提取`}/>
|
||||
<NavItem href={`/admin/ip/extract/record`} icon={`📜`} label={`IP提取记录`}/>
|
||||
<NavItem href={`/admin/ip/view`} icon={`👁️`} label={`查看提取IP`}/>
|
||||
<NavItem href={`/admin/ip/view/used`} icon={`🗂️`} label={`查看使用IP`}/>
|
||||
<NavItem href={'/admin'} icon={`🏠`} label={`账户总览`}/>
|
||||
<NavTitle label={`个人信息`}/>
|
||||
<NavItem href={`/admin/profile`} icon={`📝`} label={`个人信息修改`}/>
|
||||
<NavItem href={`/admin/bill`} icon={`💰`} label={`我的账单`}/>
|
||||
<NavItem href={`/admin`} icon={`📝`} label={`修改信息`}/>
|
||||
<NavItem href={`/admin/identify`} icon={`🆔`} label={`实名认证`}/>
|
||||
<NavItem href={`/admin/whitelist`} icon={`🔒`} label={`白名单`}/>
|
||||
<NavItem href={`/admin`} icon={`💰`} label={`我的账单`}/>
|
||||
<NavTitle label={`套餐管理`}/>
|
||||
<NavItem href={`/admin/purchase`} icon={`🛒`} label={`购买套餐`}/>
|
||||
<NavItem href={`/admin`} icon={`📦`} label={`我的套餐`}/>
|
||||
<NavTitle label={`IP 管理`}/>
|
||||
<NavItem href={`/admin/extract`} icon={`📤`} label={`IP提取`}/>
|
||||
<NavItem href={`/admin`} icon={`📜`} label={`IP提取记录`}/>
|
||||
<NavItem href={`/admin`} icon={`👁️`} label={`查看提取IP`}/>
|
||||
<NavItem href={`/admin`} icon={`🗂️`} label={`查看使用IP`}/>
|
||||
</nav>
|
||||
|
||||
{props.children}
|
||||
@@ -85,7 +93,7 @@ function NavItem(props: {
|
||||
`px-4 py-2 flex items-center rounded-md`,
|
||||
`hover:bg-gray-100`,
|
||||
)} href={props.href}>
|
||||
{props.icon}
|
||||
<span className={`w-6 h-6 flex items-center justify-center`}>{props.icon}</span>
|
||||
<span className={`ml-2`}>{props.label}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
11
src/app/admin/purchase/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Purchase from '@/components/composites/purchase'
|
||||
|
||||
export type PurchasePageProps = {}
|
||||
|
||||
export default async function PurchasePage(props: PurchasePageProps) {
|
||||
return (
|
||||
<main className={`flex-auto mb-4 mr-4`}>
|
||||
<Purchase/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
12
src/app/admin/resources/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
export type ResourcesPageProps = {
|
||||
}
|
||||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
return (
|
||||
<main>
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
--secondary-foreground: oklch(0.21 0.034 264.665);
|
||||
--muted: oklch(0.967 0.003 264.542);
|
||||
--muted-foreground: oklch(0.551 0.027 264.364);
|
||||
--accent: oklch(0.967 0.003 264.542);
|
||||
--accent-foreground: oklch(0.21 0.034 264.665);
|
||||
--accent: oklch(0.769 0.188 70.08);
|
||||
--accent-foreground: oklch(0.985 0.002 247.839);
|
||||
--destructive: oklch(0.64 0.1597 25);
|
||||
--destructive-foreground: oklch(0.985 0.002 247.839);
|
||||
--border: oklch(0.928 0.006 264.531);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {Metadata} from 'next'
|
||||
import './globals.css'
|
||||
import localFont from 'next/font/local'
|
||||
import {Toaster} from '@/components/ui/sonner'
|
||||
import AuthProvider from '@/components/providers/AuthProvider'
|
||||
import {getProfile} from '@/actions/auth/auth'
|
||||
|
||||
const font = localFont({
|
||||
src: './NotoSansSC-VariableFont_wght.ttf',
|
||||
@@ -13,7 +15,7 @@ export const metadata: Metadata = {
|
||||
description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode
|
||||
@@ -21,8 +23,10 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-Cn">
|
||||
<body className={`${font.className}`}>
|
||||
{children}
|
||||
<Toaster position={'top-center'}/>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<Toaster position={'top-center'} richColors expand/>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
4
src/components/composites/_assets/balance.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.99996 0C4.4771 0 0 4.47714 0 10C0 15.5229 4.47712 20 9.99996 20C15.5228 20 19.9999 15.5228 19.9999 10C19.9999 4.47716 15.5228 0 9.99996 0ZM7.05159 4.29727C7.78297 3.68016 9.40572 4.98294 9.97711 4.93723C10.5485 4.98294 12.1713 3.68016 12.9026 4.29727C13.634 4.91437 12.017 6.325 12.017 6.325H7.93688C7.93688 6.325 6.32021 4.91437 7.05159 4.29727ZM12.1713 7.05639C12.1713 7.25852 12.0077 7.42208 11.8056 7.42208H8.14866C7.9465 7.42208 7.78297 7.25852 7.78297 7.05639C7.78297 6.85425 7.9465 6.6907 8.14866 6.6907H11.8056C12.0077 6.6907 12.1713 6.85425 12.1713 7.05639ZM9.97711 15.8251C7.14941 15.8251 4.85744 16.0801 4.85744 13.6881C4.85744 11.9096 6.12522 9.10908 7.93688 7.78775H12.017C13.829 9.10911 15.0968 11.9096 15.0968 13.6881C15.0968 16.0801 12.8048 15.8251 9.97711 15.8251Z" fill="#FF6B00"/>
|
||||
<path d="M10.0331 10.4641C10.1948 10.4641 10.321 10.5147 10.4125 10.6152C10.504 10.7158 10.5501 10.8605 10.5501 11.0487H11.5651C11.5651 10.7314 11.457 10.4759 11.2417 10.2807C11.0263 10.0854 10.7292 9.96605 10.3514 9.92196V9.375H9.82754V9.91174C9.43225 9.94399 9.11936 10.0511 8.88808 10.2334C8.65681 10.4157 8.54116 10.6486 8.54116 10.9315C8.54116 11.2434 8.6553 11.4833 8.88429 11.6511C9.11333 11.8189 9.46855 11.969 9.94998 12.1013C10.1714 12.1846 10.3249 12.268 10.4103 12.3519C10.4956 12.4358 10.5387 12.5498 10.5387 12.6939C10.5387 12.8349 10.4972 12.9473 10.4126 13.0322C10.3279 13.1167 10.1971 13.1592 10.0188 13.1592C9.82683 13.1592 9.6726 13.1113 9.55846 13.0167C9.44432 12.922 9.38689 12.7634 9.38689 12.5412H8.39678L8.38921 12.5541C8.37939 12.915 8.50335 13.1888 8.7611 13.3755C9.01882 13.5621 9.35064 13.6712 9.75495 13.7035V14.2043H10.2825V13.7009C10.6778 13.6686 10.9892 13.5653 11.2167 13.3894C11.4442 13.2136 11.5583 12.9801 11.5583 12.6887C11.5583 12.3783 11.4411 12.1352 11.2076 11.9588C10.974 11.7824 10.6234 11.6334 10.1563 11.5113C9.92274 11.4183 9.76476 11.3317 9.68312 11.2521C9.60152 11.1725 9.56073 11.0665 9.55995 10.9348C9.55995 10.7922 9.597 10.6781 9.6703 10.5927C9.74364 10.5072 9.86457 10.4641 10.0331 10.4641Z" fill="#FF6B00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
218
src/components/composites/_client/center.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Minus, Plus} from 'lucide-react'
|
||||
import {PurchaseFormContext, Schema} from '@/components/composites/_client/form'
|
||||
import {useContext} from 'react'
|
||||
import FormOption from '@/components/composites/_client/option'
|
||||
import Image from 'next/image'
|
||||
import check from '../_assets/check.svg'
|
||||
|
||||
export default function Center() {
|
||||
|
||||
const form = useContext(PurchaseFormContext)?.form
|
||||
if (!form) {
|
||||
throw new Error(`Center component must be used within PurchaseFormContext`)
|
||||
}
|
||||
|
||||
const watchType = form.watch('type')
|
||||
|
||||
return (
|
||||
<div className={`flex-auto p-8 flex flex-col gap-8 relative`}>
|
||||
|
||||
{/* 计费方式 */}
|
||||
<FormField
|
||||
className={`flex flex-col gap-4`}
|
||||
name={`type`}
|
||||
label={`计费方式`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className={`flex gap-4`}>
|
||||
|
||||
<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 时效 */}
|
||||
<FormField
|
||||
className={`space-y-4`}
|
||||
name={`live`}
|
||||
label={`IP 时效`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className={`flex gap-4 flex-wrap`}>
|
||||
|
||||
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.007/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.010/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-20`} value="20" label="20 分钟" description="¥0.015/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.020/IP" compare={field.value}/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 根据套餐类型显示不同表单项 */}
|
||||
{watchType === '2' ? (
|
||||
/* 包量:IP 购买数量 */
|
||||
<FormField
|
||||
className={`space-y-4`}
|
||||
name={`quota`}
|
||||
label={`IP 购买数量`}>
|
||||
{({id, field}) => (
|
||||
<div className={`flex gap-2 items-center`}>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
type="button"
|
||||
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}
|
||||
onClick={() => form.setValue('quota', Math.max(10_000, Number(field.value) - 5_000))}
|
||||
disabled={Number(field.value) === 10_000}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className={`w-40 h-10 border border-gray-200 rounded-sm text-center`}
|
||||
min={10_000}
|
||||
step={5_000}
|
||||
/>
|
||||
<Button
|
||||
variant={`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) + 5_000)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
variant={`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}
|
||||
/>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
type="button"
|
||||
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}
|
||||
onClick={() => form.setValue('daily_limit', Number(field.value) + 1_000)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 产品特性 */}
|
||||
<div className={`space-y-6`}>
|
||||
<h3>产品特性</h3>
|
||||
<div className={`grid grid-cols-3 auto-rows-fr gap-y-6`}>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>支持高并发提取</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>指定省份、城市或混播</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>账密+白名单验证</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>完备的API接口</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>IP时效3-30分钟(可定制)</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>IP资源定期筛选</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>完备的API接口</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>包量/包时计费方式</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>每日去重量:500万</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 左右的边框 */}
|
||||
<div className={`absolute inset-0 my-8 border-l border-r border-gray-200 pointer-events-none`}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
src/components/composites/_client/form.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import {createContext, useContext} from 'react'
|
||||
import {useForm, UseFormReturn} from 'react-hook-form'
|
||||
import Center from '@/components/composites/_client/center'
|
||||
import Right from '@/components/composites/_client/right'
|
||||
import Left from '@/components/composites/_client/left'
|
||||
import {Form} from '@/components/ui/form'
|
||||
import * as z from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {createResourceByBalance} from '@/actions/resource'
|
||||
import {toast} from 'sonner'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {AuthContext} from '@/components/providers/AuthProvider'
|
||||
|
||||
// 定义表单验证架构
|
||||
const schema = z.object({
|
||||
type: z.enum(['1', '2']).default('2'),
|
||||
live: z.enum(['3', '5', '10', '20', '30']),
|
||||
quota: z.number().min(10000, '购买数量不能少于10000个'),
|
||||
expire: z.enum(['7', '15', '30', '90', '180', '365']),
|
||||
daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
||||
})
|
||||
|
||||
// 从架构中推断类型
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
type PurchaseFormContextType = {
|
||||
form: UseFormReturn<Schema>
|
||||
}
|
||||
|
||||
export const PurchaseFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
|
||||
|
||||
export type PurchaseFormProps = {}
|
||||
|
||||
export default function PurchaseForm(props: PurchaseFormProps) {
|
||||
console.log('PurchaseForm render')
|
||||
const authCtx = useContext(AuthContext)
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: '2', // 默认为包量套餐
|
||||
live: '3', // 分钟
|
||||
quota: 10_000, // >= 10000
|
||||
expire: '30', // 天
|
||||
daily_limit: 2_000, // >= 2000
|
||||
pay_type: 'balance', // 余额支付
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const toExtract = () => {
|
||||
router.push('/admin/extract')
|
||||
}
|
||||
|
||||
const onSubmit = async (value: Schema) => {
|
||||
try {
|
||||
const resp = await createResourceByBalance({
|
||||
type: Number(value.type),
|
||||
live: Number(value.live) * 60,
|
||||
quota: Number(value.quota),
|
||||
expire: Number(value.expire) * 24 * 3600,
|
||||
daily_limit: Number(value.daily_limit),
|
||||
})
|
||||
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.message)
|
||||
}
|
||||
|
||||
toast.success('购买成功', {
|
||||
duration: 10 * 1000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: `去提取`,
|
||||
onClick: toExtract,
|
||||
},
|
||||
})
|
||||
|
||||
await authCtx.refreshProfile()
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
toast.error('购买失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section role={`tabpanel`} className={`bg-white rounded-lg`}>
|
||||
<Form form={form} onSubmit={onSubmit} className={`flex flex-row`}>
|
||||
<PurchaseFormContext.Provider value={{form}}>
|
||||
<Left/>
|
||||
<Center/>
|
||||
<Right/>
|
||||
</PurchaseFormContext.Provider>
|
||||
</Form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
124
src/components/composites/_client/left.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
import {useState} from 'react'
|
||||
import Image from 'next/image'
|
||||
import banner from '@/components/composites/_assets/banner.webp'
|
||||
|
||||
export type LeftProps = {
|
||||
}
|
||||
|
||||
export default function Left(props: LeftProps) {
|
||||
return (
|
||||
<div className="flex-none basis-56 p-8 flex flex-col gap-4">
|
||||
<Image src={banner} alt={`banner`} className={`w-full`}/>
|
||||
<h3 className={`text-lg`}>包量套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<Combo name={`3分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`5分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`10分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`15分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`30分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
</ul>
|
||||
<div className={`border-b border-gray-200`}></div>
|
||||
<h3 className={`text-lg`}>包时套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>7天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>9折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>30天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>8折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>90天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>7折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>180天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>6折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>360天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>5折</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Combo(props: {
|
||||
name: string
|
||||
level?: {
|
||||
number: number
|
||||
discount: number
|
||||
}[]
|
||||
}) {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<p className={`flex justify-between items-center`}>
|
||||
<span>{props.name}</span>
|
||||
<button
|
||||
className={`text-gray-500 text-sm`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? '收起' : '展开'}
|
||||
</button>
|
||||
</p>
|
||||
{props.level && (
|
||||
<ul className={[
|
||||
`flex flex-col gap-3 overflow-hidden`,
|
||||
`transition-[opacity,padding,max-height] transition-discrete duration-200 ease-in-out`,
|
||||
open
|
||||
? 'delay-[0s, 0s] opacity-100 py-3 max-h-80'
|
||||
: 'delay-[0s, 0.2s] opacity-0 p-0 max-h-0',
|
||||
].join(' ')}>
|
||||
{props.level.map((item, index) => (
|
||||
<li key={index} className={`flex flex-row justify-between items-center`}>
|
||||
<span className={`text-gray-500 text-sm`}>{item.number}</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>赠送 {item.discount} %</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/components/composites/_client/option.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
import {FormLabel} from '@/components/ui/form'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {RadioGroupItem} from '@/components/ui/radio-group'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
export type FormOptionProps = {
|
||||
id: string
|
||||
value: string
|
||||
label?: string
|
||||
description?: string
|
||||
compare: string
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function FormOption(props: FormOptionProps) {
|
||||
return <>
|
||||
<FormLabel
|
||||
htmlFor={props.id}
|
||||
className={merge(
|
||||
`transition-colors duration-150 ease-in-out`,
|
||||
`px-6 py-4 border rounded-md flex flex-col gap-2 cursor-pointer`,
|
||||
props.compare === props.value ? `bg-primary/10 border-primary` : `border-gray-200`,
|
||||
props.className,
|
||||
)}>
|
||||
{props.children ? props.children : <>
|
||||
<span>{props.label}</span>
|
||||
{props.description && <p className={`text-sm text-gray-500`}>{props.description}</p>}
|
||||
</>}
|
||||
</FormLabel>
|
||||
<RadioGroupItem id={props.id} value={props.value} className={`hidden`}/>
|
||||
</>
|
||||
}
|
||||
155
src/components/composites/_client/recharge.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import zod from 'zod'
|
||||
import FormOption from '@/components/composites/_client/option'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import Image from 'next/image'
|
||||
import wechat from '../_assets/wechat.svg'
|
||||
import alipay from '../_assets/alipay.svg'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {tradeRecharge} from '@/actions/trade'
|
||||
import {toast} from 'sonner'
|
||||
import {useRouter} from 'next/navigation'
|
||||
|
||||
const schema = zod.object({
|
||||
method: zod.enum(['alipay', 'wechat']),
|
||||
amount: zod.number().min(1, '充值金额必须大于 0'),
|
||||
})
|
||||
|
||||
type Schema = zod.infer<typeof schema>
|
||||
|
||||
export type RechargeModelProps = {}
|
||||
|
||||
export default function RechargeModal(props: RechargeModelProps) {
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
method: 'alipay',
|
||||
amount: 50,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
const resp = await tradeRecharge(data)
|
||||
if (!resp.success) {
|
||||
throw new Error(resp.message)
|
||||
}
|
||||
|
||||
// todo 跳转支付页
|
||||
router.push('/pay')
|
||||
}
|
||||
catch (e) {
|
||||
toast.error(`充值失败`, {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={`accent`} type={`button`} className={`px-4 h-8`}>去充值</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogTitle className={`flex flex-col gap-2`}>
|
||||
充值中心
|
||||
</DialogTitle>
|
||||
|
||||
<Form form={form} onSubmit={onSubmit} className={`flex flex-col gap-8`}>
|
||||
|
||||
{/* 充值额度 */}
|
||||
<FormField<Schema> name={`amount`} label={`充值额度`} className={`flex flex-col gap-4`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={String(field.value)}
|
||||
onValueChange={v => field.onChange(Number(v))}
|
||||
className={`flex flex-col gap-2`}>
|
||||
|
||||
<div className={`flex items-center gap-2`}>
|
||||
<FormOption
|
||||
id={`${id}-20`} value={`20`} label={`20元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
<FormOption
|
||||
id={`${id}-50`} value={`50`} label={`50元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
<FormOption
|
||||
id={`${id}-100`} value={`100`} label={`100元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-2`}>
|
||||
<FormOption
|
||||
id={`${id}-200`} value={`200`} label={`200元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
<FormOption
|
||||
id={`${id}-500`} value={`500`} label={`500元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
<FormOption
|
||||
id={`${id}-1000`} value={`1000`} label={`1000元`}
|
||||
compare={String(field.value)}
|
||||
className={`flex-1`}
|
||||
/>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<FormField name={`method`} label={`支付方式`} className={`flex flex-col gap-4`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className={`flex gap-2`}>
|
||||
<FormOption
|
||||
id={`${id}-alipay`} value={`alipay`}
|
||||
compare={field.value}
|
||||
className={`flex-1 flex-row justify-center items-center`}>
|
||||
<Image src={alipay} alt={`支付宝 logo`} className={`w-6 h-6`}/>
|
||||
<span>支付宝</span>
|
||||
</FormOption>
|
||||
<FormOption
|
||||
id={`${id}-wechat`} value={`wechat`}
|
||||
compare={field.value}
|
||||
className={`flex-1 flex-row justify-center items-center`}>
|
||||
<Image src={wechat} alt={`微信 logo`} className={`w-6 h-6`}/>
|
||||
<span>微信</span>
|
||||
</FormOption>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<DialogFooter className={`!flex !flex-row !justify-center`}>
|
||||
<Button className={`px-8 h-12 text-lg`}>立即支付</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
128
src/components/composites/_client/right.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
import {useContext} from 'react'
|
||||
import {PurchaseFormContext} from '@/components/composites/_client/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import FormOption from '@/components/composites/_client/option'
|
||||
import Image from 'next/image'
|
||||
import alipay from '../_assets/alipay.svg'
|
||||
import wechat from '../_assets/wechat.svg'
|
||||
import balance from '../_assets/balance.svg'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {AuthContext} from '@/components/providers/AuthProvider'
|
||||
import RechargeModal from '@/components/composites/_client/recharge'
|
||||
|
||||
export type RightProps = {}
|
||||
|
||||
export default function Right(props: RightProps) {
|
||||
console.log('Right render')
|
||||
|
||||
const authCtx = useContext(AuthContext)
|
||||
const profile = authCtx.profile
|
||||
|
||||
const form = useContext(PurchaseFormContext)?.form
|
||||
if (!form) {
|
||||
throw new Error(`Center component must be used within PurchaseFormContext`)
|
||||
}
|
||||
|
||||
const watchType = form.watch('type')
|
||||
const watchLive = form.watch('live')
|
||||
const watchQuota = form.watch('quota')
|
||||
const watchExpire = form.watch('expire')
|
||||
const watchDailyLimit = form.watch('daily_limit')
|
||||
|
||||
return (
|
||||
<div className={`flex-none basis-80 p-6 flex flex-col gap-6`}>
|
||||
<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`}>
|
||||
{watchType === '2' ? `包量套餐` : `包时套餐`}
|
||||
</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>IP 时效</span>
|
||||
<span className={`text-sm`}>
|
||||
{watchLive}分钟
|
||||
</span>
|
||||
</li>
|
||||
{watchType === '2' ? (
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>购买 IP 量</span>
|
||||
<span className={`text-sm`}>
|
||||
{watchQuota}个
|
||||
</span>
|
||||
</li>
|
||||
) : <>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>套餐时长</span>
|
||||
<span className={`text-sm`}>
|
||||
{watchExpire}天
|
||||
</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>每日限额</span>
|
||||
<span className={`text-sm`}>
|
||||
{watchDailyLimit}个
|
||||
</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`}>¥--</span>
|
||||
</p>
|
||||
<FormField name={`pay_type`} label={`支付方式`} className={`flex flex-col gap-6`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className={`flex flex-col gap-3`}>
|
||||
<div className={`w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md`}>
|
||||
<p className={`flex items-center gap-3`}>
|
||||
<Image src={balance} alt={`余额icon`}/>
|
||||
<span className={`text-sm text-gray-500`}>账户余额</span>
|
||||
</p>
|
||||
<p className={`flex justify-between items-center`}>
|
||||
<span className={`text-xl`}>{profile?.balance}</span>
|
||||
<RechargeModal/>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<FormOption
|
||||
id={`${id}-balance`}
|
||||
value={`balance`}
|
||||
compare={field.value}
|
||||
className={`p-3 w-full flex-row gap-2 justify-center`}>
|
||||
<Image src={balance} alt={`余额 icon`}/>
|
||||
<span>余额</span>
|
||||
</FormOption>
|
||||
<FormOption
|
||||
id={`${id}-wechat`}
|
||||
value={`wechat`}
|
||||
compare={field.value}
|
||||
className={`p-3 w-full flex-row gap-2 justify-center`}>
|
||||
<Image src={wechat} alt={`微信 logo`}/>
|
||||
<span>微信</span>
|
||||
</FormOption>
|
||||
<FormOption
|
||||
id={`${id}-alipay`}
|
||||
value={`alipay`}
|
||||
compare={field.value}
|
||||
className={`p-3 w-full flex-row gap-2 justify-center`}>
|
||||
<Image src={alipay} alt={`支付宝 logo`}/>
|
||||
<span>支付宝</span>
|
||||
</FormOption>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
<Button className={`mt-4 h-12`} type="submit">
|
||||
立即支付
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
27
src/components/composites/purchase.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import PurchaseForm from '@/components/composites/_client/form'
|
||||
|
||||
export type PurchaseProps = {}
|
||||
|
||||
export default async function Purchase(props: PurchaseProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ul role={`tablist`} className={`flex justify-center items-stretch bg-white rounded-lg`}>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>短效动态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>长效静态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>固定套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>定制套餐</button>
|
||||
</li>
|
||||
</ul>
|
||||
<PurchaseForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
41
src/components/providers/AuthProvider.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import {User} from '@/lib/models'
|
||||
import {createContext, ReactNode, useEffect, useState} from 'react'
|
||||
import {getProfile} from '@/actions/auth/auth'
|
||||
|
||||
type AuthContentType = {
|
||||
profile: User | null
|
||||
refreshProfile: () => Promise<void>
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContentType>({
|
||||
profile: null,
|
||||
refreshProfile: async () => {
|
||||
throw new Error('Not implemented')
|
||||
},
|
||||
})
|
||||
|
||||
export type ProfileProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function AuthProvider(props: ProfileProviderProps) {
|
||||
|
||||
const [profile, setProfile] = useState<User | null>(null)
|
||||
|
||||
const refreshProfile = async () => {
|
||||
setProfile(await getProfile(true))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshProfile().then()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
profile, refreshProfile,
|
||||
}}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {Slot} from '@radix-ui/react-slot'
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
type ButtonProps = React.ComponentProps<'button'> & {
|
||||
variant?: 'default' | 'outline' | 'gradient' | 'danger'
|
||||
variant?: 'default' | 'outline' | 'gradient' | 'danger' | 'accent'
|
||||
}
|
||||
|
||||
function Button(rawProps: ButtonProps) {
|
||||
@@ -22,9 +22,10 @@ function Button(rawProps: ButtonProps) {
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
{
|
||||
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2',
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
danger: 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
accent: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
danger: 'bg-destructive text-white hover:bg-destructive/90',
|
||||
outline: 'border bg-background hover:bg-secondary hover:text-secondary-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
}[variant ?? 'default'],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -28,7 +28,11 @@ function Form<T extends FieldValues>(rawProps: FormProps<T>) {
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form {...props} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<form {...props} onSubmit={event => {
|
||||
event.preventDefault()
|
||||
form.handleSubmit(onSubmit)(event)
|
||||
event.stopPropagation()
|
||||
}}>
|
||||
{children}
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
281
src/lib/api.ts
@@ -1,277 +1,12 @@
|
||||
// API工具函数
|
||||
|
||||
// 定义后端服务URL和OAuth2配置
|
||||
import {cookies} from 'next/headers'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL
|
||||
const CLIENT_ID = process.env.CLIENT_ID
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET
|
||||
|
||||
// OAuth令牌缓存
|
||||
interface TokenCache {
|
||||
token: string
|
||||
expires: number // 过期时间戳
|
||||
}
|
||||
|
||||
// ======================
|
||||
// region device token
|
||||
// ======================
|
||||
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
// 获取OAuth2访问令牌
|
||||
export async function getAccessToken(forceRefresh = false): Promise<string> {
|
||||
try {
|
||||
// 检查缓存的令牌是否可用
|
||||
if (!forceRefresh && tokenCache && tokenCache.expires > Date.now()) {
|
||||
return tokenCache.token
|
||||
}
|
||||
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
grant_type: 'client_credentials',
|
||||
}
|
||||
|
||||
const response = await fetch(addr, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OAuth token request failed: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 缓存令牌和过期时间
|
||||
// 通常后端会返回expires_in(秒为单位)
|
||||
tokenCache = {
|
||||
token: data.access_token,
|
||||
expires: Date.now() + data.expires_in * 1000,
|
||||
}
|
||||
|
||||
return tokenCache.token
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to get access token:', error)
|
||||
throw new Error('认证服务暂时不可用')
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的API调用函数
|
||||
export async function call<R = undefined>(endpoint: string, data: unknown): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
// 发送请求
|
||||
let accessToken = getAccessToken()
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${await accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
let response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
// 如果返回401未授权,尝试刷新令牌并重试一次
|
||||
if (response.status === 401) {
|
||||
accessToken = getAccessToken(true) // 强制刷新令牌
|
||||
|
||||
// 使用新令牌重试请求
|
||||
requestOptions.headers['Authorization'] = `Bearer ${await accessToken}`
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, await response.text())
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('API call failed:', e)
|
||||
throw new Error('服务调用失败', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
|
||||
// ======================
|
||||
// region user token
|
||||
// ======================
|
||||
|
||||
async function getUserToken(refresh = false): Promise<string> {
|
||||
// 从 cookie 中获取用户令牌
|
||||
const cookie = await cookies()
|
||||
const userToken = cookie.get('auth_token')?.value
|
||||
const userRefresh = cookie.get('auth_refresh')?.value
|
||||
|
||||
// 检查缓存的令牌是否可用
|
||||
if (!refresh && userToken) {
|
||||
return userToken
|
||||
}
|
||||
|
||||
// 如果没有刷新令牌,抛出异常
|
||||
if (!userRefresh) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 请求刷新访问令牌
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: userRefresh,
|
||||
}
|
||||
|
||||
const response = await fetch(addr, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('刷新令牌失败', `status=${response.status}`, await response.text())
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 保存新的用户令牌到 cookie
|
||||
const data = await response.json()
|
||||
const nextAccessToken = data.access_token
|
||||
const nextRefreshToken = data.refresh_token
|
||||
const expiresIn = data.expires_in
|
||||
|
||||
cookie.set('auth_token', nextAccessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(expiresIn, 0),
|
||||
})
|
||||
cookie.set('auth_refresh', nextRefreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600, // 7天
|
||||
})
|
||||
|
||||
return nextAccessToken
|
||||
}
|
||||
|
||||
// 使用用户令牌的API调用函数
|
||||
export async function callWithUserToken<R = undefined>(
|
||||
endpoint: string,
|
||||
data: unknown,
|
||||
): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
let token = await getUserToken()
|
||||
|
||||
// 发送请求
|
||||
let response: Response
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
if (response.status === 401) {
|
||||
token = await getUserToken(true)
|
||||
requestOptions.headers['Authorization'] = `Bearer ${token}`
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
console.log('响应不成功', `status=${response.status}`, await response.text())
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
// 重定向到登录页面
|
||||
if (e === UnauthorizedError) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
console.error('API call with user token failed:', e)
|
||||
throw new Error('服务调用失败', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// 统一响应解析
|
||||
async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> {
|
||||
|
||||
// 解析响应数据
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, json)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的API响应类型
|
||||
export type ApiResponse<T = undefined> = {
|
||||
type ApiResponse<T = undefined> = {
|
||||
success: false
|
||||
status: number
|
||||
message: string
|
||||
@@ -280,11 +15,21 @@ export type ApiResponse<T = undefined> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
export type PageRecord<T = unknown> = {
|
||||
type PageRecord<T = unknown> = {
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export const UnauthorizedError = new Error('未授权访问')
|
||||
// 预定义错误
|
||||
const UnauthorizedError = new Error('未授权访问')
|
||||
|
||||
export {
|
||||
API_BASE_URL,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
type ApiResponse,
|
||||
type PageRecord,
|
||||
UnauthorizedError,
|
||||
}
|
||||
|
||||
25
src/lib/models.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
type User = {
|
||||
id: number
|
||||
admin_id: number
|
||||
phone: string
|
||||
username: string
|
||||
email: string
|
||||
name: string
|
||||
avatar: string
|
||||
status: number
|
||||
balance: number
|
||||
id_type: number
|
||||
id_no: string
|
||||
id_token: string
|
||||
contact_qq: string
|
||||
contact_wechat: string
|
||||
last_login: Date
|
||||
last_login_host: string
|
||||
last_login_agent: string
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
}
|
||||
|
||||
export type {
|
||||
User,
|
||||
}
|
||||