feat(P1): 前端双 Token 适配 + 401 自动刷新 + UserAvatar src 支持 + Account API 扩展

This commit is contained in:
Sisyphus
2026-04-25 18:54:22 +08:00
parent 9b20df4bf3
commit 2322383036
30 changed files with 4284 additions and 4236 deletions

View File

@@ -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 })
}

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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>

View File

@@ -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 }
})

View File

@@ -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()

View File

@@ -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