重构认证逻辑,优化登录和用户信息获取流程,新增全局缓存支持

This commit is contained in:
2025-04-23 19:00:53 +08:00
parent 22d3b8f3e3
commit 9473413def
23 changed files with 438 additions and 474 deletions

View File

@@ -4,18 +4,10 @@
使用 pure js 的包代替 canvas加快编译速度
重新设计验证逻辑,通过全局 cache 优化请求效率,使用服务端组件实现验证
提取后刷新提取页套餐可用余量
提取时检查 IP 和实名状态
保存客户端信息时用 jwt 序列化
登录后刷新 profile
区分调用方式,提供 callByDevice callByUser call 三种调用方式
---
页面数据:

View File

@@ -28,7 +28,7 @@
"date-fns": "^4.1.0",
"lucide-react": "^0.479.0",
"motion": "^12.5.0",
"next": "15.2.1",
"next": "15.2.4",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "^19.0.0",
@@ -38,7 +38,8 @@
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

108
pnpm-lock.yaml generated
View File

@@ -66,8 +66,8 @@ importers:
specifier: ^12.5.0
version: 12.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next:
specifier: 15.2.1
version: 15.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.2.4
version: 15.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -98,6 +98,9 @@ importers:
zod:
specifier: ^3.24.2
version: 3.24.2
zustand:
specifier: ^5.0.3
version: 5.0.3(@types/react@19.0.10)(react@19.0.0)
devDependencies:
'@eslint/eslintrc':
specifier: ^3
@@ -339,60 +342,60 @@ packages:
cpu: [x64]
os: [win32]
'@next/env@15.2.1':
resolution: {integrity: sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==}
'@next/env@15.2.4':
resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==}
'@next/eslint-plugin-next@15.2.1':
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
'@next/swc-darwin-arm64@15.2.1':
resolution: {integrity: sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==}
'@next/swc-darwin-arm64@15.2.4':
resolution: {integrity: sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.2.1':
resolution: {integrity: sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==}
'@next/swc-darwin-x64@15.2.4':
resolution: {integrity: sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.1':
resolution: {integrity: sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==}
'@next/swc-linux-arm64-gnu@15.2.4':
resolution: {integrity: sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.2.1':
resolution: {integrity: sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==}
'@next/swc-linux-arm64-musl@15.2.4':
resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.2.1':
resolution: {integrity: sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==}
'@next/swc-linux-x64-gnu@15.2.4':
resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.2.1':
resolution: {integrity: sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==}
'@next/swc-linux-x64-musl@15.2.4':
resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.2.1':
resolution: {integrity: sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==}
'@next/swc-win32-arm64-msvc@15.2.4':
resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.2.1':
resolution: {integrity: sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==}
'@next/swc-win32-x64-msvc@15.2.4':
resolution: {integrity: sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2136,8 +2139,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.2.1:
resolution: {integrity: sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==}
next@15.2.4:
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@@ -2715,6 +2718,24 @@ packages:
zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
zustand@5.0.3:
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -2876,34 +2897,34 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
'@next/env@15.2.1': {}
'@next/env@15.2.4': {}
'@next/eslint-plugin-next@15.2.1':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.2.1':
'@next/swc-darwin-arm64@15.2.4':
optional: true
'@next/swc-darwin-x64@15.2.1':
'@next/swc-darwin-x64@15.2.4':
optional: true
'@next/swc-linux-arm64-gnu@15.2.1':
'@next/swc-linux-arm64-gnu@15.2.4':
optional: true
'@next/swc-linux-arm64-musl@15.2.1':
'@next/swc-linux-arm64-musl@15.2.4':
optional: true
'@next/swc-linux-x64-gnu@15.2.1':
'@next/swc-linux-x64-gnu@15.2.4':
optional: true
'@next/swc-linux-x64-musl@15.2.1':
'@next/swc-linux-x64-musl@15.2.4':
optional: true
'@next/swc-win32-arm64-msvc@15.2.1':
'@next/swc-win32-arm64-msvc@15.2.4':
optional: true
'@next/swc-win32-x64-msvc@15.2.1':
'@next/swc-win32-x64-msvc@15.2.4':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -4735,9 +4756,9 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
next@15.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.1
'@next/env': 15.2.4
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@@ -4747,14 +4768,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.1
'@next/swc-darwin-x64': 15.2.1
'@next/swc-linux-arm64-gnu': 15.2.1
'@next/swc-linux-arm64-musl': 15.2.1
'@next/swc-linux-x64-gnu': 15.2.1
'@next/swc-linux-x64-musl': 15.2.1
'@next/swc-win32-arm64-msvc': 15.2.1
'@next/swc-win32-x64-msvc': 15.2.1
'@next/swc-darwin-arm64': 15.2.4
'@next/swc-darwin-x64': 15.2.4
'@next/swc-linux-arm64-gnu': 15.2.4
'@next/swc-linux-arm64-musl': 15.2.4
'@next/swc-linux-x64-gnu': 15.2.4
'@next/swc-linux-x64-musl': 15.2.4
'@next/swc-win32-arm64-msvc': 15.2.4
'@next/swc-win32-x64-msvc': 15.2.4
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
@@ -5435,3 +5456,8 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.24.2: {}
zustand@5.0.3(@types/react@19.0.10)(react@19.0.0):
optionalDependencies:
'@types/react': 19.0.10
react: 19.0.0

View File

@@ -1,62 +1,57 @@
'use server'
import {cookies} from 'next/headers'
import {ApiResponse} from '@/lib/api'
import {ApiResponse, UnauthorizedError} from '@/lib/api'
import {AuthContext} from '@/lib/auth'
import {User} from '@/lib/models'
import {callByDevice, callByUser, getUserToken} from '@/actions/base'
import {callByDevice, callByUser, callPublic, getUserToken} from '@/actions/base'
import {redirect} from 'next/navigation'
import {cache} from 'react'
export interface LoginParams {
username: string
password: string
remember?: boolean
remember: boolean
}
type LoginResp = {
access_token: string
refresh_token: string
expires: number
auth: AuthContext
profile: User
expires_in: number
token_type: string
scope?: string
}
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,
const result = await callByDevice<LoginResp>('/api/auth/token', {
...props,
grant_type: 'password',
login_type: 'phone_code',
})
if (!result.success) {
return result
}
const data = result.data
// 保存到 cookies
const current = Math.floor(Date.now() / 1000)
const future = data.expires - current
const data = result.data
const cookieStore = await cookies()
cookieStore.set('auth_token', data.access_token, {
httpOnly: true,
sameSite: 'strict',
maxAge: Math.max(future, 0),
maxAge: Math.max(data.expires_in, 0),
})
cookieStore.set('auth_refresh', data.refresh_token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 3600,
})
cookieStore.set('auth_info', JSON.stringify(data.auth), {
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 3600,
})
cookieStore.set('auth_profile', JSON.stringify(data.profile), {
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 3600,
})
// cookieStore.set('auth_info', JSON.stringify(data.auth), {
// httpOnly: true,
// sameSite: 'strict',
// })
// cookieStore.set('auth_profile', JSON.stringify(data.profile), {
// httpOnly: true,
// sameSite: 'strict',
// })
return {
success: true,
@@ -88,52 +83,27 @@ export async function logout() {
sameSite: 'strict',
maxAge: -1,
})
cookieStore.set('auth_info', '', {
httpOnly: true,
sameSite: 'strict',
maxAge: -1,
})
cookieStore.set('auth_profile', '', {
httpOnly: true,
sameSite: 'strict',
maxAge: -1,
})
return redirect('/')
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
export async function getProfile() {
try {
token = await getUserToken()
const token = await getUserToken()
const result = await callPublic<User>('/api/user/get/token', {token})
if (!result.success) {
throw new Error('获取用户信息失败')
}
return result.data
}
catch (e) {
return null
if (e === UnauthorizedError) {
return null
}
throw e
}
// 如果没有缓存,则请求用户信息
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',
maxAge: 7 * 24 * 3600,
})
return result.data
}

View File

@@ -2,106 +2,52 @@
import {API_BASE_URL, CLIENT_ID, CLIENT_SECRET, ApiResponse, UnauthorizedError} from '@/lib/api'
import {cookies, headers} from 'next/headers'
import {redirect} from 'next/navigation'
// OAuth令牌缓存
interface TokenCache {
token: string
expires: number // 过期时间戳
}
import {cache} from 'react'
// ======================
// 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('认证服务暂时不可用')
}
async function callByDevice<R = undefined>(
endpoint: string,
data: unknown,
): Promise<ApiResponse<R>> {
return _callByDevice(endpoint, data ? JSON.stringify(data) : undefined)
}
// 通用的API调用函数
async function callByDevice<R = undefined>(endpoint: string, data: unknown): Promise<ApiResponse<R>> {
const _callByDevice = cache(async <R = undefined>(
endpoint: string,
data?: string,
): Promise<ApiResponse<R>> => {
try {
// 发送请求
let accessToken = getDeviceToken()
const requestOptions = {
// 获取设备令牌
if (!CLIENT_ID || !CLIENT_SECRET) {
throw new Error('缺少CLIENT_ID或CLIENT_SECRET环境变量')
}
const token = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
// 构造请求
const url = `${API_BASE_URL}${endpoint}`
const request = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await accessToken}`,
'Authorization': `Basic ${token}`,
},
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)
body: data,
}
// 检查响应状态
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, await response.text())
return {
success: false,
status: response.status,
message: '请求失败',
}
}
// 发送请求
const response = await fetch(`${API_BASE_URL}${endpoint}`, request)
// 检查响应状态
return handleResponse(response)
}
catch (e) {
console.error('API call failed:', e)
throw new Error('服务调用失败', {cause: e})
}
}
})
// endregion
@@ -172,26 +118,33 @@ async function callByUser<R = undefined>(
endpoint: string,
data?: unknown,
): Promise<ApiResponse<R>> {
return _callByUser(endpoint, data ? JSON.stringify(data) : undefined)
}
const _callByUser = cache(async <R = undefined>(
endpoint: string,
data?: string,
): Promise<ApiResponse<R>> => {
const header = await headers()
try {
let token = await getUserToken()
const header = await headers()
// 获取客户端 IP
const clientIp = header.get('x-forwarded-for')
// 发送请求
// 构造请求
const request = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
} as Record<string, string>,
body: data ? JSON.stringify(data) : undefined,
}
if (clientIp) {
request.headers['X-Forwarded-For'] = clientIp
body: data,
}
const userIp = header.get('x-forwarded-for')
if (userIp) {
request.headers['X-Forwarded-For'] = userIp
}
// 发送请求
let response = await fetch(`${API_BASE_URL}${endpoint}`, request)
if (response.status === 401) {
token = await getUserToken(true)
@@ -199,45 +152,25 @@ async function callByUser<R = undefined>(
response = await fetch(`${API_BASE_URL}${endpoint}`, request)
}
// 检查响应状态
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}`,
}
if (response.status === 401) {
throw UnauthorizedError
}
return handleResponse(response)
}
catch (e) {
console.error('API call with user token failed:', e)
if (e === UnauthorizedError) {
const referer = header.get('referer')
let redirectUrl = '/login'
if (referer) {
const url = new URL(referer)
redirectUrl = `/login?redirect=${encodeURIComponent(url.pathname)}`
}
return redirect(redirectUrl)
}
throw new Error('服务调用失败', {cause: e})
}
}
})
// endregion
@@ -250,43 +183,35 @@ async function callPublic<R = undefined>(
endpoint: string,
data?: unknown,
): Promise<ApiResponse<R>> {
return _callPublic(endpoint, data ? JSON.stringify(data) : undefined)
}
const _callPublic = cache(async <R = undefined>(
endpoint: string,
data?: string,
): Promise<ApiResponse<R>> => {
try {
// 发送请求
const requestOptions: RequestInit = {
const url = `${API_BASE_URL}${endpoint}`
const request: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: data ? JSON.stringify(data) : undefined,
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
// 检查响应状态
if (!response.ok) {
console.log('公共接口响应不成功', `status=${response.status}`, await response.text())
return {
status: response.status,
success: false,
message: response.status >= 500 ? '服务器错误' : '请求失败',
}
body: data,
}
const response = await fetch(url, request)
return handleResponse(response)
}
catch (e) {
console.error('Public API call failed:', e)
throw new Error('服务调用失败', { cause: 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()
@@ -313,9 +238,11 @@ async function handleResponse<R = undefined>(response: Response): Promise<ApiRes
message: text || '请求失败',
}
}
console.log('响应成功', `type=text`, `text=${text}`)
return {
success: true,
data: undefined as unknown as R, // 强转类型,考虑优化
data: undefined as R, // 强转类型,考虑优化
}
}
else {
@@ -327,7 +254,6 @@ async function handleResponse<R = undefined>(response: Response): Promise<ApiRes
// 导出
export {
getDeviceToken,
getUserToken,
callByDevice,
callByUser,

View File

@@ -21,12 +21,13 @@ import zod from 'zod'
import Captcha from './captcha'
import verify from '@/actions/auth/verify'
import {login} from '@/actions/auth/auth'
import {useRouter} from 'next/navigation'
import {useRouter, useSearchParams} 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'
import {useProfileStore} from '@/components/providers/StoreProvider'
export type LoginPageProps = {}
@@ -157,30 +158,27 @@ export default function LoginPage(props: LoginPageProps) {
// 调用登录函数
const result = await login({
username: values.username,
password: values.password, // 使用验证码作为密码
password: values.password,
remember: values.remember,
})
if (result.success) {
// 登录成功
toast.success('登录成功', {
description: '欢迎回来!',
})
// 跳转到首页或用户仪表板
router.push('/')
router.refresh() // 刷新页面状态
}
else {
// 登录失败
toast.error(result.message, {
// 登录失败
if (!result.success) {
return toast.error(result.message, {
description: '请检查您的手机号码和验证码',
})
}
// 登录成功
await refreshProfile()
router.push(redirect || '/')
toast.success('登录成功', {
description: '欢迎回来!',
})
}
catch (e) {
toast.error('服务器错误', {
description: '请稍后再试',
toast.error('登录错误', {
description: (e as Error).message,
})
}
finally {
@@ -188,6 +186,19 @@ export default function LoginPage(props: LoginPageProps) {
}
}
// ======================
// 重定向
// ======================
const params = useSearchParams()
const redirect = params.get('redirect')
const refreshProfile = useProfileStore(store=>store.refreshProfile)
// ======================
// render
// ======================
return (
<main className={merge(
`relative`,

View File

@@ -1,5 +1,5 @@
'use client'
import {createContext, ReactNode, useCallback, useEffect, useMemo, useState} from 'react'
import {createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState} from 'react'
import Link from 'next/link'
import Image from 'next/image'
import {LinkItem, MenuItem} from './navs'
@@ -8,14 +8,14 @@ import ProductMenu from './product'
import HelpMenu from './help'
import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp'
import {Button} from '@/components/ui/button'
import {useProfileStore} from '@/components/providers/StoreProvider'
export const HeaderContext = createContext<{
setMenu: (value: boolean) => void
} | null>(null)
export type ProviderProps = {
userCenter: ReactNode
}
export type ProviderProps = {}
export default function Provider(props: ProviderProps) {
@@ -51,7 +51,13 @@ export default function Provider(props: ProviderProps) {
], [])
// ======================
// 渲染组件
// 用户信息
// ======================
const profile = useProfileStore(store=>store.profile)
// ======================
// render
// ======================
return (
@@ -116,7 +122,35 @@ export default function Provider(props: ProviderProps) {
</nav>
</div>
{/* 登录 */}
{props.userCenter}
<div className={`flex items-center`}>
{profile == undefined
? <>
<Link
href="/login"
className={`w-24 h-12 flex items-center justify-center lg:text-lg`}
>
<span></span>
</Link>
<Link
href="/login"
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</>
: (
<Link href={`/admin`}>
<Button theme={`gradient`}>
</Button>
</Link>
)
}
</div>
</Wrap>
</div>

View File

@@ -1,45 +0,0 @@
import Link from 'next/link'
import {cookies} from 'next/headers'
import {Button} from '@/components/ui/button'
import {AuthContext} from '@/lib/auth'
export type UserCenterProps = {}
export default async function UserCenter(props: UserCenterProps) {
const store = await cookies()
const info = store.get('auth_info')?.value
const data = info ? JSON.parse(info) as AuthContext : undefined
return (
<div className={`flex items-center`}>
{data == undefined
? <>
<Link
href="/login"
className={`w-24 h-12 flex items-center justify-center lg:text-lg`}
>
<span></span>
</Link>
<Link
href="/login"
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(' ')}
>
<span></span>
</Link>
</>
: (
<Link href={`/admin`}>
<Button theme={`gradient`}>
</Button>
</Link>
)
}
</div>
)
}

View File

@@ -1,14 +1,11 @@
import Provider from '@/app/(root)/@header/_client/provider'
import UserCenter from '@/app/(root)/@header/_server/user-center'
export type HeaderProps = {}
export default async function Header(props: HeaderProps) {
return (
<header className={`fixed top-0 w-full z-10`}>
<Provider
userCenter={<UserCenter/>}
/>
<Provider/>
</header>
)
}

View File

@@ -10,12 +10,6 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
export type DashboardPageProps = {}
export default async function DashboardPage(props: DashboardPageProps) {
const profile = await getProfile()
if (!profile) {
return redirect('/login')
}
return (
<Page className={`flex-auto grid grid-cols-4 grid-rows-[150px_minmax(200px,1fr)_minmax(200px,1fr)_minmax(200px,1fr)]`}>
{/* banner */}

View File

@@ -1,17 +1,33 @@
import {cookies} from 'next/headers'
'use client'
import {Button} from '@/components/ui/button'
import {logout} from '@/actions/auth/auth'
import {useProfileStore} from '@/components/providers/StoreProvider'
import {useRouter} from 'next/navigation'
import {toast} from 'sonner'
export type ProfileProps = {}
export default async function Profile(props: ProfileProps) {
const store = await cookies()
const info = store.get('auth_info')?.value
const data = info ? JSON.parse(info) : undefined
export default function Profile(props: ProfileProps) {
const refreshProfile = useProfileStore(store => store.refreshProfile)
const router = useRouter()
const doLogout = async () => {
try {
const resp = await logout()
if (resp.success) {
await refreshProfile()
router.push('/')
}
}
catch (e) {
toast.error('退出登录失败', {
description: (e as Error).message,
})
}
}
return (
<div className="flex gap-2 items-center">
<span>{data?.payload.name}</span>
<Button theme={`error`} onClick={logout}>
<Button theme={`error`} onClick={doLogout}>
退
</Button>
</div>

View File

@@ -68,6 +68,8 @@ export default function BillsPage(props: BillsPageProps) {
}
useEffect(() => {
console.log('init bill list')
refresh(1, 10).then()
refresh(1, 10).then()
}, [])

View File

@@ -13,7 +13,7 @@ import {Identify} from '@/actions/auth/identify'
import {toast} from 'sonner'
import {useContext, useEffect, useRef, useState} from 'react'
import * as qrcode from 'qrcode'
import {AuthContext} from '@/components/providers/AuthProvider'
import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider'
export type IdentifyPageProps = {}
@@ -90,8 +90,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
// 用户数据
// ======================
const ctx = useContext(AuthContext)
console.log('render identify page')
const profile = useProfileStore(store => store.profile)
const refreshProfile = useProfileStore(store => store.refreshProfile)
// ======================
// render
@@ -116,7 +116,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<h3 className={`text-center text-lg font-bold`}></h3>
<p className={`text-sm text-gray-600`}></p>
</div>
{ctx.profile?.id_token ? (
{profile?.id_token ? (
<div className={`flex flex-col gap-4`}>
<p className={`text-sm text-gray-600`}></p>
</div>
@@ -162,7 +162,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<canvas ref={canvas} width={256} height={256}/>
<p className={`text-sm text-gray-600`}></p>
<Button onClick={async () => {
await ctx.refreshProfile()
await refreshProfile()
setOpenDialog(false)
}}>

View File

@@ -4,8 +4,8 @@ import logo from '@/assets/logo.webp'
import Profile from '@/app/admin/_server/profile'
import {merge} from '@/lib/utils'
import Link from 'next/link'
import {getProfile} from '@/actions/auth/auth'
import {redirect} from 'next/navigation'
import {getProfile} from '@/actions/auth/auth'
export type DashboardLayoutProps = {
children: ReactNode
@@ -13,9 +13,9 @@ export type DashboardLayoutProps = {
export default async function DashboardLayout(props: DashboardLayoutProps) {
const profile = await getProfile()
if (!profile) {
return redirect('/login')
const user = await getProfile()
if (!user) {
return redirect(`/login?redirect=${encodeURIComponent('/admin')}`)
}
return (

View File

@@ -9,7 +9,7 @@ import {Input} from '@/components/ui/input'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod'
import {AuthContext} from '@/components/providers/AuthProvider'
import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {toast} from 'sonner'
import {Alert, AlertDescription, AlertTitle} from '@/components/ui/alert'
@@ -22,8 +22,8 @@ import {User} from '@/lib/models'
export type ProfilePageProps = {}
export default function ProfilePage(props: ProfilePageProps) {
const authCtx = useContext(AuthContext)
const profile = authCtx.profile
const profile = useProfileStore(store => store.profile)
const refreshProfile = useProfileStore(store => store.refreshProfile)
// 默认选中的Tab
const [activeTab, setActiveTab] = useState('basic')
@@ -55,19 +55,19 @@ export default function ProfilePage(props: ProfilePageProps) {
</TabsList>
<TabsContent value="basic">
<BasicInfoTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
<BasicInfoTab profile={profile} refreshProfile={refreshProfile}/>
</TabsContent>
<TabsContent value="security">
<SecurityTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
<SecurityTab profile={profile} refreshProfile={refreshProfile}/>
</TabsContent>
<TabsContent value="balance">
<BalanceTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
<BalanceTab profile={profile} refreshProfile={refreshProfile}/>
</TabsContent>
<TabsContent value="identify">
<IdentifyTab profile={profile} refreshProfile={authCtx.refreshProfile}/>
<IdentifyTab profile={profile} refreshProfile={refreshProfile}/>
</TabsContent>
</Tabs>
</div>

View File

@@ -3,7 +3,7 @@ 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 StoreProvider from '@/components/providers/StoreProvider'
import {getProfile} from '@/actions/auth/auth'
const font = localFont({
@@ -20,12 +20,15 @@ export default async function RootLayout({
}: Readonly<{
children: ReactNode
}>) {
const user = await getProfile()
return (
<html lang="zh-Cn">
<body className={`${font.className}`}>
<AuthProvider>
<StoreProvider user={user}>
{children}
</AuthProvider>
</StoreProvider>
<Toaster position={'top-center'} richColors expand/>
</body>
</html>

View File

@@ -10,7 +10,7 @@ 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'
import {StoreContext} from '@/components/providers/StoreProvider'
// 定义表单验证架构
const schema = z.object({

View File

@@ -6,7 +6,7 @@ import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import Image from 'next/image'
import {useContext, useRef, useState} from 'react'
import {AuthContext} from '@/components/providers/AuthProvider'
import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider'
import {Alert, AlertDescription} from '@/components/ui/alert'
import {
prepareResourceByAlipay,
@@ -32,7 +32,9 @@ export type PayProps = {
export default function Pay(props: PayProps) {
const ctx = useContext(AuthContext)
const profile = useProfileStore(store=>store.profile)
const refreshProfile = useProfileStore(store=>store.refreshProfile)
const [open, setOpen] = useState(false)
const [payInfo, setPayInfo] = useState<CreateResourceResp | undefined>()
const canvas = useRef<HTMLCanvasElement>(null)
@@ -97,7 +99,7 @@ export default function Pay(props: PayProps) {
})
setOpen(false)
await ctx.refreshProfile()
await refreshProfile()
}
catch (e) {
console.log(e)
@@ -133,12 +135,12 @@ export default function Pay(props: PayProps) {
</DialogHeader>
{props.method === 'balance' ? (
ctx.profile && (
profile && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
<span className={`text-lg`}>{ctx.profile.balance}</span>
<span className={`text-lg`}>{profile.balance}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
@@ -147,13 +149,13 @@ export default function Pay(props: PayProps) {
<hr className="my-2"/>
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
<span className={`text-lg ${ctx.profile.balance > props.amount ? 'text-done' : `text-fail`}`}>
{ctx.profile.balance - props.amount}
<span className={`text-lg ${profile.balance > props.amount ? 'text-done' : `text-fail`}`}>
{profile.balance - props.amount}
</span>
</div>
</div>
{ctx.profile.balance < props.amount && (
{profile.balance < props.amount && (
<Alert variant="fail">
<AlertDescription>
@@ -161,7 +163,7 @@ export default function Pay(props: PayProps) {
</Alert>
)}
{ctx.profile.balance >= props.amount && (
{profile.balance >= props.amount && (
<Alert>
<AlertDescription>
@@ -200,7 +202,7 @@ export default function Pay(props: PayProps) {
<DialogFooter>
<Button
type="button"
disabled={props.method === 'balance' && !!ctx.profile && ctx.profile.balance < props.amount}
disabled={props.method === 'balance' && !!profile && profile.balance < props.amount}
onClick={onSubmit}
>
{props.method === 'balance' ? '确认支付' : '已完成支付'}

View File

@@ -21,7 +21,7 @@ import {useContext, useRef, useState} from 'react'
import {Loader} from 'lucide-react'
import {RechargeByAlipay, RechargeByAlipayConfirm, RechargeByWechat, RechargeByWechatConfirm} from '@/actions/user'
import * as qrcode from 'qrcode'
import {AuthContext} from '@/components/providers/AuthProvider'
import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider'
const schema = zod.object({
method: zod.enum(['alipay', 'wechat']),
@@ -53,7 +53,7 @@ export default function RechargeModal(props: RechargeModelProps) {
pay_url: string
}>()
const ctx = useContext(AuthContext)
const refreshProfile = useProfileStore(store => store.refreshProfile)
const createRecharge = async (data: Schema) => {
try {
@@ -124,7 +124,7 @@ export default function RechargeModal(props: RechargeModelProps) {
}
toast.success(`充值成功`)
closeDialog()
await ctx.refreshProfile()
await refreshProfile()
}
catch (e) {
toast.error(`充值失败`, {

View File

@@ -8,15 +8,16 @@ import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg'
import {AuthContext} from '@/components/providers/AuthProvider'
import {useProfileStore} from '@/components/providers/StoreProvider'
import RechargeModal from '@/components/composites/purchase/_client/recharge'
import Pay from '@/components/composites/purchase/_client/pay'
import {Button, buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
export type RightProps = {}
export default function Right(props: RightProps) {
const authCtx = useContext(AuthContext)
const profile = authCtx.profile
const profile = useProfileStore(store => store.profile)
const form = useContext(PurchaseFormContext)?.form
if (!form) {
@@ -89,58 +90,65 @@ export default function Right(props: RightProps) {
<span></span>
<span className={`text-xl text-orange-500`}>{price}</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>
{profile ? <>
<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>
<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>
<Pay method={payType} amount={price} resource={{
type: Number(watchType),
live: Number(watchLive) * 60,
quota: watchQuota,
expire: Number(watchExpire),
daily_limit: watchDailyLimit,
}}/>
<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>
<Pay method={payType} amount={price} resource={{
type: Number(watchType),
live: Number(watchLive) * 60,
quota: watchQuota,
expire: Number(watchExpire),
daily_limit: watchDailyLimit,
}}/>
</> : (
<Link href={`/login`} className={buttonVariants()}>
</Link>
)}
</div>
)
}

View File

@@ -1,41 +0,0 @@
'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

@@ -0,0 +1,43 @@
'use client'
import {User} from '@/lib/models'
import {createContext, ReactNode, useContext, useRef} from 'react'
import {createProfileStore, ProfileStore} from '@/stores/profile-store'
import {StoreApi} from 'zustand/vanilla'
import {useStore} from 'zustand/react'
export type StoreContextType = {
profile: StoreApi<ProfileStore>
}
export const StoreContext = createContext<StoreContextType | null>(null)
export type ProfileProviderProps = {
user: User | null
children: ReactNode
}
export default function StoreProvider(props: ProfileProviderProps) {
const profile = useRef<StoreApi<ProfileStore>>(null)
if (!profile.current) {
console.log('create profile store')
profile.current = createProfileStore(props.user)
}
return (
<StoreContext.Provider value={{
profile: profile.current,
}}>
{props.children}
</StoreContext.Provider>
)
}
export function useProfileStore<T>(selector: (store: ProfileStore) => T) {
const ctx = useContext(StoreContext)
if (!ctx) {
throw new Error('useProfileStore must be used within a StoreProvider')
}
return useStore(ctx.profile, selector)
}

View File

@@ -0,0 +1,25 @@
import {User} from '@/lib/models'
import {createStore} from 'zustand/vanilla'
import {getProfile} from '@/actions/auth/auth'
export type ProfileStore = ProfileState & ProfileActions
export type ProfileState = {
profile: User | null
}
export type ProfileActions = {
refreshProfile: () => Promise<void>
}
export const createProfileStore = (init: User|null) => {
return createStore<ProfileStore>()(setState => ({
profile: init,
refreshProfile: async () => {
const profile = await getProfile()
setState({profile})
},
}))
}