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

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

View File

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

108
pnpm-lock.yaml generated
View File

@@ -66,8 +66,8 @@ importers:
specifier: ^12.5.0 specifier: ^12.5.0
version: 12.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 12.5.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: next:
specifier: 15.2.1 specifier: 15.2.4
version: 15.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 15.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 0.4.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -98,6 +98,9 @@ importers:
zod: zod:
specifier: ^3.24.2 specifier: ^3.24.2
version: 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: devDependencies:
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3 specifier: ^3
@@ -339,60 +342,60 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@next/env@15.2.1': '@next/env@15.2.4':
resolution: {integrity: sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==} resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==}
'@next/eslint-plugin-next@15.2.1': '@next/eslint-plugin-next@15.2.1':
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==} resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
'@next/swc-darwin-arm64@15.2.1': '@next/swc-darwin-arm64@15.2.4':
resolution: {integrity: sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==} resolution: {integrity: sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@15.2.1': '@next/swc-darwin-x64@15.2.4':
resolution: {integrity: sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==} resolution: {integrity: sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.1': '@next/swc-linux-arm64-gnu@15.2.4':
resolution: {integrity: sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==} resolution: {integrity: sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-arm64-musl@15.2.1': '@next/swc-linux-arm64-musl@15.2.4':
resolution: {integrity: sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==} resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-linux-x64-gnu@15.2.1': '@next/swc-linux-x64-gnu@15.2.4':
resolution: {integrity: sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==} resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-x64-musl@15.2.1': '@next/swc-linux-x64-musl@15.2.4':
resolution: {integrity: sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==} resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-win32-arm64-msvc@15.2.1': '@next/swc-win32-arm64-msvc@15.2.4':
resolution: {integrity: sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==} resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@15.2.1': '@next/swc-win32-x64-msvc@15.2.4':
resolution: {integrity: sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==} resolution: {integrity: sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -2136,8 +2139,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.2.1: next@15.2.4:
resolution: {integrity: sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==} resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -2715,6 +2718,24 @@ packages:
zod@3.24.2: zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} 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: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
@@ -2876,34 +2897,34 @@ snapshots:
'@img/sharp-win32-x64@0.33.5': '@img/sharp-win32-x64@0.33.5':
optional: true optional: true
'@next/env@15.2.1': {} '@next/env@15.2.4': {}
'@next/eslint-plugin-next@15.2.1': '@next/eslint-plugin-next@15.2.1':
dependencies: dependencies:
fast-glob: 3.3.1 fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.2.1': '@next/swc-darwin-arm64@15.2.4':
optional: true optional: true
'@next/swc-darwin-x64@15.2.1': '@next/swc-darwin-x64@15.2.4':
optional: true optional: true
'@next/swc-linux-arm64-gnu@15.2.1': '@next/swc-linux-arm64-gnu@15.2.4':
optional: true optional: true
'@next/swc-linux-arm64-musl@15.2.1': '@next/swc-linux-arm64-musl@15.2.4':
optional: true optional: true
'@next/swc-linux-x64-gnu@15.2.1': '@next/swc-linux-x64-gnu@15.2.4':
optional: true optional: true
'@next/swc-linux-x64-musl@15.2.1': '@next/swc-linux-x64-musl@15.2.4':
optional: true optional: true
'@next/swc-win32-arm64-msvc@15.2.1': '@next/swc-win32-arm64-msvc@15.2.4':
optional: true optional: true
'@next/swc-win32-x64-msvc@15.2.1': '@next/swc-win32-x64-msvc@15.2.4':
optional: true optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -4735,9 +4756,9 @@ snapshots:
react: 19.0.0 react: 19.0.0
react-dom: 19.0.0(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: dependencies:
'@next/env': 15.2.1 '@next/env': 15.2.4
'@swc/counter': 0.1.3 '@swc/counter': 0.1.3
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
busboy: 1.6.0 busboy: 1.6.0
@@ -4747,14 +4768,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0) react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0) styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 15.2.1 '@next/swc-darwin-arm64': 15.2.4
'@next/swc-darwin-x64': 15.2.1 '@next/swc-darwin-x64': 15.2.4
'@next/swc-linux-arm64-gnu': 15.2.1 '@next/swc-linux-arm64-gnu': 15.2.4
'@next/swc-linux-arm64-musl': 15.2.1 '@next/swc-linux-arm64-musl': 15.2.4
'@next/swc-linux-x64-gnu': 15.2.1 '@next/swc-linux-x64-gnu': 15.2.4
'@next/swc-linux-x64-musl': 15.2.1 '@next/swc-linux-x64-musl': 15.2.4
'@next/swc-win32-arm64-msvc': 15.2.1 '@next/swc-win32-arm64-msvc': 15.2.4
'@next/swc-win32-x64-msvc': 15.2.1 '@next/swc-win32-x64-msvc': 15.2.4
sharp: 0.33.5 sharp: 0.33.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -5435,3 +5456,8 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@3.24.2: {} 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' 'use server'
import {cookies} from 'next/headers' import {cookies} from 'next/headers'
import {ApiResponse} from '@/lib/api' import {ApiResponse, UnauthorizedError} from '@/lib/api'
import {AuthContext} from '@/lib/auth' import {AuthContext} from '@/lib/auth'
import {User} from '@/lib/models' 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 {redirect} from 'next/navigation'
import {cache} from 'react'
export interface LoginParams { export interface LoginParams {
username: string username: string
password: string password: string
remember?: boolean remember: boolean
} }
type LoginResp = { type LoginResp = {
access_token: string access_token: string
refresh_token: string refresh_token: string
expires: number expires_in: number
auth: AuthContext token_type: string
profile: User scope?: string
} }
export async function login(props: LoginParams): Promise<ApiResponse> { export async function login(props: LoginParams): Promise<ApiResponse> {
// 尝试登录 // 尝试登录
const result = await callByDevice<LoginResp>('/api/auth/login/sms', { const result = await callByDevice<LoginResp>('/api/auth/token', {
username: props.username, ...props,
password: props.password, grant_type: 'password',
remember: props.remember ?? false, login_type: 'phone_code',
}) })
if (!result.success) { if (!result.success) {
return result return result
} }
const data = result.data
// 保存到 cookies // 保存到 cookies
const current = Math.floor(Date.now() / 1000) const data = result.data
const future = data.expires - current
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set('auth_token', data.access_token, { cookieStore.set('auth_token', data.access_token, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
maxAge: Math.max(future, 0), maxAge: Math.max(data.expires_in, 0),
}) })
cookieStore.set('auth_refresh', data.refresh_token, { cookieStore.set('auth_refresh', data.refresh_token, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', 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 { return {
success: true, success: true,
@@ -88,52 +83,27 @@ export async function logout() {
sameSite: 'strict', sameSite: 'strict',
maxAge: -1, 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) { export async function getProfile() {
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 { 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) { 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 {API_BASE_URL, CLIENT_ID, CLIENT_SECRET, ApiResponse, UnauthorizedError} from '@/lib/api'
import {cookies, headers} from 'next/headers' import {cookies, headers} from 'next/headers'
import {redirect} from 'next/navigation' import {redirect} from 'next/navigation'
import {cache} from 'react'
// OAuth令牌缓存
interface TokenCache {
token: string
expires: number // 过期时间戳
}
// ====================== // ======================
// region device token // region device token
// ====================== // ======================
let tokenCache: TokenCache | null = null async function callByDevice<R = undefined>(
endpoint: string,
// 获取OAuth2访问令牌 data: unknown,
async function getDeviceToken(forceRefresh = false): Promise<string> { ): Promise<ApiResponse<R>> {
try { return _callByDevice(endpoint, data ? JSON.stringify(data) : undefined)
// 检查缓存的令牌是否可用
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调用函数 // 通用的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 { try {
// 发送请求 // 获取设备令牌
let accessToken = getDeviceToken() if (!CLIENT_ID || !CLIENT_SECRET) {
const requestOptions = { 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${await accessToken}`, 'Authorization': `Basic ${token}`,
}, },
body: JSON.stringify(data), body: 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) { const response = await fetch(`${API_BASE_URL}${endpoint}`, request)
console.log('响应不成功', `status=${response.status}`, await response.text())
return {
success: false,
status: response.status,
message: '请求失败',
}
}
// 检查响应状态 // 检查响应状态
return handleResponse(response) return handleResponse(response)
} }
catch (e) { catch (e) {
console.error('API call failed:', e)
throw new Error('服务调用失败', {cause: e}) throw new Error('服务调用失败', {cause: e})
} }
} })
// endregion // endregion
@@ -172,26 +118,33 @@ async function callByUser<R = undefined>(
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
): Promise<ApiResponse<R>> { ): 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 { try {
let token = await getUserToken() let token = await getUserToken()
const header = await headers()
// 构造请求
// 获取客户端 IP
const clientIp = header.get('x-forwarded-for')
// 发送请求
const request = { const request = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
} as Record<string, string>, } as Record<string, string>,
body: data ? JSON.stringify(data) : undefined, body: data,
}
if (clientIp) {
request.headers['X-Forwarded-For'] = clientIp
} }
const userIp = header.get('x-forwarded-for')
if (userIp) {
request.headers['X-Forwarded-For'] = userIp
}
// 发送请求
let response = await fetch(`${API_BASE_URL}${endpoint}`, request) let response = await fetch(`${API_BASE_URL}${endpoint}`, request)
if (response.status === 401) { if (response.status === 401) {
token = await getUserToken(true) token = await getUserToken(true)
@@ -199,45 +152,25 @@ async function callByUser<R = undefined>(
response = await fetch(`${API_BASE_URL}${endpoint}`, request) response = await fetch(`${API_BASE_URL}${endpoint}`, request)
} }
// 检查响应状态 if (response.status === 401) {
if (!response.ok) { throw UnauthorizedError
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) return handleResponse(response)
} }
catch (e) { 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}) throw new Error('服务调用失败', {cause: e})
} }
} })
// endregion // endregion
@@ -250,43 +183,35 @@ async function callPublic<R = undefined>(
endpoint: string, endpoint: string,
data?: unknown, data?: unknown,
): Promise<ApiResponse<R>> { ): 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 { try {
// 发送请求 const url = `${API_BASE_URL}${endpoint}`
const requestOptions: RequestInit = { const request: RequestInit = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: data ? JSON.stringify(data) : undefined, body: data,
}
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 ? '服务器错误' : '请求失败',
}
} }
const response = await fetch(url, request)
return handleResponse(response) return handleResponse(response)
} }
catch (e) { catch (e) {
console.error('Public API call failed:', e) throw new Error('服务调用失败', {cause: e})
throw new Error('服务调用失败', { cause: e })
} }
} })
// endregion // endregion
// 统一响应解析 // 统一响应解析
async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> { async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> {
// 解析响应数据
const type = response.headers.get('Content-Type') ?? 'text/plain' const type = response.headers.get('Content-Type') ?? 'text/plain'
if (type.indexOf('application/json') !== -1) { if (type.indexOf('application/json') !== -1) {
const json = await response.json() const json = await response.json()
@@ -313,9 +238,11 @@ async function handleResponse<R = undefined>(response: Response): Promise<ApiRes
message: text || '请求失败', message: text || '请求失败',
} }
} }
console.log('响应成功', `type=text`, `text=${text}`)
return { return {
success: true, success: true,
data: undefined as unknown as R, // 强转类型,考虑优化 data: undefined as R, // 强转类型,考虑优化
} }
} }
else { else {
@@ -327,7 +254,6 @@ async function handleResponse<R = undefined>(response: Response): Promise<ApiRes
// 导出 // 导出
export { export {
getDeviceToken,
getUserToken, getUserToken,
callByDevice, callByDevice,
callByUser, callByUser,

View File

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

View File

@@ -1,5 +1,5 @@
'use client' '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 Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import {LinkItem, MenuItem} from './navs' import {LinkItem, MenuItem} from './navs'
@@ -8,14 +8,14 @@ import ProductMenu from './product'
import HelpMenu from './help' import HelpMenu from './help'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import logo from '@/assets/logo.webp' import logo from '@/assets/logo.webp'
import {Button} from '@/components/ui/button'
import {useProfileStore} from '@/components/providers/StoreProvider'
export const HeaderContext = createContext<{ export const HeaderContext = createContext<{
setMenu: (value: boolean) => void setMenu: (value: boolean) => void
} | null>(null) } | null>(null)
export type ProviderProps = { export type ProviderProps = {}
userCenter: ReactNode
}
export default function Provider(props: 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 ( return (
@@ -116,7 +122,35 @@ export default function Provider(props: ProviderProps) {
</nav> </nav>
</div> </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> </Wrap>
</div> </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 Provider from '@/app/(root)/@header/_client/provider'
import UserCenter from '@/app/(root)/@header/_server/user-center'
export type HeaderProps = {} export type HeaderProps = {}
export default async function Header(props: HeaderProps) { export default async function Header(props: HeaderProps) {
return ( return (
<header className={`fixed top-0 w-full z-10`}> <header className={`fixed top-0 w-full z-10`}>
<Provider <Provider/>
userCenter={<UserCenter/>}
/>
</header> </header>
) )
} }

View File

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

View File

@@ -1,17 +1,33 @@
import {cookies} from 'next/headers' 'use client'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {logout} from '@/actions/auth/auth' 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 type ProfileProps = {}
export default async function Profile(props: ProfileProps) { export default function Profile(props: ProfileProps) {
const store = await cookies() const refreshProfile = useProfileStore(store => store.refreshProfile)
const info = store.get('auth_info')?.value const router = useRouter()
const data = info ? JSON.parse(info) : undefined 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 ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<span>{data?.payload.name}</span> <Button theme={`error`} onClick={doLogout}>
<Button theme={`error`} onClick={logout}>
退 退
</Button> </Button>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {zodResolver} from '@hookform/resolvers/zod'
import {createResourceByBalance} from '@/actions/resource' import {createResourceByBalance} from '@/actions/resource'
import {toast} from 'sonner' import {toast} from 'sonner'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {AuthContext} from '@/components/providers/AuthProvider' import {StoreContext} from '@/components/providers/StoreProvider'
// 定义表单验证架构 // 定义表单验证架构
const schema = z.object({ const schema = z.object({

View File

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

View File

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

View File

@@ -8,15 +8,16 @@ import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg' import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg' import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.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 RechargeModal from '@/components/composites/purchase/_client/recharge'
import Pay from '@/components/composites/purchase/_client/pay' import Pay from '@/components/composites/purchase/_client/pay'
import {Button, buttonVariants} from '@/components/ui/button'
import Link from 'next/link'
export type RightProps = {} export type RightProps = {}
export default function Right(props: RightProps) { export default function Right(props: RightProps) {
const authCtx = useContext(AuthContext) const profile = useProfileStore(store => store.profile)
const profile = authCtx.profile
const form = useContext(PurchaseFormContext)?.form const form = useContext(PurchaseFormContext)?.form
if (!form) { if (!form) {
@@ -89,58 +90,65 @@ export default function Right(props: RightProps) {
<span></span> <span></span>
<span className={`text-xl text-orange-500`}>{price}</span> <span className={`text-xl text-orange-500`}>{price}</span>
</p> </p>
<FormField name={`pay_type`} label={`支付方式`} className={`flex flex-col gap-6`}> {profile ? <>
{({id, field}) => ( <FormField name={`pay_type`} label={`支付方式`} className={`flex flex-col gap-6`}>
<RadioGroup {({id, field}) => (
id={id} <RadioGroup
defaultValue={field.value} id={id}
onValueChange={field.onChange} defaultValue={field.value}
className={`flex flex-col gap-3`}> onValueChange={field.onChange}
<div className={`w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md`}> className={`flex flex-col gap-3`}>
<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> <div className={`w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md`}>
<FormOption <p className={`flex items-center gap-3`}>
id={`${id}-balance`} <Image src={balance} alt={`余额icon`}/>
value={`balance`} <span className={`text-sm text-gray-500`}></span>
compare={field.value} </p>
className={`p-3 w-full flex-row gap-2 justify-center`}> <p className={`flex justify-between items-center`}>
<Image src={balance} alt={`余额 icon`}/> <span className={`text-xl`}>{profile?.balance}</span>
<span></span> <RechargeModal/>
</FormOption> </p>
<FormOption </div>
id={`${id}-wechat`}
value={`wechat`} <FormOption
compare={field.value} id={`${id}-balance`}
className={`p-3 w-full flex-row gap-2 justify-center`}> value={`balance`}
<Image src={wechat} alt={`微信 logo`}/> compare={field.value}
<span></span> className={`p-3 w-full flex-row gap-2 justify-center`}>
</FormOption> <Image src={balance} alt={`余额 icon`}/>
<FormOption <span></span>
id={`${id}-alipay`} </FormOption>
value={`alipay`} <FormOption
compare={field.value} id={`${id}-wechat`}
className={`p-3 w-full flex-row gap-2 justify-center`}> value={`wechat`}
<Image src={alipay} alt={`支付宝 logo`}/> compare={field.value}
<span></span> className={`p-3 w-full flex-row gap-2 justify-center`}>
</FormOption> <Image src={wechat} alt={`微信 logo`}/>
</RadioGroup> <span></span>
)} </FormOption>
</FormField> <FormOption
<Pay method={payType} amount={price} resource={{ id={`${id}-alipay`}
type: Number(watchType), value={`alipay`}
live: Number(watchLive) * 60, compare={field.value}
quota: watchQuota, className={`p-3 w-full flex-row gap-2 justify-center`}>
expire: Number(watchExpire), <Image src={alipay} alt={`支付宝 logo`}/>
daily_limit: watchDailyLimit, <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> </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})
},
}))
}