新增:上传头像
This commit is contained in:
parent
018e92afcf
commit
4c0cf38bac
31
docs/api/fileUpload.md
Normal file
31
docs/api/fileUpload.md
Normal file
@ -0,0 +1,31 @@
|
||||
# fileUpload.ts
|
||||
|
||||
**路径**:`src/api/fileUpload.ts`
|
||||
|
||||
## 功能用途
|
||||
|
||||
文件上传接口封装,用于头像等文件上传场景。先调用 upload 上传文件,获取返回的 URL 后可供 setSelfHeaderImg 设置头像。
|
||||
|
||||
## 导出
|
||||
|
||||
| 导出 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `upload(file, authHeaders)` | `Promise<UploadResponse>` | POST /fileUploadAndDownload/upload,multipart/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
37
src/api/fileUpload.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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 请求
|
||||
*/
|
||||
|
||||
@ -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
|
||||
* 修改自身用户名
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -293,7 +293,10 @@
|
||||
"nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア(_)のみで入力してください",
|
||||
"nameTooShort": "ユーザー名は {min} 文字以上にしてください",
|
||||
"nameFormatHint": "半角英字、数字、アンダースコア(_)のみ使用できます(a-z / 0-9 / _)",
|
||||
"nameSaved": "ユーザー名を更新しました"
|
||||
"nameSaved": "ユーザー名を更新しました",
|
||||
"changeAvatar": "アバターを変更",
|
||||
"avatarUploadSuccess": "アバターを更新しました",
|
||||
"avatarUploadFailed": "アバターのアップロードに失敗しました"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -293,7 +293,10 @@
|
||||
"nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다",
|
||||
"nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다",
|
||||
"nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)",
|
||||
"nameSaved": "사용자 이름이 업데이트되었습니다"
|
||||
"nameSaved": "사용자 이름이 업데이트되었습니다",
|
||||
"changeAvatar": "아바타 변경",
|
||||
"avatarUploadSuccess": "아바타가 업데이트되었습니다",
|
||||
"avatarUploadFailed": "아바타 업로드에 실패했습니다"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "입금",
|
||||
|
||||
@ -293,7 +293,10 @@
|
||||
"nameInvalidFormat": "用户名只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||
"nameTooShort": "用户名长度至少 {min} 位",
|
||||
"nameFormatHint": "只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||
"nameSaved": "用户名已更新"
|
||||
"nameSaved": "用户名已更新",
|
||||
"changeAvatar": "更换头像",
|
||||
"avatarUploadSuccess": "头像已更新",
|
||||
"avatarUploadFailed": "头像上传失败"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -293,7 +293,10 @@
|
||||
"nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)",
|
||||
"nameTooShort": "使用者名稱長度至少 {min} 個字元",
|
||||
"nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)",
|
||||
"nameSaved": "使用者名稱已更新"
|
||||
"nameSaved": "使用者名稱已更新",
|
||||
"changeAvatar": "更換頭像",
|
||||
"avatarUploadSuccess": "頭像已更新",
|
||||
"avatarUploadFailed": "頭像上傳失敗"
|
||||
},
|
||||
"deposit": {
|
||||
"title": "入金",
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user