Files
jiapuapp/pages/profile/m07-feedback.vue
T
2026-07-23 08:24:07 +08:00

264 lines
13 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.
<!-- 页面编号M-07用途提交意见反馈 -->
<template>
<view
class="feedback-page"
:class="{
'feedback-state--ready': feedbackState === 'ready',
'feedback-state--submitting': feedbackState === 'submitting',
'feedback-state--success': feedbackState === 'success',
'feedback-state--error': feedbackState === 'error',
'feedback-state--uncertain': feedbackState === 'uncertain',
}"
>
<ModulePageBackground module="profile" />
<view class="page-layer"><PageHeader title="意见反馈" custom-back @back="requestBack" /></view>
<view class="page-content page-layer">
<text class="lead">你的建议会帮助我们把家谱做得更好</text>
<view class="type-field" role="group" aria-label="反馈类型选填" aria-describedby="feedback-type-note">
<text id="feedback-type-note" class="field-note">反馈类型为选填可再次点击取消选择</text>
<view class="type-grid">
<button
v-for="type in feedbackTypes"
:key="type"
class="type-chip"
:class="{ active: feedbackForm.feedbackType === type }"
:aria-pressed="feedbackForm.feedbackType === type"
:disabled="feedbackState === 'submitting'"
@click="selectFeedbackType(type)"
>{{ type }}</button>
</view>
</view>
<view class="form-panel">
<textarea
v-model.trim="feedbackForm.feedbackContent"
auto-height
maxlength="500"
aria-label="问题描述"
aria-required="true"
:aria-describedby="errors.feedbackContent ? 'feedback-content-help feedback-content-error' : 'feedback-content-help'"
:aria-invalid="Boolean(errors.feedbackContent)"
:disabled="feedbackState === 'submitting'"
:focus="feedbackContentFocused"
placeholder="请说明遇到的问题、操作步骤或建议"
@input="errors.feedbackContent = ''"
@blur="feedbackContentFocused = false"
/>
<text class="counter">{{ feedbackForm.feedbackContent.length }}/500</text>
<text id="feedback-content-help" class="field-note">请勿填写密码验证码等敏感信息</text>
<text v-if="errors.feedbackContent" id="feedback-content-error" class="field-error">{{ errors.feedbackContent }}</text>
<view class="contact-row">
<text>联系方式</text>
<input
v-model.trim="feedbackForm.contactInfo"
maxlength="50"
aria-label="联系方式选填"
:disabled="feedbackState === 'submitting'"
placeholder="手机号或邮箱(选填)"
/>
</view>
</view>
<text class="privacy-note">联系方式选填便于需要时核实本次反馈</text>
<view
v-if="feedbackResult"
class="feedback-result"
:class="{
'feedback-result--success': feedbackResultTone === 'success',
'feedback-result--error': feedbackResultTone === 'error',
'feedback-result--uncertain': feedbackResultTone === 'uncertain',
}"
:role="feedbackResultTone === 'error' ? 'alert' : 'status'"
:aria-live="feedbackResultTone === 'error' ? 'assertive' : 'polite'"
>{{ feedbackResult }}</view>
<AppButton block :disabled="submitDisabled" :label="submitLabel" @click="submitFeedback" />
</view>
<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, nextTick, reactive, ref, watch } from "vue";
import { onBackPress, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
const feedbackTypes = ["功能问题", "使用建议", "内容纠错", "其他"];
const feedbackForm = reactive({ feedbackType: "", feedbackContent: "", contactInfo: "" });
const errors = reactive({ feedbackContent: "" });
const feedbackState = ref("ready");
const feedbackResult = ref("");
const feedbackResultTone = ref("");
const feedbackContentFocused = ref(false);
const discardVisible = ref(false);
const lastSubmittedSnapshot = ref("");
const lastUncertainSnapshot = ref("");
let pageActive = true;
const requestController = createRequestController();
const normalizedForm = computed(() => ({
feedbackType: feedbackForm.feedbackType.trim(),
feedbackContent: feedbackForm.feedbackContent.trim(),
contactInfo: feedbackForm.contactInfo.trim(),
}));
const formSnapshot = computed(() => JSON.stringify(normalizedForm.value));
const baseline = ref(formSnapshot.value);
const isDirty = computed(() => formSnapshot.value !== baseline.value);
const submitDisabled = computed(() =>
feedbackState.value === "submitting" ||
(Boolean(lastSubmittedSnapshot.value) && formSnapshot.value === lastSubmittedSnapshot.value) ||
(Boolean(lastUncertainSnapshot.value) && formSnapshot.value === lastUncertainSnapshot.value),
);
const submitLabel = computed(() => {
if (feedbackState.value === "submitting") return "正在提交";
if (feedbackState.value === "success") return "反馈已提交";
if (feedbackState.value === "uncertain") return "提交结果待确认";
if (feedbackState.value === "error") return "重新提交";
return "提交反馈";
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const validateFeedback = () => {
errors.feedbackContent = feedbackForm.feedbackContent.trim() ? "" : "请填写问题描述";
if (errors.feedbackContent) {
feedbackContentFocused.value = false;
nextTick(() => {
if (pageActive) feedbackContentFocused.value = true;
});
}
return !errors.feedbackContent;
};
const selectFeedbackType = (type) => {
if (feedbackState.value === "submitting") return;
feedbackForm.feedbackType = feedbackForm.feedbackType === type ? "" : type;
};
const feedbackErrorMessage = (error) => {
if (error?.code === "WRITE_UNAVAILABLE") {
return "当前为本地预览模式,反馈未提交服务器。";
}
if (error?.httpStatus === 401) {
return "登录状态已失效,服务器未确认提交成功。请重新登录后再处理。";
}
return error?.message ? `服务器未确认提交成功:${error.message}` : "服务器未确认提交成功,请稍后再试。";
};
const submittedFeedbackMessage = "反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。";
const submittedWithPendingEditsMessage = "上一份反馈已提交,当前修改尚未提交。";
const uncertainFeedbackMessage = "提交结果可能未知。为避免重复记录,当前内容不能直接重提;请稍后确认或修改后提交新反馈。";
const uncertainWithPendingEditsMessage = "上一份反馈的提交结果仍待确认,当前修改尚未提交。";
const isFeedbackResultUncertain = (error) => {
if (["REQUEST_TIMEOUT", "NETWORK_ERROR", "RESPONSE_INVALID"].includes(error?.code)) {
return true;
}
if (error?.code !== "HTTP_ERROR") return false;
const status = error.httpStatus;
return (status >= 200 && status < 400) || status === 408 || status >= 500;
};
const submitFeedback = async () => {
if (!validateFeedback() || feedbackState.value === "submitting") return;
if (
(lastSubmittedSnapshot.value && formSnapshot.value === lastSubmittedSnapshot.value) ||
(lastUncertainSnapshot.value && formSnapshot.value === lastUncertainSnapshot.value)
) return;
const submittedSnapshot = formSnapshot.value;
const submittedPayload = JSON.parse(submittedSnapshot);
feedbackState.value = "submitting";
feedbackResult.value = "";
feedbackResultTone.value = "";
try {
await appApi.submitFeedback(submittedPayload, { requestController });
if (!pageActive) return;
baseline.value = submittedSnapshot;
lastSubmittedSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "success";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = submittedWithPendingEditsMessage;
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
if (isFeedbackResultUncertain(error)) {
lastUncertainSnapshot.value = submittedSnapshot;
feedbackResultTone.value = "uncertain";
if (formSnapshot.value === submittedSnapshot) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
} else {
feedbackState.value = "ready";
feedbackResult.value = uncertainWithPendingEditsMessage;
}
return;
}
feedbackState.value = "error";
feedbackResult.value = feedbackErrorMessage(error);
feedbackResultTone.value = "error";
}
};
watch(formSnapshot, (snapshot) => {
if (feedbackState.value === "submitting") return;
errors.feedbackContent = "";
if (lastSubmittedSnapshot.value && snapshot === lastSubmittedSnapshot.value) {
feedbackState.value = "success";
feedbackResult.value = submittedFeedbackMessage;
feedbackResultTone.value = "success";
return;
}
if (lastUncertainSnapshot.value && snapshot === lastUncertainSnapshot.value) {
feedbackState.value = "uncertain";
feedbackResult.value = uncertainFeedbackMessage;
feedbackResultTone.value = "uncertain";
return;
}
feedbackState.value = "ready";
if (lastUncertainSnapshot.value) {
feedbackResult.value = uncertainWithPendingEditsMessage;
feedbackResultTone.value = "uncertain";
} else if (lastSubmittedSnapshot.value) {
feedbackResult.value = submittedWithPendingEditsMessage;
feedbackResultTone.value = "success";
} else {
feedbackResult.value = "";
feedbackResultTone.value = "";
}
});
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: feedbackState.value === "submitting",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
requestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feedback-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-layer{z-index:1}.page-content{flex:1;padding:26rpx 30rpx 72rpx}.lead{display:block;color:$ink-muted;font-size:23rpx;line-height:1.5;text-align:center}.type-field{margin-top:20rpx}.field-note{display:block;color:$ink-muted;font-size:20rpx;line-height:1.5}.type-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:10rpx}.type-chip{margin:0;padding:0;border:0;background:transparent;line-height:normal;@include adaptive.adaptive-profile-field;display:flex;min-height:88rpx;align-items:center;justify-content:center;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip::after{border:0}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.type-chip[disabled]{opacity:.55}.form-panel{@include adaptive.adaptive-profile-content;margin-top:20rpx;padding:30rpx 34rpx}.form-panel textarea{display:block;width:auto;min-width:0;min-height:210rpx;color:$ink;font-size:24rpx;line-height:1.65}.form-panel textarea[disabled],.contact-row input[disabled]{opacity:.65}.counter{display:block;color:$ink-muted;font-size:20rpx;text-align:right}.contact-row{display:grid;grid-template-columns:130rpx minmax(0,1fr);min-height:88rpx;align-items:center;gap:16rpx;margin-top:16rpx;border-top:1px solid rgba(181,137,63,.38)}.contact-row text{color:$ink;font-size:23rpx;font-weight:700}.contact-row input{width:auto;min-width:0;min-height:68rpx;color:$ink;font-size:22rpx;text-align:right}.field-error{display:block;margin-top:7rpx;color:#b42318;font-size:20rpx;line-height:1.4;text-align:right}.privacy-note{display:block;margin-top:15rpx;color:$ink-muted;font-size:20rpx;line-height:1.5;text-align:center}.feedback-result{margin-top:18rpx;padding:18rpx 20rpx;border:1px solid rgba(122,91,46,.24);border-radius:12rpx;font-size:21rpx;line-height:1.55}.feedback-result--success{border-color:rgba(41,112,66,.34);background:rgba(234,246,237,.88);color:#245f39}.feedback-result--error{border-color:rgba(180,35,24,.3);background:rgba(255,241,239,.9);color:#8f241c}.feedback-result--uncertain{border-color:rgba(155,101,22,.34);background:rgba(255,248,229,.92);color:#76500f}.page-content>.app-button{margin-top:24rpx}@media(max-width:340px){.page-content{padding-right:22rpx;padding-left:22rpx}.form-panel{padding-right:26rpx;padding-left:26rpx}}
</style>