diff --git a/docs/api/fileUpload.md b/docs/api/fileUpload.md new file mode 100644 index 0000000..40dcd74 --- /dev/null +++ b/docs/api/fileUpload.md @@ -0,0 +1,31 @@ +# fileUpload.ts + +**路径**:`src/api/fileUpload.ts` + +## 功能用途 + +文件上传接口封装,用于头像等文件上传场景。先调用 upload 上传文件,获取返回的 URL 后可供 setSelfHeaderImg 设置头像。 + +## 导出 + +| 导出 | 类型 | 说明 | +|------|------|------| +| `upload(file, authHeaders)` | `Promise` | 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,或扩展分片、断点续传等 diff --git a/src/api/fileUpload.ts b/src/api/fileUpload.ts new file mode 100644 index 0000000..cacd8c8 --- /dev/null +++ b/src/api/fileUpload.ts @@ -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, +): Promise { + return uploadFile('/fileUploadAndDownload/upload', file, { + headers: authHeaders, + }) +} diff --git a/src/api/request.ts b/src/api/request.ts index 6d7263e..21c9269 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -108,6 +108,33 @@ export async function post( return res.json() as Promise } +/** + * 带 x-token 等自定义头的文件上传(multipart/form-data) + */ +export async function uploadFile( + path: string, + file: File, + config?: RequestConfig, +): Promise { + const url = new URL(path, BASE_URL || window.location.origin) + const form = new FormData() + form.append('file', file) + const headers: Record = { + 'Accept-Language': i18n.global.locale.value as string, + ...config?.headers, + } + delete (headers as Record)['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 +} + /** * 带 x-token 等自定义头的 PUT 请求 */ diff --git a/src/api/user.ts b/src/api/user.ts index 4e6c973..b29b548 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -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, + req: ChangeSelfHeaderImgReq, +): Promise { + return put('/user/setSelfHeaderImg', req, { + headers: authHeaders, + }) +} + /** * PUT /user/setSelfUsername * 修改自身用户名 diff --git a/src/locales/en.json b/src/locales/en.json index fa3c932..9ca445d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/ja.json b/src/locales/ja.json index d09bab1..58ebfcf 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -293,7 +293,10 @@ "nameInvalidFormat": "ユーザー名は半角英字、数字、アンダースコア(_)のみで入力してください", "nameTooShort": "ユーザー名は {min} 文字以上にしてください", "nameFormatHint": "半角英字、数字、アンダースコア(_)のみ使用できます(a-z / 0-9 / _)", - "nameSaved": "ユーザー名を更新しました" + "nameSaved": "ユーザー名を更新しました", + "changeAvatar": "アバターを変更", + "avatarUploadSuccess": "アバターを更新しました", + "avatarUploadFailed": "アバターのアップロードに失敗しました" }, "deposit": { "title": "入金", diff --git a/src/locales/ko.json b/src/locales/ko.json index c72ee3d..bc4b665 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -293,7 +293,10 @@ "nameInvalidFormat": "사용자 이름은 영문자, 숫자, 밑줄(_)만 사용할 수 있습니다", "nameTooShort": "사용자 이름은 최소 {min}자 이상이어야 합니다", "nameFormatHint": "영문자, 숫자, 밑줄(_)만 사용할 수 있습니다 (a-z / 0-9 / _)", - "nameSaved": "사용자 이름이 업데이트되었습니다" + "nameSaved": "사용자 이름이 업데이트되었습니다", + "changeAvatar": "아바타 변경", + "avatarUploadSuccess": "아바타가 업데이트되었습니다", + "avatarUploadFailed": "아바타 업로드에 실패했습니다" }, "deposit": { "title": "입금", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index b7d1409..202b1a4 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -293,7 +293,10 @@ "nameInvalidFormat": "用户名只能包含字母、数字与下划线(a-z / 0-9 / _)", "nameTooShort": "用户名长度至少 {min} 位", "nameFormatHint": "只能包含字母、数字与下划线(a-z / 0-9 / _)", - "nameSaved": "用户名已更新" + "nameSaved": "用户名已更新", + "changeAvatar": "更换头像", + "avatarUploadSuccess": "头像已更新", + "avatarUploadFailed": "头像上传失败" }, "deposit": { "title": "入金", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index b2f43c5..db1a1ff 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -293,7 +293,10 @@ "nameInvalidFormat": "使用者名稱只能包含字母、數字與底線(_)", "nameTooShort": "使用者名稱長度至少 {min} 個字元", "nameFormatHint": "只能包含字母、數字與底線(a-z / 0-9 / _)", - "nameSaved": "使用者名稱已更新" + "nameSaved": "使用者名稱已更新", + "changeAvatar": "更換頭像", + "avatarUploadSuccess": "頭像已更新", + "avatarUploadFailed": "頭像上傳失敗" }, "deposit": { "title": "入金", diff --git a/src/views/Profile.vue b/src/views/Profile.vue index 019ff91..73b89a8 100644 --- a/src/views/Profile.vue +++ b/src/views/Profile.vue @@ -3,10 +3,30 @@
-
- - {{ avatarText }} -
+
{{ displayName }}
{{ t('profile.uidLabel', { uid: userIdText }) }}
@@ -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(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 {