feat: refine tiangong flows and integrate profile avatars
This commit is contained in:
1 parent
4608dbd5cf
commit
5be2ca6b50
65 files changed
+2099
-532
No files matched your search
+92
-17
@@ -1,7 +1,50 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { frontendPreviewProfile, isFrontendPreview } from './frontendPreview'
|
||||
import { createResourceCache } from '../utils/resourceCache'
|
||||
import { getAvatarDemoProfile, isAvatarDemo, saveAvatarDemoImage, updateAvatarDemoProfile } from './avatarDemo'
|
||||
|
||||
const BASE_URL = 'http://localhost:8080'
|
||||
export const BASE_URL = typeof API_BASE_URL === 'undefined' ? 'http://127.0.0.1:8080' : API_BASE_URL
|
||||
const profileCache = createResourceCache<UserProfile>(30000)
|
||||
const profileListeners = new Set<(profile: UserProfile | null) => void>()
|
||||
export const getToken = (): string => Taro.getStorageSync('token') || ''
|
||||
const profileKey = () => isFrontendPreview() ? 'preview' : isAvatarDemo() ? 'avatar-demo' : `user:${Taro.getStorageSync('token') || ''}`
|
||||
const cloneProfile = (profile: UserProfile): UserProfile => JSON.parse(JSON.stringify(profile))
|
||||
export class AuthenticationError extends Error {
|
||||
constructor(message = '请先登录') { super(message); this.name = 'AuthenticationError' }
|
||||
}
|
||||
export function subscribeProfile(listener: (profile: UserProfile | null) => void) {
|
||||
profileListeners.add(listener)
|
||||
return () => { profileListeners.delete(listener) }
|
||||
}
|
||||
function publishProfile(profile: UserProfile | null) {
|
||||
profileListeners.forEach(listener => listener(profile ? cloneProfile(profile) : null))
|
||||
}
|
||||
export function clearLogin() {
|
||||
Taro.removeStorageSync('token')
|
||||
profileCache.clear()
|
||||
publishProfile(null)
|
||||
}
|
||||
function normalizeProfile(profile: UserProfile): UserProfile {
|
||||
return { ...profile, nickname: profile.nickname || '', birthday: profile.birthday || '',
|
||||
birthHour: profile.birthHour || '', province: profile.province || '', city: profile.city || '',
|
||||
district: profile.district || '', gender: profile.gender || '未知', avatarUrl: profile.avatarUrl || '' }
|
||||
}
|
||||
function storeProfile(profile: UserProfile, key = profileKey()) {
|
||||
if (key !== profileKey()) throw new AuthenticationError('登录状态已变更,请重新打开页面')
|
||||
const data = normalizeProfile(profile)
|
||||
profileCache.seed(key, cloneProfile(data))
|
||||
publishProfile(data)
|
||||
return data
|
||||
}
|
||||
export function acceptLoginSession(session: LoginSession) {
|
||||
if (!session.token || !session.user) throw new Error('登录响应不完整,请重试')
|
||||
Taro.setStorageSync('token', session.token)
|
||||
return storeProfile(session.user)
|
||||
}
|
||||
export function getCachedProfile(): UserProfile | null {
|
||||
const profile = profileCache.peek(profileKey())
|
||||
return profile ? cloneProfile(profile) : null
|
||||
}
|
||||
|
||||
type ApiResult<T> = {
|
||||
code: number
|
||||
@@ -11,7 +54,26 @@ type ApiResult<T> = {
|
||||
|
||||
type RequestOptions = Omit<Taro.request.Option, 'url'>
|
||||
|
||||
function unwrap<T>(status: number, body: ApiResult<T>, token: string, protectedRequest: boolean): T {
|
||||
const expired = status === 401 || status === 403
|
||||
|| (body?.code !== 0 && /请先登录|登录状态.*过期|重新登录/.test(body?.message || ''))
|
||||
if (expired && protectedRequest) {
|
||||
if (token === getToken()) clearLogin()
|
||||
throw new AuthenticationError('登录状态已失效,请重新登录')
|
||||
}
|
||||
if (status < 200 || status >= 300 || !body || body.code !== 0) {
|
||||
throw new Error(body?.message && typeof body.message === 'string' ? body.message : `请求失败(${status || '网络异常'}),请稍后重试`)
|
||||
}
|
||||
if (protectedRequest && token !== getToken()) throw new AuthenticationError('登录状态已变更,请重新打开页面')
|
||||
return body.data
|
||||
}
|
||||
|
||||
export async function request<T>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||
if (isAvatarDemo()) {
|
||||
if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) return getAvatarDemoProfile() as T
|
||||
if (url === '/api/user/profile' && options.method === 'PUT') return updateAvatarDemoProfile(options.data as Partial<UserProfile>) as T
|
||||
throw new Error('已登录演示仅支持本地资料与头像,不请求真实后端')
|
||||
}
|
||||
if (isFrontendPreview()) {
|
||||
if (url === '/api/user/profile' && (!options.method || options.method === 'GET')) {
|
||||
return { ...frontendPreviewProfile, elementReport: { ...frontendPreviewProfile.elementReport, scores: [] } } as T
|
||||
@@ -19,6 +81,8 @@ export async function request<T>(url: string, options: RequestOptions = {}): Pro
|
||||
throw new Error('纯前端预览暂不请求后端或保存修改')
|
||||
}
|
||||
const token = Taro.getStorageSync('token')
|
||||
const protectedRequest = !/^\/api\/(auth|content)\//.test(url)
|
||||
if (protectedRequest && !token) throw new AuthenticationError()
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -27,44 +91,51 @@ export async function request<T>(url: string, options: RequestOptions = {}): Pro
|
||||
const res = await Taro.request<ApiResult<T>>({
|
||||
url: `${BASE_URL}${url}`,
|
||||
...options,
|
||||
timeout: options.timeout || 15000,
|
||||
credentials: 'omit',
|
||||
header: headers
|
||||
})
|
||||
if (res.data.code !== 0) {
|
||||
throw new Error(res.data.message || '请求失败')
|
||||
}
|
||||
return res.data.data
|
||||
return unwrap(res.statusCode, res.data, token, protectedRequest)
|
||||
}
|
||||
|
||||
export function resolveAssetUrl(url?: string) {
|
||||
if (!url) return ''
|
||||
return /^https?:\/\//.test(url) || url.startsWith('wxfile://') || url.startsWith('http://tmp/')
|
||||
if (url.startsWith('cos://')) return '' // Only the backend may resolve private object references.
|
||||
return /^(https?:\/\/|wxfile:\/\/|blob:|data:image\/)/.test(url)
|
||||
? url
|
||||
: `${BASE_URL}${url.startsWith('/') ? url : `/${url}`}`
|
||||
}
|
||||
|
||||
async function uploadAvatar(filePath: string): Promise<UserProfile> {
|
||||
if (isAvatarDemo()) return storeProfile(saveAvatarDemoImage(filePath))
|
||||
if (isFrontendPreview()) throw new Error('纯前端预览暂不上传头像或保存修改')
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) throw new AuthenticationError()
|
||||
const key = profileKey()
|
||||
const response = await Taro.uploadFile({
|
||||
url: `${BASE_URL}/api/user/avatar`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
timeout: 45000,
|
||||
withCredentials: false,
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
let result: ApiResult<UserProfile>
|
||||
try {
|
||||
result = JSON.parse(response.data) as ApiResult<UserProfile>
|
||||
} catch (error) {
|
||||
if (response.statusCode === 401 || response.statusCode === 403) unwrap(response.statusCode, null, token, true)
|
||||
throw new Error('头像上传失败')
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.message || '头像上传失败')
|
||||
}
|
||||
return result.data
|
||||
const profile = unwrap(response.statusCode, result, token, true)
|
||||
return storeProfile(profile, key)
|
||||
}
|
||||
|
||||
export type LoginSession = { token: string; user: UserProfile }
|
||||
export type WxLoginResponse = { sessionKey: string; phoneBound: boolean; token?: string; user?: UserProfile }
|
||||
|
||||
export const api = {
|
||||
wxLogin: (code: string) => request<{ sessionKey: string; phoneBound: boolean }>('/api/auth/wx-login', {
|
||||
wxLogin: (code: string) => request<WxLoginResponse>('/api/auth/wx-login', {
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}),
|
||||
@@ -72,15 +143,19 @@ export const api = {
|
||||
method: 'POST',
|
||||
data: { phone }
|
||||
}),
|
||||
bindPhone: (sessionKey: string, phone: string, captcha: string) => request<{ token: string; user: UserProfile }>('/api/auth/bind-phone', {
|
||||
bindPhone: (sessionKey: string, phone: string, captcha: string) => request<LoginSession>('/api/auth/bind-phone', {
|
||||
method: 'POST',
|
||||
data: { sessionKey, phone, captcha }
|
||||
}),
|
||||
profile: () => request<UserProfile>('/api/user/profile'),
|
||||
updateProfile: (data: Partial<UserProfile>) => request<UserProfile>('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
data
|
||||
profile: (force = false) => profileCache.load(profileKey(), () => request<UserProfile>('/api/user/profile').then(normalizeProfile), force).then(data => {
|
||||
publishProfile(data)
|
||||
return cloneProfile(data)
|
||||
}),
|
||||
updateProfile: async (data: Partial<UserProfile>) => {
|
||||
const key = profileKey()
|
||||
const profile = await request<UserProfile>('/api/user/profile', { method: 'PUT', data })
|
||||
return storeProfile(profile, key)
|
||||
},
|
||||
uploadAvatar,
|
||||
exchangeStatus: () => request<{ points: number; available: boolean; message: string }>('/api/points/exchange-status'),
|
||||
agreement: (type: string) => request<{ title: string; body: string }>(`/api/content/agreement/${type}`),
|
||||
@@ -111,7 +186,7 @@ export type UserProfile = {
|
||||
province: string
|
||||
city: string
|
||||
district: string
|
||||
points: number
|
||||
points?: number
|
||||
remainingEdits: number
|
||||
elementReport: {
|
||||
scores: ElementScore[]
|
||||
|
||||
Reference in new issue
Block a user