feat: 支持移动端上传本地照片作为自定义头像,并允许公开读取头像

This commit is contained in:
yml
2026-05-25 09:28:54 +08:00
parent 5d8a009935
commit de4a4ac50e
3 changed files with 117 additions and 9 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
response.BadRequest(c, "文件 key 不正确")
return
}
if publicOnly && !strings.HasPrefix(key, "home-banner/") {
if publicOnly && !strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "avatar/") {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
return nil, err
}
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" {
if scene == "home-banner" || scene == "avatar" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return &UploadDTO{
+115 -7
View File
@@ -7,6 +7,7 @@ import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/api/wallet";
import { formatDateMinute } from "@/utils/time";
import { uploadFile } from "@/api/files";
const session = useSessionStore();
const router = useRouter();
@@ -22,6 +23,38 @@ const profileForm = reactive({
avatar_url: "",
});
const avatarFileInput = ref<HTMLInputElement | null>(null);
const uploadingAvatar = ref(false);
function triggerAvatarUpload() {
avatarFileInput.value?.click();
}
async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
uploadingAvatar.value = true;
const toast = showToast({
type: "loading",
message: "上传中...",
forbidClick: true,
duration: 0,
});
try {
const uploaded = await uploadFile(file, "avatar");
profileForm.avatar_url = uploaded.url;
showToast({ message: "上传成功", icon: "passed" });
} catch (error) {
showToast({ message: readError(error, "上传失败"), icon: "cross" });
} finally {
uploadingAvatar.value = false;
}
}
// 收支明细列表
const showLedgers = ref(false);
const loadingLedgers = ref(false);
@@ -191,6 +224,14 @@ const maskedPhone = computed(() =>
: "登录后查看手机号"
);
const avatarText = computed(() => displayName.value.slice(0, 1));
function resolveAvatarURL(url: string | undefined | null) {
if (!url) return "";
if (url.includes("/api/files/object?key=avatar/")) {
return url.replace("/api/files/object", "/api/public/files/object");
}
return url;
}
</script>
<template>
@@ -208,7 +249,7 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
<div class="profile-hero-content">
<div class="avatar-wrap">
<div class="avatar-circle">
<img v-if="session.avatarUrl" :src="session.avatarUrl" alt="" />
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
<span v-else>{{ avatarText }}</span>
</div>
</div>
@@ -397,14 +438,38 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
</header>
<div class="profile-preview">
<div class="profile-preview-avatar">
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" alt="" />
<div class="profile-preview-avatar clickable" @click="triggerAvatarUpload">
<img v-if="profileForm.avatar_url" :src="resolveAvatarURL(profileForm.avatar_url)" alt="" />
<span v-else>{{ (profileForm.nickname || defaultName).slice(0, 1) }}</span>
<div class="avatar-upload-overlay">
<van-icon name="photograph" :size="16" />
</div>
<van-loading v-if="uploadingAvatar" size="20px" class="avatar-loading" />
</div>
<div>
<div class="preview-info">
<strong>{{ profileForm.nickname || defaultName }}</strong>
<p>{{ maskedPhone }}</p>
<van-button
size="mini"
type="primary"
plain
round
icon="photograph"
class="upload-btn"
:loading="uploadingAvatar"
@click="triggerAvatarUpload"
>
上传本地照片
</van-button>
</div>
<!-- 隐藏的文件选择框 -->
<input
ref="avatarFileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleAvatarFileChange"
/>
</div>
<label class="editor-field">
@@ -907,7 +972,45 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
object-fit: cover;
}
.profile-preview strong {
.profile-preview-avatar.clickable {
cursor: pointer;
position: relative;
}
.avatar-upload-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
opacity: 0;
transition: opacity 0.2s ease;
}
.profile-preview-avatar.clickable:hover .avatar-upload-overlay,
.profile-preview-avatar.clickable:active .avatar-upload-overlay {
opacity: 1;
}
.avatar-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
}
.preview-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.preview-info strong {
display: block;
max-width: 230px;
overflow: hidden;
@@ -917,12 +1020,17 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
white-space: nowrap;
}
.profile-preview p {
margin: 4px 0 0;
.preview-info p {
margin: 0;
color: #7b8798;
font-size: 12px;
}
.upload-btn {
margin-top: 4px;
font-weight: 600;
}
.editor-field {
display: block;
margin-bottom: 12px;