完善产品购买页面,抽取公共组件,优化导航链接

This commit is contained in:
2025-04-08 11:21:58 +08:00
parent a2c18a1be8
commit ba07c79b04
42 changed files with 1481 additions and 666 deletions

View File

@@ -2,6 +2,10 @@
保存客户端信息时用 jwt 序列化
登录后刷新 profile
区分调用方式,提供 callByDevice callByUser call 三种调用方式
---
页面数据:

105
src/actions/auth/auth.ts Normal file
View 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
}

View File

@@ -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})
}
}

View File

@@ -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
View 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,
}

View File

@@ -0,0 +1,3 @@
export async function tradeCallbackByAlipay() {
}

27
src/actions/resource.ts Normal file
View 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
View 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,
})
}

View File

@@ -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 {

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -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">

View File

@@ -33,7 +33,7 @@ export default async function UserCenter(props: UserCenterProps) {
</Link>
</>
: <>
<Link href={`/admin/dashboard`}>
<Link href={`/admin`}>
<Button variant={`gradient`}>
</Button>

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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`}>

View File

@@ -0,0 +1,10 @@
import {ReactNode} from 'react'
export type ExtractPageProps = {
}
export default async function ExtractPage(props: ExtractPageProps) {
return (
<main></main>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View 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>
)
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -0,0 +1,12 @@
import {ReactNode} from 'react'
export type ResourcesPageProps = {
}
export default async function ResourcesPage(props: ResourcesPageProps) {
return (
<main>
</main>
)
}

View File

@@ -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);

View File

@@ -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>
)

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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`}/>
</>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

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

View File

@@ -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>

View File

@@ -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
View 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,
}