feat(P1): 前端双 Token 适配 + 401 自动刷新 + UserAvatar src 支持 + Account API 扩展
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { postJson } from './client'
|
||||
import { postForm, postJson } from './client'
|
||||
import type { Account, MessageResponse, TokenResponse } from './types'
|
||||
|
||||
export function register(username: string, password: string) {
|
||||
@@ -32,3 +32,17 @@ export function findById(id: number) {
|
||||
export function findByUsername(username: string) {
|
||||
return postJson<Account>('/account/findByUsername', { username })
|
||||
}
|
||||
|
||||
export function uploadAvatar(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true })
|
||||
}
|
||||
|
||||
export function updateProfile(data: { avatar_url?: string; bio?: string }) {
|
||||
return postJson<MessageResponse>('/account/updateProfile', data, { authRequired: true })
|
||||
}
|
||||
|
||||
export function refresh(refreshToken: string) {
|
||||
return postJson<TokenResponse>('/account/refresh', { refresh_token: refreshToken })
|
||||
}
|
||||
|
||||
@@ -17,6 +17,35 @@ type ApiErrorBody = { error?: string }
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.refreshToken) return null
|
||||
if (isRefreshing) return refreshPromise
|
||||
isRefreshing = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/account/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: auth.refreshToken }),
|
||||
})
|
||||
if (!res.ok) { auth.clearTokens(); return null }
|
||||
const data = await res.json()
|
||||
auth.setToken(data.token)
|
||||
return data.token as string
|
||||
} catch {
|
||||
auth.clearTokens()
|
||||
return null
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function postJson<T>(path: string, body: unknown, options?: { authRequired?: boolean }): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const token = auth.token
|
||||
@@ -34,30 +63,18 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
if (res.status === 401 && path !== '/account/refresh') {
|
||||
const newToken = await tryRefresh()
|
||||
if (newToken) {
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST', headers, body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
return handleResponse<T>(retryRes, path)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
auth.clearToken()
|
||||
}
|
||||
const msg =
|
||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
const apiErr = new ApiError(msg, res.status, data)
|
||||
reportError(apiErr, { path, status: res.status })
|
||||
throw apiErr
|
||||
}
|
||||
|
||||
return data as T
|
||||
return handleResponse<T>(res, path)
|
||||
}
|
||||
|
||||
export async function postForm<T>(path: string, body: FormData, options?: { authRequired?: boolean }): Promise<T> {
|
||||
@@ -77,24 +94,33 @@ export async function postForm<T>(path: string, body: FormData, options?: { auth
|
||||
body,
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
if (res.status === 401 && path !== '/account/refresh') {
|
||||
const newToken = await tryRefresh()
|
||||
if (newToken) {
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST', headers, body,
|
||||
})
|
||||
return handleResponse<T>(retryRes, path)
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse<T>(res, path)
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try { data = JSON.parse(text) } catch { data = text }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
auth.clearToken()
|
||||
}
|
||||
const msg =
|
||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
if (res.status === 401) auth.clearTokens()
|
||||
const msg = data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
const apiErr = new ApiError(msg, res.status, data)
|
||||
reportError(apiErr, { path, status: res.status })
|
||||
throw apiErr
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type MessageResponse = { message: string }
|
||||
|
||||
export type TokenResponse = { token: string }
|
||||
export type TokenResponse = { token: string; refresh_token?: string; account_id?: number; username?: string }
|
||||
|
||||
export type Account = {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export type Video = {
|
||||
|
||||
5
frontend/src/components/UserAvatar.vue
vendored
5
frontend/src/components/UserAvatar.vue
vendored
@@ -5,6 +5,7 @@ const props = defineProps<{
|
||||
username: string
|
||||
id?: number
|
||||
size?: number
|
||||
src?: string
|
||||
}>()
|
||||
|
||||
function hashToHue(input: string) {
|
||||
@@ -33,7 +34,8 @@ const bg = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
|
||||
<div v-else class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||
{{ initial }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,6 +51,7 @@ const bg = computed(() => {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
user-select: none;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -3,43 +3,46 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
|
||||
|
||||
const TOKEN_KEY = 'jwt_token'
|
||||
const ACCESS_KEY = 'access_token'
|
||||
const REFRESH_KEY = 'refresh_token'
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
function readStored(key: string): string | null {
|
||||
try { return localStorage.getItem(key) } catch { return null }
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
function writeStored(key: string, value: string) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
function removeToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
function removeStored(key: string) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(readToken())
|
||||
const token = ref<string | null>(readStored(ACCESS_KEY))
|
||||
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
writeToken(newToken)
|
||||
writeStored(ACCESS_KEY, newToken)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
function setTokens(access: string, refresh: string) {
|
||||
token.value = access
|
||||
refreshToken.value = refresh
|
||||
writeStored(ACCESS_KEY, access)
|
||||
writeStored(REFRESH_KEY, refresh)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
token.value = null
|
||||
removeToken()
|
||||
refreshToken.value = null
|
||||
removeStored(ACCESS_KEY)
|
||||
removeStored(REFRESH_KEY)
|
||||
}
|
||||
|
||||
function syncFromStorage() {
|
||||
token.value = readToken()
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, claims, setToken, clearToken, syncFromStorage }
|
||||
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
|
||||
})
|
||||
|
||||
2
frontend/src/views/AccountView.vue
vendored
2
frontend/src/views/AccountView.vue
vendored
@@ -125,7 +125,7 @@ async function onLogin() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await accountApi.login(username, password)
|
||||
auth.setToken(res.token)
|
||||
auth.setTokens(res.token, res.refresh_token ?? '')
|
||||
toast.success('登录成功')
|
||||
await social.refreshMine()
|
||||
await loadMyVideos()
|
||||
|
||||
2
frontend/src/views/SettingsView.vue
vendored
2
frontend/src/views/SettingsView.vue
vendored
@@ -75,7 +75,7 @@ async function onLogout() {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(`登出失败:${msg}`)
|
||||
} finally {
|
||||
auth.clearToken()
|
||||
auth.clearTokens()
|
||||
rename.open = false
|
||||
toast.info('已退出登录')
|
||||
busy.value = false
|
||||
|
||||
Reference in New Issue
Block a user