feat: 支持移动端上传本地照片作为自定义头像,并允许公开读取头像
This commit is contained in:
@@ -65,7 +65,7 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
response.BadRequest(c, "文件 key 不正确")
|
response.BadRequest(c, "文件 key 不正确")
|
||||||
return
|
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", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
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)
|
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||||
}
|
}
|
||||||
return &UploadDTO{
|
return &UploadDTO{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
|||||||
|
|
||||||
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/api/wallet";
|
import { fetchWalletBalance, fetchWalletLedger, type WalletLedger } from "@/api/wallet";
|
||||||
import { formatDateMinute } from "@/utils/time";
|
import { formatDateMinute } from "@/utils/time";
|
||||||
|
import { uploadFile } from "@/api/files";
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,6 +23,38 @@ const profileForm = reactive({
|
|||||||
avatar_url: "",
|
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 showLedgers = ref(false);
|
||||||
const loadingLedgers = ref(false);
|
const loadingLedgers = ref(false);
|
||||||
@@ -191,6 +224,14 @@ const maskedPhone = computed(() =>
|
|||||||
: "登录后查看手机号"
|
: "登录后查看手机号"
|
||||||
);
|
);
|
||||||
const avatarText = computed(() => displayName.value.slice(0, 1));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -208,7 +249,7 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
|
|||||||
<div class="profile-hero-content">
|
<div class="profile-hero-content">
|
||||||
<div class="avatar-wrap">
|
<div class="avatar-wrap">
|
||||||
<div class="avatar-circle">
|
<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>
|
<span v-else>{{ avatarText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,14 +438,38 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="profile-preview">
|
<div class="profile-preview">
|
||||||
<div class="profile-preview-avatar">
|
<div class="profile-preview-avatar clickable" @click="triggerAvatarUpload">
|
||||||
<img v-if="profileForm.avatar_url" :src="profileForm.avatar_url" alt="" />
|
<img v-if="profileForm.avatar_url" :src="resolveAvatarURL(profileForm.avatar_url)" alt="" />
|
||||||
<span v-else>{{ (profileForm.nickname || defaultName).slice(0, 1) }}</span>
|
<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>
|
<div class="preview-info">
|
||||||
<strong>{{ profileForm.nickname || defaultName }}</strong>
|
<strong>{{ profileForm.nickname || defaultName }}</strong>
|
||||||
<p>{{ maskedPhone }}</p>
|
<p>{{ maskedPhone }}</p>
|
||||||
|
<van-button
|
||||||
|
size="mini"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
round
|
||||||
|
icon="photograph"
|
||||||
|
class="upload-btn"
|
||||||
|
:loading="uploadingAvatar"
|
||||||
|
@click="triggerAvatarUpload"
|
||||||
|
>
|
||||||
|
上传本地照片
|
||||||
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 隐藏的文件选择框 -->
|
||||||
|
<input
|
||||||
|
ref="avatarFileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleAvatarFileChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="editor-field">
|
<label class="editor-field">
|
||||||
@@ -907,7 +972,45 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
|
|||||||
object-fit: cover;
|
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;
|
display: block;
|
||||||
max-width: 230px;
|
max-width: 230px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -917,12 +1020,17 @@ const avatarText = computed(() => displayName.value.slice(0, 1));
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-preview p {
|
.preview-info p {
|
||||||
margin: 4px 0 0;
|
margin: 0;
|
||||||
color: #7b8798;
|
color: #7b8798;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-field {
|
.editor-field {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user