重构认证逻辑,优化登录和用户信息获取流程,新增全局缓存支持
This commit is contained in:
@@ -4,18 +4,10 @@
|
||||
|
||||
使用 pure js 的包代替 canvas,加快编译速度
|
||||
|
||||
重新设计验证逻辑,通过全局 cache 优化请求效率,使用服务端组件实现验证
|
||||
|
||||
提取后刷新提取页套餐可用余量
|
||||
|
||||
提取时检查 IP 和实名状态
|
||||
|
||||
保存客户端信息时用 jwt 序列化
|
||||
|
||||
登录后刷新 profile
|
||||
|
||||
区分调用方式,提供 callByDevice callByUser call 三种调用方式
|
||||
|
||||
---
|
||||
|
||||
页面数据:
|
||||
|
||||
@@ -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
108
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -68,6 +68,8 @@ export default function BillsPage(props: BillsPageProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log('init bill list')
|
||||
refresh(1, 10).then()
|
||||
refresh(1, 10).then()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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)
|
||||
}}>
|
||||
已完成认证
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' ? '确认支付' : '已完成支付'}
|
||||
|
||||
@@ -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(`充值失败`, {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
43
src/components/providers/StoreProvider.tsx
Normal file
43
src/components/providers/StoreProvider.tsx
Normal 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)
|
||||
}
|
||||
25
src/stores/profile-store.ts
Normal file
25
src/stores/profile-store.ts
Normal 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})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user