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,
}
}
-37
View File
@@ -1,37 +0,0 @@
const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__'
type CanvasLikePrototype = {
getContext?: (contextId: string, options?: unknown) => unknown
[PATCH_FLAG]?: boolean
}
function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) {
const prototype = ctor?.prototype as CanvasLikePrototype | undefined
if (!prototype?.getContext || prototype[PATCH_FLAG]) return
const originalGetContext = prototype.getContext
prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) {
if (contextId === '2d') {
const nextOptions =
options && typeof options === 'object'
? { ...options as Record<string, unknown>, willReadFrequently: true }
: { willReadFrequently: true }
return originalGetContext.call(this, contextId, nextOptions)
}
return originalGetContext.call(this, contextId, options)
}
prototype[PATCH_FLAG] = true
}
export function enableCanvasReadbackHint() {
if (typeof window === 'undefined') return
patch2DContextOptions(window.HTMLCanvasElement)
if (typeof window.OffscreenCanvas !== 'undefined') {
patch2DContextOptions(window.OffscreenCanvas)
}
}
-130
View File
@@ -1,130 +0,0 @@
export type BadgeRarity = 'common' | 'uncommon' | 'rare'
export interface CloudBadgeCardInput {
cloudName: string
cloudNameEn: string
unlockedAt: string
username: string
rarity: BadgeRarity
}
const rarityTheme = {
common: {
from: '#dbeafe',
to: '#f8fafc',
accent: '#0284c7',
shadow: 'rgba(2, 132, 199, 0.18)',
},
uncommon: {
from: '#fef3c7',
to: '#fff7ed',
accent: '#d97706',
shadow: 'rgba(217, 119, 6, 0.22)',
},
rare: {
from: '#fee2e2',
to: '#fff7ed',
accent: '#dc2626',
shadow: 'rgba(220, 38, 38, 0.22)',
},
} satisfies Record<BadgeRarity, { from: string; to: string; accent: string; shadow: string }>
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function makeDownloadName(name: string) {
return `opencloud-badge-${name}.png`
}
export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) {
const canvas = document.createElement('canvas')
canvas.width = 1200
canvas.height = 630
const ctx = canvas.getContext('2d')
if (!ctx) return
const theme = rarityTheme[input.rarity]
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
gradient.addColorStop(0, theme.from)
gradient.addColorStop(1, theme.to)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = 'rgba(255,255,255,0.72)'
ctx.beginPath()
ctx.arc(1010, 120, 140, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = 'rgba(255,255,255,0.45)'
ctx.beginPath()
ctx.arc(160, 520, 180, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#0f172a'
ctx.font = '700 44px sans-serif'
ctx.fillText('OpenCloud 云朵图鉴', 72, 88)
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
ctx.font = '400 24px sans-serif'
ctx.fillText('新徽章已解锁', 72, 126)
ctx.shadowColor = theme.shadow
ctx.shadowBlur = 28
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.roundRect(72, 170, 1056, 380, 32)
ctx.fill()
ctx.shadowBlur = 0
ctx.fillStyle = theme.accent
ctx.beginPath()
ctx.arc(210, 360, 92, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = '700 82px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(input.cloudName.slice(0, 1), 210, 360)
ctx.textAlign = 'left'
ctx.textBaseline = 'alphabetic'
ctx.fillStyle = '#0f172a'
ctx.font = '700 64px sans-serif'
ctx.fillText(input.cloudName, 340, 300)
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
ctx.font = '400 30px sans-serif'
ctx.fillText(input.cloudNameEn, 340, 346)
ctx.fillStyle = theme.accent
ctx.beginPath()
ctx.roundRect(340, 382, 168, 48, 24)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = '600 24px sans-serif'
ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414)
ctx.fillStyle = '#334155'
ctx.font = '400 28px sans-serif'
ctx.fillText(`解锁者:${input.username}`, 340, 476)
ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518)
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
if (!blob) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = makeDownloadName(input.cloudNameEn.toLowerCase())
link.click()
URL.revokeObjectURL(url)
}
+10 -10
View File
@@ -1,14 +1,14 @@
export const CLOUD_TYPES = [
{ id: 1, name: '积云', nameEn: 'Cumulus' },
{ id: 2, name: '层云', nameEn: 'Stratus' },
{ id: 3, name: '卷云', nameEn: 'Cirrus' },
{ id: 4, name: '积雨云', nameEn: 'Cumulonimbus' },
{ id: 5, name: '层积云', nameEn: 'Stratocumulus' },
{ id: 6, name: '高积云', nameEn: 'Altocumulus' },
{ id: 7, name: '高层云', nameEn: 'Altostratus' },
{ id: 8, name: '雨层云', nameEn: 'Nimbostratus' },
{ id: 9, name: '卷层云', nameEn: 'Cirrostratus' },
{ id: 10, name: '卷积云', nameEn: 'Cirrocumulus' },
{ id: 1, name: '积云' },
{ id: 2, name: '层云' },
{ id: 3, name: '卷云' },
{ id: 4, name: '积雨云' },
{ id: 5, name: '层积云' },
{ id: 6, name: '高积云' },
{ id: 7, name: '高层云' },
{ id: 8, name: '雨层云' },
{ id: 9, name: '卷层云' },
{ id: 10, name: '卷积云' },
] as const
export function getCloudTypeName(id: string | string[]) {