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'
|
import type { Account, MessageResponse, TokenResponse } from './types'
|
||||||
|
|
||||||
export function register(username: string, password: string) {
|
export function register(username: string, password: string) {
|
||||||
@@ -32,3 +32,17 @@ export function findById(id: number) {
|
|||||||
export function findByUsername(username: string) {
|
export function findByUsername(username: string) {
|
||||||
return postJson<Account>('/account/findByUsername', { username })
|
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'
|
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> {
|
export async function postJson<T>(path: string, body: unknown, options?: { authRequired?: boolean }): Promise<T> {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const token = auth.token
|
const token = auth.token
|
||||||
@@ -34,30 +63,18 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
|
|||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
if (res.status === 401 && path !== '/account/refresh') {
|
||||||
let data: unknown = null
|
const newToken = await tryRefresh()
|
||||||
if (text) {
|
if (newToken) {
|
||||||
try {
|
headers.Authorization = `Bearer ${newToken}`
|
||||||
data = JSON.parse(text)
|
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||||
} catch {
|
method: 'POST', headers, body: JSON.stringify(body ?? {}),
|
||||||
data = text
|
})
|
||||||
|
return handleResponse<T>(retryRes, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
return handleResponse<T>(res, path)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postForm<T>(path: string, body: FormData, options?: { authRequired?: boolean }): Promise<T> {
|
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,
|
body,
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
if (res.status === 401 && path !== '/account/refresh') {
|
||||||
let data: unknown = null
|
const newToken = await tryRefresh()
|
||||||
if (text) {
|
if (newToken) {
|
||||||
try {
|
headers.Authorization = `Bearer ${newToken}`
|
||||||
data = JSON.parse(text)
|
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||||
} catch {
|
method: 'POST', headers, body,
|
||||||
data = text
|
})
|
||||||
|
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.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) auth.clearTokens()
|
||||||
auth.clearToken()
|
const msg = data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||||
}
|
? String((data as ApiErrorBody).error)
|
||||||
const msg =
|
: `请求失败 (${res.status})`
|
||||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
|
||||||
? String((data as ApiErrorBody).error)
|
|
||||||
: `请求失败 (${res.status})`
|
|
||||||
const apiErr = new ApiError(msg, res.status, data)
|
const apiErr = new ApiError(msg, res.status, data)
|
||||||
reportError(apiErr, { path, status: res.status })
|
reportError(apiErr, { path, status: res.status })
|
||||||
throw apiErr
|
throw apiErr
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export type MessageResponse = { message: string }
|
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 = {
|
export type Account = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
avatar_url?: string
|
||||||
|
bio?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Video = {
|
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
|
username: string
|
||||||
id?: number
|
id?: number
|
||||||
size?: number
|
size?: number
|
||||||
|
src?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function hashToHue(input: string) {
|
function hashToHue(input: string) {
|
||||||
@@ -33,7 +34,8 @@ const bg = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 }}
|
{{ initial }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,6 +51,7 @@ const bg = computed(() => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -3,43 +3,46 @@ import { computed, ref } from 'vue'
|
|||||||
|
|
||||||
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
|
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 {
|
function readStored(key: string): string | null {
|
||||||
try {
|
try { return localStorage.getItem(key) } catch { return null }
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeToken(token: string) {
|
function writeStored(key: string, value: string) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeToken() {
|
function removeStored(key: string) {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
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 isLoggedIn = computed(() => !!token.value)
|
||||||
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
||||||
|
|
||||||
function setToken(newToken: string) {
|
function setToken(newToken: string) {
|
||||||
token.value = newToken
|
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
|
token.value = null
|
||||||
removeToken()
|
refreshToken.value = null
|
||||||
|
removeStored(ACCESS_KEY)
|
||||||
|
removeStored(REFRESH_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncFromStorage() {
|
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
|
||||||
token.value = readToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token, isLoggedIn, claims, setToken, clearToken, syncFromStorage }
|
|
||||||
})
|
})
|
||||||
|
|||||||
2
frontend/src/views/AccountView.vue
vendored
2
frontend/src/views/AccountView.vue
vendored
@@ -125,7 +125,7 @@ async function onLogin() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const res = await accountApi.login(username, password)
|
const res = await accountApi.login(username, password)
|
||||||
auth.setToken(res.token)
|
auth.setTokens(res.token, res.refresh_token ?? '')
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
await social.refreshMine()
|
await social.refreshMine()
|
||||||
await loadMyVideos()
|
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)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(`登出失败:${msg}`)
|
toast.error(`登出失败:${msg}`)
|
||||||
} finally {
|
} finally {
|
||||||
auth.clearToken()
|
auth.clearTokens()
|
||||||
rename.open = false
|
rename.open = false
|
||||||
toast.info('已退出登录')
|
toast.info('已退出登录')
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user