diff --git a/README.md b/README.md index d1b5073..ea1b50d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ 保存客户端信息时用 jwt 序列化 +登录后刷新 profile + +区分调用方式,提供 callByDevice callByUser call 三种调用方式 + --- 页面数据: diff --git a/src/actions/auth/auth.ts b/src/actions/auth/auth.ts new file mode 100644 index 0000000..084c2ad --- /dev/null +++ b/src/actions/auth/auth.ts @@ -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 { + // 尝试登录 + const result = await callByDevice('/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('/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 +} diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts deleted file mode 100644 index 4e5f414..0000000 --- a/src/actions/auth/login.ts +++ /dev/null @@ -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 { - try { - // 尝试登录 - const result = await call('/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}) - } -} diff --git a/src/actions/auth/verify.ts b/src/actions/auth/verify.ts index f6f5b7b..a3af5fb 100644 --- a/src/actions/auth/verify.ts +++ b/src/actions/auth/verify.ts @@ -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 } // 请求发送短信 - return await call('/api/auth/verify/sms', { + return await callByDevice('/api/auth/verify/sms', { phone: props.phone, purpose: 0, }) diff --git a/src/actions/base.ts b/src/actions/base.ts new file mode 100644 index 0000000..f8fdbb9 --- /dev/null +++ b/src/actions/base.ts @@ -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 { + 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(endpoint: string, data: unknown): Promise> { + 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 { + // 从 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( + endpoint: string, + data: unknown, +): Promise> { + 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(response: Response): Promise> { + + // 解析响应数据 + 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, +} diff --git a/src/actions/fake/tradeCallback.ts b/src/actions/fake/tradeCallback.ts new file mode 100644 index 0000000..a2d8d05 --- /dev/null +++ b/src/actions/fake/tradeCallback.ts @@ -0,0 +1,3 @@ +export async function tradeCallbackByAlipay() { + +} diff --git a/src/actions/resource.ts b/src/actions/resource.ts new file mode 100644 index 0000000..097f46e --- /dev/null +++ b/src/actions/resource.ts @@ -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, +} diff --git a/src/actions/trade.ts b/src/actions/trade.ts new file mode 100644 index 0000000..ae49204 --- /dev/null +++ b/src/actions/trade.ts @@ -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, + }) +} diff --git a/src/actions/whitelist.ts b/src/actions/whitelist.ts index f8c4972..4816128 100644 --- a/src/actions/whitelist.ts +++ b/src/actions/whitelist.ts @@ -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>('/api/whitelist/list', props) + return await callByUser>('/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 { diff --git a/public/login/bg.webp b/src/app/(auth)/login/_assets/bg.webp similarity index 100% rename from public/login/bg.webp rename to src/app/(auth)/login/_assets/bg.webp diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index b6b72d9..019886c 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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 (
- {`logo`} + {`背景图`} + + {`logo`} {/* 登录表单 */} diff --git a/src/app/(root)/@header/_server/user-center.tsx b/src/app/(root)/@header/_server/user-center.tsx index 6ab1e65..4d1c2ea 100644 --- a/src/app/(root)/@header/_server/user-center.tsx +++ b/src/app/(root)/@header/_server/user-center.tsx @@ -33,7 +33,7 @@ export default async function UserCenter(props: UserCenterProps) { : <> - + diff --git a/src/app/(root)/product/_client/combo.tsx b/src/app/(root)/product/_client/combo.tsx deleted file mode 100644 index 213d0ff..0000000 --- a/src/app/(root)/product/_client/combo.tsx +++ /dev/null @@ -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 ( -
  • -

    - {props.name} - -

    - {props.level && ( -
      - {props.level.map((item, index) => ( -
    • - {item.number} - 赠送 {item.discount} % -
    • - ))} -
    - )} -
  • - ) -} diff --git a/src/app/(root)/product/page.tsx b/src/app/(root)/product/page.tsx index 77e1e21..b51ea45 100644 --- a/src/app/(root)/product/page.tsx +++ b/src/app/(root)/product/page.tsx @@ -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 (
    - + - -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    - -
    - -
    - -
    +
    ) } -function Left() { - return ( -
    - {`banner`} -

    包量套餐

    -
      - - - - - -
    -
    -

    包时套餐

    -
      -
    • - 7天 - 9折 -
    • -
    • - 30天 - 8折 -
    • -
    • - 90天 - 7折 -
    • -
    • - 180天 - 6折 -
    • -
    • - 360天 - 5折 -
    • -
    -
    - ) -} - -function Center() { - return ( -
    - -
    -

    计费方式

    -
    - - -
    -
    - -
    -

    IP 时效

    -
    - - - - - -
    -
    - - {/* 赠送 IP 数 */} -
    -

    赠送IP总数

    -
    - - - -
    -
    - - {/* 产品特性 */} -
    -

    产品特性

    -
    -

    - {`check`} - 支持高并发提取 -

    -

    - {`check`} - 指定省份、城市或混播 -

    -

    - {`check`} - 账密+白名单验证 -

    -

    - {`check`} - 完备的API接口 -

    -

    - {`check`} - IP时效3-30分钟(可定制) -

    -

    - {`check`} - IP资源定期筛选 -

    -

    - {`check`} - 完备的API接口 -

    -

    - {`check`} - 包量/包时计费方式 -

    -

    - {`check`} - 每日去重量:500万 -

    -
    -
    - - {/* 左右的边框 */} -
    -
    - ) -} - -function Right() { - return ( -
    -

    订单详情

    -
      -
    • - 套餐名称 - 包量套餐 -
    • -
    • - 套餐时长 - 3分钟 -
    • -
    • - 购买 IP 量 - 1000个 -
    • -
    • - 实到 IP 量 - 1000个 -
    • -
    • - 原价 - ¥50 -
    • -
    -
    -

    - 实付价格 - ¥50 -

    -
    - - -
    - -
    - ) -} diff --git a/src/app/(temp)/pay/page.tsx b/src/app/(temp)/pay/page.tsx new file mode 100644 index 0000000..d6ca9d4 --- /dev/null +++ b/src/app/(temp)/pay/page.tsx @@ -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 ( +
    +
    + 模拟支付页 +
    + + 模拟支付成功 + +
    + ) +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/(dashboard)/page.tsx similarity index 96% rename from src/app/admin/dashboard/page.tsx rename to src/app/admin/(dashboard)/page.tsx index e32f7ff..537a1c1 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/(dashboard)/page.tsx @@ -3,7 +3,7 @@ export type DashboardPageProps = {} export default async function DashboardPage(props: DashboardPageProps) { return ( -
    +
    {/* banner */}
    diff --git a/src/app/admin/extract/page.tsx b/src/app/admin/extract/page.tsx new file mode 100644 index 0000000..64972a5 --- /dev/null +++ b/src/app/admin/extract/page.tsx @@ -0,0 +1,10 @@ +import {ReactNode} from 'react' + +export type ExtractPageProps = { +} + +export default async function ExtractPage(props: ExtractPageProps) { + return ( +
    + ) +} diff --git a/src/app/admin/identify/_assets/banner.webp b/src/app/admin/identify/_assets/banner.webp new file mode 100644 index 0000000..5beaa94 Binary files /dev/null and b/src/app/admin/identify/_assets/banner.webp differ diff --git a/src/app/admin/identify/_assets/personal.webp b/src/app/admin/identify/_assets/personal.webp new file mode 100644 index 0000000..431034f Binary files /dev/null and b/src/app/admin/identify/_assets/personal.webp differ diff --git a/src/app/admin/identify/page.tsx b/src/app/admin/identify/page.tsx new file mode 100644 index 0000000..6f69184 --- /dev/null +++ b/src/app/admin/identify/page.tsx @@ -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 ( +
    +
    + + {/* banner */} +
    + {`背景图`} +

    蓝狐HTTP邀请您参与【先测后买】服务

    +

    为了保障您的账户安全,请先完成实名认证,即可获取福利套餐测试资格

    +
    + +
    + {/* 个人认证 */} +
    + {`个人认证`}/ +
    +

    个人认证

    +

    平台授权支付宝安全认证,不会泄露您的认证信息

    +
    + +
    +
    +
    + +
    +

    操作引导

    +

    为响应国家相关规定,使用HTTP代理需完成实名认证。认证服务由支付宝提供,您的个人信息将受到严格保护,仅用于账户安全认证

    +
    +
    + ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index fc27305..03f34a8 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 ( -
    +
    {/* background */} -
    +
    +
    {/* 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`, )}> - - - - - - - - - + - - + + + + + + + + + + {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} + {props.icon} {props.label} ) diff --git a/src/app/admin/purchase/page.tsx b/src/app/admin/purchase/page.tsx new file mode 100644 index 0000000..5089fab --- /dev/null +++ b/src/app/admin/purchase/page.tsx @@ -0,0 +1,11 @@ +import Purchase from '@/components/composites/purchase' + +export type PurchasePageProps = {} + +export default async function PurchasePage(props: PurchasePageProps) { + return ( +
    + +
    + ) +} diff --git a/src/app/admin/resources/page.tsx b/src/app/admin/resources/page.tsx new file mode 100644 index 0000000..675bbfc --- /dev/null +++ b/src/app/admin/resources/page.tsx @@ -0,0 +1,12 @@ +import {ReactNode} from 'react' + +export type ResourcesPageProps = { +} + +export default async function ResourcesPage(props: ResourcesPageProps) { + return ( +
    + +
    + ) +} diff --git a/src/app/globals.css b/src/app/globals.css index f3553f2..3a65dab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9b74e90..bb20d79 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} - + + {children} + + ) diff --git a/public/product/alipay.svg b/src/components/composites/_assets/alipay.svg similarity index 100% rename from public/product/alipay.svg rename to src/components/composites/_assets/alipay.svg diff --git a/src/components/composites/_assets/balance.svg b/src/components/composites/_assets/balance.svg new file mode 100644 index 0000000..e08ce1c --- /dev/null +++ b/src/components/composites/_assets/balance.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/product/banner.webp b/src/components/composites/_assets/banner.webp similarity index 100% rename from public/product/banner.webp rename to src/components/composites/_assets/banner.webp diff --git a/public/product/check.svg b/src/components/composites/_assets/check.svg similarity index 100% rename from public/product/check.svg rename to src/components/composites/_assets/check.svg diff --git a/public/product/wechat.svg b/src/components/composites/_assets/wechat.svg similarity index 100% rename from public/product/wechat.svg rename to src/components/composites/_assets/wechat.svg diff --git a/src/components/composites/_client/center.tsx b/src/components/composites/_client/center.tsx new file mode 100644 index 0000000..364fdca --- /dev/null +++ b/src/components/composites/_client/center.tsx @@ -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 ( +
    + + {/* 计费方式 */} + + {({id, field}) => ( + + + + + + + + )} + + + {/* IP 时效 */} + + {({id, field}) => ( + + + + + + + + + )} + + + {/* 根据套餐类型显示不同表单项 */} + {watchType === '2' ? ( + /* 包量:IP 购买数量 */ + + {({id, field}) => ( +
    + + + +
    + )} +
    + ) : ( + <> + {/* 包时:套餐时效 */} + + {({id, field}) => ( + + + + + + + + + + )} + + + {/* 包时:每日提取上限 */} + + {({id, field}) => ( +
    + + + +
    + )} +
    + + )} + + {/* 产品特性 */} +
    +

    产品特性

    +
    +

    + {`check`} + 支持高并发提取 +

    +

    + {`check`} + 指定省份、城市或混播 +

    +

    + {`check`} + 账密+白名单验证 +

    +

    + {`check`} + 完备的API接口 +

    +

    + {`check`} + IP时效3-30分钟(可定制) +

    +

    + {`check`} + IP资源定期筛选 +

    +

    + {`check`} + 完备的API接口 +

    +

    + {`check`} + 包量/包时计费方式 +

    +

    + {`check`} + 每日去重量:500万 +

    +
    +
    + + {/* 左右的边框 */} +
    +
    + ) +} diff --git a/src/components/composites/_client/form.tsx b/src/components/composites/_client/form.tsx new file mode 100644 index 0000000..c446538 --- /dev/null +++ b/src/components/composites/_client/form.tsx @@ -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 + +type PurchaseFormContextType = { + form: UseFormReturn +} + +export const PurchaseFormContext = createContext(undefined) + +export type PurchaseFormProps = {} + +export default function PurchaseForm(props: PurchaseFormProps) { + console.log('PurchaseForm render') + const authCtx = useContext(AuthContext) + + const form = useForm({ + 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 ( +
    +
    + + +
    + + + +
    + ) +} + diff --git a/src/components/composites/_client/left.tsx b/src/components/composites/_client/left.tsx new file mode 100644 index 0000000..c279f5c --- /dev/null +++ b/src/components/composites/_client/left.tsx @@ -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 ( +
    + {`banner`} +

    包量套餐

    +
      + + + + + +
    +
    +

    包时套餐

    +
      +
    • + 7天 + 9折 +
    • +
    • + 30天 + 8折 +
    • +
    • + 90天 + 7折 +
    • +
    • + 180天 + 6折 +
    • +
    • + 360天 + 5折 +
    • +
    +
    + ) +} + +function Combo(props: { + name: string + level?: { + number: number + discount: number + }[] +}) { + + const [open, setOpen] = useState(false) + + return ( +
  • +

    + {props.name} + +

    + {props.level && ( +
      + {props.level.map((item, index) => ( +
    • + {item.number} + 赠送 {item.discount} % +
    • + ))} +
    + )} +
  • + ) +} + diff --git a/src/components/composites/_client/option.tsx b/src/components/composites/_client/option.tsx new file mode 100644 index 0000000..3a03536 --- /dev/null +++ b/src/components/composites/_client/option.tsx @@ -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 <> + + {props.children ? props.children : <> + {props.label} + {props.description &&

    {props.description}

    } + } +
    + + +} diff --git a/src/components/composites/_client/recharge.tsx b/src/components/composites/_client/recharge.tsx new file mode 100644 index 0000000..9b51203 --- /dev/null +++ b/src/components/composites/_client/recharge.tsx @@ -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 + +export type RechargeModelProps = {} + +export default function RechargeModal(props: RechargeModelProps) { + + const form = useForm({ + 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 ( + + + + + + + + 充值中心 + + +
    + + {/* 充值额度 */} + name={`amount`} label={`充值额度`} className={`flex flex-col gap-4`}> + {({id, field}) => ( + field.onChange(Number(v))} + className={`flex flex-col gap-2`}> + +
    + + + +
    + +
    + + + +
    +
    + )} + + + {/* 支付方式 */} + + {({id, field}) => ( + + + {`支付宝 + 支付宝 + + + {`微信 + 微信 + + + )} + + + + + + +
    +
    + ) +} diff --git a/src/components/composites/_client/right.tsx b/src/components/composites/_client/right.tsx new file mode 100644 index 0000000..ace4388 --- /dev/null +++ b/src/components/composites/_client/right.tsx @@ -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 ( +
    +

    订单详情

    +
      +
    • + 套餐名称 + + {watchType === '2' ? `包量套餐` : `包时套餐`} + +
    • +
    • + IP 时效 + + {watchLive}分钟 + +
    • + {watchType === '2' ? ( +
    • + 购买 IP 量 + + {watchQuota}个 + +
    • + ) : <> +
    • + 套餐时长 + + {watchExpire}天 + +
    • +
    • + 每日限额 + + {watchDailyLimit}个 + +
    • + } +
    +
    +

    + 价格 + ¥-- +

    + + {({id, field}) => ( + +
    +

    + {`余额icon`}/ + 账户余额 +

    +

    + {profile?.balance} + +

    + +
    + + {`余额 + 余额 + + + {`微信 + 微信 + + + {`支付宝 + 支付宝 + +
    + )} +
    + +
    + ) +} + diff --git a/src/components/composites/purchase.tsx b/src/components/composites/purchase.tsx new file mode 100644 index 0000000..4299747 --- /dev/null +++ b/src/components/composites/purchase.tsx @@ -0,0 +1,27 @@ +import PurchaseForm from '@/components/composites/_client/form' + +export type PurchaseProps = {} + +export default async function Purchase(props: PurchaseProps) { + + return ( +
    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    + +
    + ) +} + diff --git a/src/components/providers/AuthProvider.tsx b/src/components/providers/AuthProvider.tsx new file mode 100644 index 0000000..5c9b914 --- /dev/null +++ b/src/components/providers/AuthProvider.tsx @@ -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 +} + +export const AuthContext = createContext({ + profile: null, + refreshProfile: async () => { + throw new Error('Not implemented') + }, +}) + +export type ProfileProviderProps = { + children: ReactNode +} + +export default function AuthProvider(props: ProfileProviderProps) { + + const [profile, setProfile] = useState(null) + + const refreshProfile = async () => { + setProfile(await getProfile(true)) + } + + useEffect(() => { + refreshProfile().then() + }, []) + + return ( + + {props.children} + + ) +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 946ddff..32c9040 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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, )} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 3b78b66..fd22eb8 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -28,7 +28,11 @@ function Form(rawProps: FormProps) { return ( -
    + { + event.preventDefault() + form.handleSubmit(onSubmit)(event) + event.stopPropagation() + }}> {children}
    diff --git a/src/lib/api.ts b/src/lib/api.ts index f370e09..50ea461 100644 --- a/src/lib/api.ts +++ b/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 { - 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(endpoint: string, data: unknown): Promise> { - 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 { - // 从 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( - endpoint: string, - data: unknown, -): Promise> { - 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(response: Response): Promise> { - - // 解析响应数据 - 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 = { +type ApiResponse = { success: false status: number message: string @@ -280,11 +15,21 @@ export type ApiResponse = { data: T } -export type PageRecord = { +type PageRecord = { 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, +} diff --git a/src/lib/models.ts b/src/lib/models.ts new file mode 100644 index 0000000..64118ce --- /dev/null +++ b/src/lib/models.ts @@ -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, +}