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:
+126
-63
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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[]) {
|
||||
|
||||
Reference in New Issue
Block a user