Files
jiapuapp/pages/profile/edit-profile.vue
T

560 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="profile-edit-page">
<ModulePageBackground module="profile" />
<view class="page-header"
><PageHeader title="编辑资料" custom-back @back="backToProfile"
/></view>
<view class="page-content">
<view v-if="loading" class="state-card"
><text>正在读取个人资料</text></view
>
<view v-else-if="profileLoadError" class="state-card">
<text>个人资料暂时无法读取</text>
<text>{{ profileLoadError }}</text>
<AppButton block label="重新读取" @click="loadProfile" />
</view>
<template v-else>
<view class="form-panel">
<view class="avatar-row">
<view class="avatar-summary">
<view class="avatar-preview">
<image
v-if="avatarPreviewUrl"
:src="avatarPreviewUrl"
mode="aspectFill"
/>
<AppAvatar v-else :sex="form.sex" />
</view>
<view class="avatar-summary__copy"
><text>头像</text><text>{{ avatarMessage }}</text></view
>
</view>
<button
class="upload-button"
:disabled="uploading || saving"
@click="uploadAvatar"
>
{{ uploading ? "上传中…" : "选择图片" }}
</button>
</view>
<text v-if="avatarError" class="field-error">{{ avatarError }}</text>
<view class="form-row"
><text>昵称</text
><input
v-model.trim="form.nickName"
maxlength="30"
placeholder="请输入昵称"
placeholder-class="placeholder"
:disabled="saving || uploading"
/></view>
<view class="form-row"
><text>真实姓名</text
><input
v-model.trim="form.realName"
maxlength="30"
placeholder="请输入真实姓名"
placeholder-class="placeholder"
:disabled="saving || uploading"
/></view>
<view class="form-row">
<text>性别</text>
<picker
:range="sexOptions"
range-key="label"
:value="sexIndex"
:disabled="saving || uploading"
@change="changeSex"
><view
class="picker-value"
:class="{ 'picker-value--placeholder': !form.sex }"
>{{ sexOptions[sexIndex].label }}</view
></picker
>
</view>
<view class="form-row">
<text>生日</text>
<picker
mode="date"
:value="form.birthday"
:disabled="saving || uploading"
@change="changeBirthday"
><view
class="picker-value"
:class="{ 'picker-value--placeholder': !form.birthday }"
>{{ form.birthday || "请选择生日" }}</view
></picker
>
</view>
<view class="form-row form-row--email"
><text>邮箱</text
><input
v-model.trim="form.email"
maxlength="100"
placeholder="请输入邮箱"
placeholder-class="placeholder"
:disabled="saving || uploading"
/></view>
</view>
<text class="form-note"
>资料可按需填写留空内容不会修改</text
>
<text v-if="saveError" class="save-error">{{ saveError }}</text>
<AppButton
block
:disabled="saving || uploading"
:label="saving ? '保存中…' : '保存资料'"
@click="saveProfile"
/>
</template>
</view>
<AppToast :visible="feedbackVisible" :message="feedbackMessage" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃资料修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
PROFILE_SEX_OPTIONS
} from "@/services/api/profile-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import {
finishPage,
handleBackPress,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const form = reactive({
nickName: "",
realName: "",
sex: "",
birthday: "",
email: "",
});
const original = reactive({
nickName: "",
realName: "",
sex: "",
birthday: "",
email: "",
avatar: null,
});
const avatarId = ref(null);
const avatarFileName = ref("");
const avatarPreviewUrl = ref("");
const loading = ref(true);
const profileLoadError = ref("");
const uploading = ref(false);
const avatarError = ref("");
const saving = ref(false);
const saveError = ref("");
const committedProfilePayload = ref("");
const feedbackVisible = ref(false);
const feedbackMessage = ref("");
const discardVisible = ref(false);
const profileReadController = createRequestController();
const avatarUploadController = createRequestController();
const profileSaveController = createRequestController();
let feedbackTimer = null;
let isPageActive = true;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const sexOptions = Object.freeze([
Object.freeze({ value: "", label: "请选择" }),
...PROFILE_SEX_OPTIONS,
]);
const sexIndex = computed(() => {
const index = sexOptions.findIndex((option) => option.value === form.sex);
return index === -1 ? 0 : index;
});
const avatarMessage = computed(() => {
if (avatarFileName.value) return `已选择 ${avatarFileName.value}`;
return avatarPreviewUrl.value ? "当前头像已设置" : "当前使用默认头像";
});
const profileSnapshot = computed(() => JSON.stringify({
nickName: form.nickName,
realName: form.realName,
sex: form.sex,
birthday: form.birthday,
email: form.email,
avatar: avatarId.value || original.avatar || null,
}));
const originalSnapshot = computed(() => JSON.stringify(original));
const isDirty = computed(() => profileSnapshot.value !== originalSnapshot.value);
const showFeedback = (message) => {
feedbackMessage.value = message;
feedbackVisible.value = true;
if (feedbackTimer) clearTimeout(feedbackTimer);
feedbackTimer = setTimeout(() => {
feedbackVisible.value = false;
feedbackTimer = null;
}, 2200);
};
const toBirthdayDate = (value) =>
/^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : "";
const loadProfile = async () => {
if (loading.value === false && (uploading.value || saving.value)) return;
loading.value = true;
profileLoadError.value = "";
try {
const profile = await profileApi.getProfile({ requestController: profileReadController });
if (!isPageActive) return;
Object.assign(form, {
nickName: profile.nickName,
realName: profile.realName,
sex: profile.sex,
birthday: toBirthdayDate(profile.birthday),
email: profile.email,
});
Object.assign(original, {
...form,
avatar: profile.avatarFile?.ossId || null,
});
committedProfilePayload.value = "";
avatarId.value = null;
avatarFileName.value = "";
avatarPreviewUrl.value = profile.avatarFile?.accessUrl || "";
} catch (error) {
if (isPageActive && !isRequestCancelled(error))
profileLoadError.value = getRequestErrorMessage(error, "请稍后重试");
} finally {
if (isPageActive) loading.value = false;
}
};
const changeBirthday = (event) => {
form.birthday = event?.detail?.value || "";
};
const changeSex = (event) => {
form.sex = sexOptions[Number(event?.detail?.value)]?.value || "";
};
const uploadAvatar = async () => {
if (uploading.value || saving.value) return;
uploading.value = true;
avatarError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: avatarUploadController,
});
if (!isPageActive) return;
avatarId.value = receipt.ossId;
avatarFileName.value = receipt.fileName || "新头像";
avatarPreviewUrl.value = receipt.thumbnailUrl || receipt.url || "";
} catch (error) {
if (
isPageActive &&
!isImagePickCancelled(error) &&
!isRequestCancelled(error)
) {
avatarError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
}
} finally {
if (isPageActive) uploading.value = false;
}
};
const buildUpdatePayload = () => {
const profileChanges = {};
for (const field of ["nickName", "realName", "sex", "email"]) {
if (form[field] && form[field] !== original[field])
profileChanges[field] = form[field];
}
if (form.birthday && form.birthday !== original.birthday)
profileChanges.birthday = form.birthday;
if (
avatarId.value &&
String(avatarId.value) !== String(original.avatar || "")
)
profileChanges.avatar = avatarId.value;
return profileChanges;
};
const saveProfile = async () => {
if (saving.value || uploading.value) return;
const payload = buildUpdatePayload();
if (Object.keys(payload).length === 0) {
showFeedback("没有需要保存的修改");
return;
}
saving.value = true;
saveError.value = "";
const payloadFingerprint = JSON.stringify(payload);
try {
if (committedProfilePayload.value !== payloadFingerprint) {
await profileApi.updateProfile(payload, {
requestController: profileSaveController,
});
if (!isPageActive) return;
committedProfilePayload.value = payloadFingerprint;
for (const field of ["nickName", "realName", "sex", "birthday", "email"]) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
original[field] = payload[field];
}
}
if (Object.prototype.hasOwnProperty.call(payload, "avatar")) {
original.avatar = payload.avatar;
avatarId.value = null;
}
}
await finishPage(
"M01",
{},
{ operation: "profile-updated", refresh: true },
);
} catch (error) {
if (isPageActive && !isRequestCancelled(error))
saveError.value = committedProfilePayload.value === payloadFingerprint
? "资料已经保存,但页面返回失败。请再次点击保存重试返回。"
: getRequestErrorMessage(error, "保存失败,请稍后重试");
} finally {
if (isPageActive) saving.value = false;
}
};
const backToProfile = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: saving.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onLoad(loadProfile);
onBackPress((event) => handleBackPress(event, backToProfile));
onUnload(() => {
isPageActive = false;
profileReadController.abort();
avatarUploadController.abort();
profileSaveController.abort();
if (feedbackTimer) clearTimeout(feedbackTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.profile-edit-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
flex: 1;
padding: 24rpx 30rpx calc(72rpx + env(safe-area-inset-bottom));
}
.state-card,
.form-panel {
@include adaptive-profile-content;
}
.state-card {
box-sizing: border-box;
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card text {
display: block;
}
.state-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.state-card text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.form-panel {
padding: 16rpx 30rpx 22rpx;
}
.avatar-row,
.form-row {
display: grid;
grid-template-columns: 142rpx minmax(0, 1fr);
min-height: 92rpx;
align-items: center;
gap: 16rpx;
border-bottom: 1rpx solid rgba(181, 137, 63, 0.42);
}
.avatar-row {
grid-template-columns: minmax(0, 1fr) 180rpx;
}
.avatar-summary {
display: flex;
min-width: 0;
align-items: center;
gap: 14rpx;
}
.avatar-preview {
width: 72rpx;
height: 72rpx;
flex: 0 0 auto;
overflow: hidden;
border: 2rpx solid rgba(181, 137, 63, 0.58);
border-radius: 50%;
background: #fff8ea;
}
.avatar-preview image,
.avatar-preview .app-avatar {
display: block;
width: 100%;
height: 100%;
}
.avatar-summary__copy {
min-width: 0;
}
.avatar-summary__copy text {
display: block;
}
.avatar-summary__copy text:first-child,
.form-row > text {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.avatar-summary__copy text:last-child {
margin-top: 4rpx;
color: $ink-muted;
font-size: clamp(13px, 19rpx, 16px);
overflow-wrap: anywhere;
}
.upload-button {
display: flex;
min-height: 88rpx;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
text-align: right;
}
.form-row input,
.picker-value {
width: auto;
min-width: 0;
min-height: 80rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
}
.form-row.form-row--email {
grid-template-columns: minmax(0, 1fr);
min-height: 132rpx;
align-content: center;
gap: 4rpx;
padding: 14rpx 0 16rpx;
}
.form-row--email input {
width: 100%;
min-height: 56rpx;
font-size: clamp(13px, 20rpx, 16px);
letter-spacing: 0;
text-align: left;
}
.picker-value {
display: flex;
align-items: center;
justify-content: flex-end;
}
.picker-value--placeholder,
.placeholder {
color: #ab9a86;
}
.field-error,
.save-error,
.form-note {
display: block;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.5;
}
.field-error,
.save-error {
color: #b42318;
}
.field-error {
margin-top: 8rpx;
text-align: right;
}
.form-note {
margin: 16rpx 4rpx 0;
}
.save-error {
margin: 12rpx 4rpx 0;
}
.page-content > .app-button {
margin-top: 24rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 22rpx;
padding-left: 22rpx;
}
.form-panel {
padding-right: 24rpx;
padding-left: 24rpx;
}
.avatar-row,
.form-row {
grid-template-columns: 116rpx minmax(0, 1fr);
gap: 12rpx;
}
.avatar-row {
grid-template-columns: minmax(0, 1fr) 160rpx;
}
}
</style>