feat: 支持移动端上传本地照片作为自定义头像,并允许公开读取头像
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user