264 lines
13 KiB
Vue
264 lines
13 KiB
Vue
<!-- 页面编号: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>
|