新增:上传头像

This commit is contained in:
ivan 2026-03-22 17:47:44 +08:00
parent 018e92afcf
commit 4c0cf38bac
10 changed files with 234 additions and 10 deletions

31
docs/api/fileUpload.md Normal file
View File

@ -0,0 +1,31 @@
# fileUpload.ts
**路径**`src/api/fileUpload.ts`
## 功能用途
文件上传接口封装,用于头像等文件上传场景。先调用 upload 上传文件,获取返回的 URL 后可供 setSelfHeaderImg 设置头像。
## 导出
| 导出 | 类型 | 说明 |
|------|------|------|
| `upload(file, authHeaders)` | `Promise<UploadResponse>` | POST /fileUploadAndDownload/uploadmultipart/form-data |
| `ExaFileUploadAndDownload` | interface | 上传返回的 data.file 结构,含 url、key、name 等 |
## 使用方式
```typescript
import { upload } from '@/api/fileUpload'
import { setSelfHeaderImg } from '@/api/user'
const res = await upload(file, userStore.getAuthHeaders())
const fileUrl = res.data?.file?.url
if (fileUrl) {
await setSelfHeaderImg(authHeaders, { headerImg: fileUrl })
}
```
## 扩展方式
- 支持其他上传场景:可复用 upload或扩展分片、断点续传等

37
src/api/fileUpload.ts Normal file
View File

@ -0,0 +1,37 @@
import { uploadFile } from './request'
import type { ApiResponse } from './types'
/** 上传接口返回的 data.file */
export interface ExaFileUploadAndDownload {
ID?: number
classId?: number
createdAt?: string
key?: string
name?: string
tag?: string
updatedAt?: string
/** 文件地址,用于头像等 */
url?: string
}
export interface UploadResponse {
code: number
data?: {
file?: ExaFileUploadAndDownload
}
msg?: string
}
/**
* POST /fileUploadAndDownload/upload
* multipart/form-data file
* ApiKeyAuth
*/
export async function upload(
file: File,
authHeaders: Record<string, string>,
): Promise<UploadResponse> {
return uploadFile<UploadResponse>('/fileUploadAndDownload/upload', file, {
headers: authHeaders,
})
}

View File

@ -108,6 +108,33 @@ export async function post<T = unknown>(
return res.json() as Promise<T>
}
/**
* x-token multipart/form-data
*/
export async function uploadFile<T = unknown>(
path: string,
file: File,
config?: RequestConfig,
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)
const form = new FormData()
form.append('file', file)
const headers: Record<string, string> = {
'Accept-Language': i18n.global.locale.value as string,
...config?.headers,
}
delete (headers as Record<string, unknown>)['Content-Type']
const res = await fetch(url.toString(), {
method: 'POST',
headers,
body: form,
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json() as Promise<T>
}
/**
* x-token PUT
*/

View File

@ -125,6 +125,25 @@ export async function getUsdcBalance(
return res
}
/**
* PUT /user/setSelfHeaderImg
*
* Body: { headerImg: string } URL /fileUploadAndDownload/upload data.file.url
* ApiKeyAuth
*/
export interface ChangeSelfHeaderImgReq {
headerImg: string
}
export async function setSelfHeaderImg(
authHeaders: Record<string, string>,
req: ChangeSelfHeaderImgReq,
): Promise<ApiResponse> {
return put<ApiResponse>('/user/setSelfHeaderImg', req, {
headers: authHeaders,
})
}
/**
* PUT /user/setSelfUsername
*

View File

@ -293,7 +293,10 @@
"nameInvalidFormat": "Username may only contain letters, numbers, and underscores",
"nameTooShort": "Username must be at least {min} characters",
"nameFormatHint": "Only letters, numbers, and underscores are allowed (a-z / 0-9 / _)",
"nameSaved": "Username updated"
"nameSaved": "Username updated",
"changeAvatar": "Change avatar",
"avatarUploadSuccess": "Avatar updated",
"avatarUploadFailed": "Failed to upload avatar"
},
"deposit": {
"title": "Deposit",

View File

@ -293,7 +293,10 @@
"nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア_のみで入力してください",
"nameTooShort": "ユーザー名は {min} 文字以上にしてください",
"nameFormatHint": "半角英字、数字、アンダースコア_のみ使用できますa-z / 0-9 / _",
"nameSaved": "ユーザー名を更新しました"
"nameSaved": "ユーザー名を更新しました",
"changeAvatar": "アバターを変更",
"avatarUploadSuccess": "アバターを更新しました",
"avatarUploadFailed": "アバターのアップロードに失敗しました"
},
"deposit": {
"title": "入金",

View File

@ -293,7 +293,10 @@
"nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다",
"nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다",
"nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)",
"nameSaved": "사용자 이름이 업데이트되었습니다"
"nameSaved": "사용자 이름이 업데이트되었습니다",
"changeAvatar": "아바타 변경",
"avatarUploadSuccess": "아바타가 업데이트되었습니다",
"avatarUploadFailed": "아바타 업로드에 실패했습니다"
},
"deposit": {
"title": "입금",

View File

@ -293,7 +293,10 @@
"nameInvalidFormat": "用户名只能包含字母、数字与下划线a-z / 0-9 / _",
"nameTooShort": "用户名长度至少 {min} 位",
"nameFormatHint": "只能包含字母、数字与下划线a-z / 0-9 / _",
"nameSaved": "用户名已更新"
"nameSaved": "用户名已更新",
"changeAvatar": "更换头像",
"avatarUploadSuccess": "头像已更新",
"avatarUploadFailed": "头像上传失败"
},
"deposit": {
"title": "入金",

View File

@ -293,7 +293,10 @@
"nameInvalidFormat": "使用者名稱只能包含字母、數字與底線_",
"nameTooShort": "使用者名稱長度至少 {min} 個字元",
"nameFormatHint": "只能包含字母、數字與底線a-z / 0-9 / _",
"nameSaved": "使用者名稱已更新"
"nameSaved": "使用者名稱已更新",
"changeAvatar": "更換頭像",
"avatarUploadSuccess": "頭像已更新",
"avatarUploadFailed": "頭像上傳失敗"
},
"deposit": {
"title": "入金",

View File

@ -3,10 +3,30 @@
<div class="profile-screen">
<section class="card profile-card">
<div class="top-row">
<div class="avatar">
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
<span v-else>{{ avatarText }}</span>
</div>
<label class="avatar-wrap" :aria-label="t('profile.changeAvatar')">
<input
type="file"
accept="image/*"
class="avatar-input"
@change="onAvatarFileChange"
/>
<div class="avatar">
<v-progress-circular
v-if="avatarUploading"
indeterminate
size="32"
width="2"
class="avatar-loading"
/>
<template v-else>
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
<span v-else>{{ avatarText }}</span>
<span class="avatar-overlay">
<v-icon size="18">mdi-camera</v-icon>
</span>
</template>
</div>
</label>
<div class="info-col">
<div class="name-text">{{ displayName }}</div>
<div class="acc-text">{{ t('profile.uidLabel', { uid: userIdText }) }}</div>
@ -110,7 +130,8 @@ import { useI18n } from 'vue-i18n'
import { useLocaleStore } from '../stores/locale'
import { useToastStore } from '../stores/toast'
import { useUserStore, type UserInfo } from '../stores/user'
import { setSelfUsername } from '@/api/user'
import { setSelfUsername, setSelfHeaderImg } from '@/api/user'
import { upload } from '@/api/fileUpload'
import type { LocaleCode } from '@/plugins/i18n'
interface SettingItem {
@ -131,6 +152,7 @@ const editNameDialogOpen = ref(false)
const editingName = ref('')
const nameError = ref<string | null>(null)
const isSaving = ref(false)
const avatarUploading = ref(false)
const currentLocaleLabel = computed(() => {
return (
@ -310,6 +332,43 @@ async function saveName() {
}
}
async function onAvatarFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !file.type.startsWith('image/')) return
if (!userStore.isLoggedIn) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
const authHeaders = userStore.getAuthHeaders()
if (!authHeaders) {
toastStore.show(t('error.pleaseLogin'), 'error')
return
}
avatarUploading.value = true
try {
const uploadRes = await upload(file, authHeaders)
const fileUrl = uploadRes.data?.file?.url
if (!fileUrl || (uploadRes.code !== 0 && uploadRes.code !== 200)) {
toastStore.show(uploadRes.msg || t('profile.avatarUploadFailed'), 'error')
return
}
const setRes = await setSelfHeaderImg(authHeaders, { headerImg: fileUrl })
if (setRes.code !== 0 && setRes.code !== 200) {
toastStore.show(setRes.msg || t('profile.avatarUploadFailed'), 'error')
return
}
await userStore.fetchUserInfo()
toastStore.show(t('profile.avatarUploadSuccess'))
} catch (err) {
const msg = err instanceof Error ? err.message : t('profile.avatarUploadFailed')
toastStore.show(msg, 'error')
} finally {
avatarUploading.value = false
input.value = ''
}
}
const MIN_USER_NAME_LEN = 2
const MAX_USER_NAME_LEN = 20
@ -374,6 +433,21 @@ onMounted(() => {
gap: 12px;
}
.avatar-wrap {
cursor: pointer;
display: block;
flex-shrink: 0;
}
.avatar-input {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
z-index: -1;
}
.avatar {
width: 56px;
height: 56px;
@ -387,6 +461,27 @@ onMounted(() => {
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.avatar-loading {
color: #ffffff !important;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity 0.2s;
}
.avatar-wrap:hover .avatar-overlay {
opacity: 1;
}
.avatar-img {