新增:上传头像
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>
|
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 请求
|
* 带 x-token 等自定义头的 PUT 请求
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -125,6 +125,25 @@ export async function getUsdcBalance(
|
|||||||
return res
|
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
|
* PUT /user/setSelfUsername
|
||||||
* 修改自身用户名
|
* 修改自身用户名
|
||||||
|
|||||||
@ -293,7 +293,10 @@
|
|||||||
"nameInvalidFormat": "Username may only contain letters, numbers, and underscores",
|
"nameInvalidFormat": "Username may only contain letters, numbers, and underscores",
|
||||||
"nameTooShort": "Username must be at least {min} characters",
|
"nameTooShort": "Username must be at least {min} characters",
|
||||||
"nameFormatHint": "Only letters, numbers, and underscores are allowed (a-z / 0-9 / _)",
|
"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": {
|
"deposit": {
|
||||||
"title": "Deposit",
|
"title": "Deposit",
|
||||||
|
|||||||
@ -293,7 +293,10 @@
|
|||||||
"nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア(_)のみで入力してください",
|
"nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア(_)のみで入力してください",
|
||||||
"nameTooShort": "ユーザー名は {min} 文字以上にしてください",
|
"nameTooShort": "ユーザー名は {min} 文字以上にしてください",
|
||||||
"nameFormatHint": "半角英字、数字、アンダースコア(_)のみ使用できます(a-z / 0-9 / _)",
|
"nameFormatHint": "半角英字、数字、アンダースコア(_)のみ使用できます(a-z / 0-9 / _)",
|
||||||
"nameSaved": "ユーザー名を更新しました"
|
"nameSaved": "ユーザー名を更新しました",
|
||||||
|
"changeAvatar": "アバターを変更",
|
||||||
|
"avatarUploadSuccess": "アバターを更新しました",
|
||||||
|
"avatarUploadFailed": "アバターのアップロードに失敗しました"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -293,7 +293,10 @@
|
|||||||
"nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다",
|
"nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다",
|
||||||
"nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다",
|
"nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다",
|
||||||
"nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)",
|
"nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)",
|
||||||
"nameSaved": "사용자 이름이 업데이트되었습니다"
|
"nameSaved": "사용자 이름이 업데이트되었습니다",
|
||||||
|
"changeAvatar": "아바타 변경",
|
||||||
|
"avatarUploadSuccess": "아바타가 업데이트되었습니다",
|
||||||
|
"avatarUploadFailed": "아바타 업로드에 실패했습니다"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "입금",
|
"title": "입금",
|
||||||
|
|||||||
@ -293,7 +293,10 @@
|
|||||||
"nameInvalidFormat": "用户名只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
"nameInvalidFormat": "用户名只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||||
"nameTooShort": "用户名长度至少 {min} 位",
|
"nameTooShort": "用户名长度至少 {min} 位",
|
||||||
"nameFormatHint": "只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
"nameFormatHint": "只能包含字母、数字与下划线(a-z / 0-9 / _)",
|
||||||
"nameSaved": "用户名已更新"
|
"nameSaved": "用户名已更新",
|
||||||
|
"changeAvatar": "更换头像",
|
||||||
|
"avatarUploadSuccess": "头像已更新",
|
||||||
|
"avatarUploadFailed": "头像上传失败"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -293,7 +293,10 @@
|
|||||||
"nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)",
|
"nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)",
|
||||||
"nameTooShort": "使用者名稱長度至少 {min} 個字元",
|
"nameTooShort": "使用者名稱長度至少 {min} 個字元",
|
||||||
"nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)",
|
"nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)",
|
||||||
"nameSaved": "使用者名稱已更新"
|
"nameSaved": "使用者名稱已更新",
|
||||||
|
"changeAvatar": "更換頭像",
|
||||||
|
"avatarUploadSuccess": "頭像已更新",
|
||||||
|
"avatarUploadFailed": "頭像上傳失敗"
|
||||||
},
|
},
|
||||||
"deposit": {
|
"deposit": {
|
||||||
"title": "入金",
|
"title": "入金",
|
||||||
|
|||||||
@ -3,10 +3,30 @@
|
|||||||
<div class="profile-screen">
|
<div class="profile-screen">
|
||||||
<section class="card profile-card">
|
<section class="card profile-card">
|
||||||
<div class="top-row">
|
<div class="top-row">
|
||||||
|
<label class="avatar-wrap" :aria-label="t('profile.changeAvatar')">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="avatar-input"
|
||||||
|
@change="onAvatarFileChange"
|
||||||
|
/>
|
||||||
<div class="avatar">
|
<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" />
|
<img v-if="avatarImage" :src="avatarImage" :alt="displayName" class="avatar-img" />
|
||||||
<span v-else>{{ avatarText }}</span>
|
<span v-else>{{ avatarText }}</span>
|
||||||
|
<span class="avatar-overlay">
|
||||||
|
<v-icon size="18">mdi-camera</v-icon>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
<div class="info-col">
|
<div class="info-col">
|
||||||
<div class="name-text">{{ displayName }}</div>
|
<div class="name-text">{{ displayName }}</div>
|
||||||
<div class="acc-text">{{ t('profile.uidLabel', { uid: userIdText }) }}</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 { useLocaleStore } from '../stores/locale'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { useUserStore, type UserInfo } from '../stores/user'
|
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'
|
import type { LocaleCode } from '@/plugins/i18n'
|
||||||
|
|
||||||
interface SettingItem {
|
interface SettingItem {
|
||||||
@ -131,6 +152,7 @@ const editNameDialogOpen = ref(false)
|
|||||||
const editingName = ref('')
|
const editingName = ref('')
|
||||||
const nameError = ref<string | null>(null)
|
const nameError = ref<string | null>(null)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const avatarUploading = ref(false)
|
||||||
|
|
||||||
const currentLocaleLabel = computed(() => {
|
const currentLocaleLabel = computed(() => {
|
||||||
return (
|
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 MIN_USER_NAME_LEN = 2
|
||||||
const MAX_USER_NAME_LEN = 20
|
const MAX_USER_NAME_LEN = 20
|
||||||
|
|
||||||
@ -374,6 +433,21 @@ onMounted(() => {
|
|||||||
gap: 12px;
|
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 {
|
.avatar {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@ -387,6 +461,27 @@ onMounted(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
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 {
|
.avatar-img {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user