feat: 适配新版后端 API 并接入邮箱验证 (#9)

## 概述
本分支对前端进行了大规模适配与升级,核心是对齐新版后端 API 接口,并接入邮箱验证与密码找回流程。

## 主要变更
- **API 适配**:对齐新版后端接口契约,重构 api 客户端与类型定义,移除前端臆造字段
- **认证流程**:接入邮箱验证(确认 / 重发确认)、密码找回(忘记密码 / 重置密码)全流程
- **管理后台**:前端对齐更新后的 API 契约
- **云图鉴**:展示云类型图标图片
- **UI 优化**:图片预览改为直角样式并调整关闭按钮

## 影响范围
- API 层:src/lib/api.ts、src/types/api.ts、src/types/database.ts
- 认证视图:src/views/auth/*
- 管理后台:src/views/admin/AdminView.vue
- 图鉴视图:src/views/encyclopedia/*
- 状态管理:src/stores/*

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-08-22 21:39:42 +08:00
parent cd70f89a28
commit 720ad29f06
34 changed files with 2472 additions and 2165 deletions
+126 -63
View File
@@ -1,14 +1,17 @@
import type { AuthResponse } from '@/types/api'
import type {
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
} from '@/types/api'
import type { CloudType } from '@/types/database'
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
const DEFAULT_API_URL = 'http://localhost:3000'
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
let accessToken: string | null = null
let refreshPromise: Promise<AuthResponse> | null = null
export class ApiError extends Error {
status: number
detail: unknown
@@ -24,6 +27,8 @@ export class ApiError extends Error {
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
// Kept for callers that explicitly disable retries. Cookie sessions do not
// use a refresh-and-retry flow, so this option is intentionally ignored.
retry?: boolean
}
@@ -36,43 +41,71 @@ function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unk
&& !(body instanceof ArrayBuffer)
}
function detailToMessage(detail: unknown, fallback: string) {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
function validationMessage(value: unknown) {
if (typeof value !== 'string') return ''
try {
const issues = JSON.parse(value) as unknown
if (!Array.isArray(issues)) return value
const messages = issues
.map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '')
.filter(Boolean)
if (messages.length) return messages.join(';')
return messages.join(';') || value
} catch {
return value
}
}
function errorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== 'object') return fallback
if ('detail' in payload) {
const detail = payload.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
.filter(Boolean)
if (messages.length) return messages.join(';')
}
}
if ('error' in payload) {
const error = payload.error
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
return validationMessage(error.message) || fallback
}
}
return fallback
}
async function toApiError(response: Response) {
let detail: unknown
async function readPayload(response: Response) {
const text = await response.text()
if (!text) return undefined
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('json')) return text
try {
const payload = await response.json() as { detail?: unknown }
detail = payload.detail
return JSON.parse(text) as unknown
} catch {
detail = undefined
return text
}
return new ApiError(
detailToMessage(detail, `请求失败(${response.status})`),
response.status,
detail,
)
}
export function setAccessToken(token: string | null) {
accessToken = token
}
async function toApiError(response: Response) {
const payload = await readPayload(response)
const fallback = typeof payload === 'string' && payload.trim()
? payload.trim()
: `请求失败(${response.status})`
export function clearAccessToken() {
accessToken = null
return new ApiError(errorMessage(payload, fallback), response.status, payload)
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry = true, ...requestOptions } = options
const { body, auth = true, retry: _retry, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
@@ -81,10 +114,6 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
requestBody = JSON.stringify(body)
}
if (auth && accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
const response = await fetch(`${API_URL}${path}`, {
...requestOptions,
body: requestBody,
@@ -92,43 +121,77 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
credentials: 'include',
})
if (response.status === 401 && auth && accessToken && retry) {
try {
const authResponse = await refreshSession()
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
return request<T>(path, { ...options, retry: false })
} catch {
clearAccessToken()
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
if (response.status === 401 && auth && typeof window !== 'undefined') {
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
if (!response.ok) throw await toApiError(response)
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
return await readPayload(response) as T
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
return request<T>(path, options)
}
export function refreshSession() {
if (!refreshPromise) {
refreshPromise = (async () => {
const response = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) throw await toApiError(response)
const authResponse = await response.json() as AuthResponse
setAccessToken(authResponse.access_token)
return authResponse
})().finally(() => {
refreshPromise = null
})
}
return refreshPromise
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
}
export function toAuthUser(profile: BackendUserProfile): AuthUser {
return {
id: profile.id,
email: profile.email,
username: profile.name,
avatar_id: profile.avatar_id,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
received_like_count: profile.received_like_count,
created_at: profile.created_at,
}
}
export function toCloudType(row: BackendCloudType): CloudType {
return {
id: row.id,
name: row.name,
genus: row.genus || '',
icon_id: row.icon_id,
// Compatibility value for the excluded legacy admin view. Migrated pages
// display rarity_value directly because API.md defines no semantic mapping.
rarity: 'common',
rarity_value: row.rarity,
description: row.description,
created_at: row.created_at,
}
}
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
return {
id: row.id,
user_id: row.owner.id,
cloud_type_id: row.type.id,
image_url: imageUrl(row.id, 'original'),
thumbnail_url: imageUrl(row.id, 'preview'),
latitude: row.latitude,
longitude: row.longitude,
description: row.description,
captured_at: row.captured_at,
status: row.status,
is_hidden: row.is_hidden,
created_at: row.uploaded_at,
updated_at: row.updated_at,
cloud_type: {
id: row.type.id,
name: row.type.name,
rarity: 'common',
rarity_value: row.type.rarity,
},
owner: { id: row.owner.id, username: row.owner.name },
cloud_type_name: row.type.name,
cloud_type_rarity: 'common',
cloud_type_rarity_value: row.type.rarity,
username: row.owner.name,
received_like_count: row.received_like_count,
}
}