完成50%
This commit is contained in:
+234
-24
@@ -1,53 +1,263 @@
|
||||
<!-- 页面编号:M-07;用途:提交意见反馈。 -->
|
||||
<template>
|
||||
<view class="feedback-page" :class="{ 'feedback-state--ready': feedbackState === 'ready', 'feedback-state--submitting': feedbackState === 'submitting' }">
|
||||
<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="意见反馈" /></view>
|
||||
<view class="page-layer"><PageHeader title="意见反馈" custom-back @back="requestBack" /></view>
|
||||
<view class="page-content page-layer">
|
||||
<text class="lead">你的建议会帮助我们把家谱做得更好</text>
|
||||
<view class="type-grid">
|
||||
<view v-for="type in feedbackTypes" :key="type" class="type-chip" :class="{ active: feedbackForm.type === type }" role="button" @click="feedbackForm.type = type; errors.type = ''">{{ type }}</view>
|
||||
<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>
|
||||
<text v-if="errors.type" class="field-error">{{ errors.type }}</text>
|
||||
<view class="form-panel">
|
||||
<textarea v-model.trim="feedbackForm.description" auto-height maxlength="500" aria-label="问题描述" placeholder="请说明遇到的问题、操作步骤或建议" @input="errors.description = ''" />
|
||||
<text class="counter">{{ feedbackForm.description.length }}/500</text>
|
||||
<text v-if="errors.description" class="field-error">{{ errors.description }}</text>
|
||||
<view class="contact-row"><text>联系方式</text><input v-model.trim="feedbackForm.contact" maxlength="50" aria-label="联系方式" placeholder="手机号或邮箱(选填)" /></view>
|
||||
<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>
|
||||
<AppButton block :disabled="feedbackState === 'submitting'" :label="feedbackState === 'submitting' ? '正在提交' : '提交反馈'" @click="submitFeedback" />
|
||||
<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>
|
||||
<AppToast :visible="toastVisible" :message="toastMessage" />
|
||||
<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 { onUnmounted, reactive, ref } from "vue";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppToast from "@/components/AppToast.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({ type: "", description: "", contact: "" });
|
||||
const errors = reactive({ type: "", description: "" });
|
||||
const feedbackForm = reactive({ feedbackType: "", feedbackContent: "", contactInfo: "" });
|
||||
const errors = reactive({ feedbackContent: "" });
|
||||
const feedbackState = ref("ready");
|
||||
const toastVisible = ref(false); const toastMessage = ref(""); let timer = null;
|
||||
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.type = feedbackForm.type ? "" : "请选择反馈类型";
|
||||
errors.description = feedbackForm.description.length < 5 ? "请至少填写 5 个字的问题描述" : "";
|
||||
return !errors.type && !errors.description;
|
||||
errors.feedbackContent = feedbackForm.feedbackContent.trim() ? "" : "请填写问题描述";
|
||||
if (errors.feedbackContent) {
|
||||
feedbackContentFocused.value = false;
|
||||
nextTick(() => {
|
||||
if (pageActive) feedbackContentFocused.value = true;
|
||||
});
|
||||
}
|
||||
return !errors.feedbackContent;
|
||||
};
|
||||
const submitFeedback = () => {
|
||||
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";
|
||||
timer = setTimeout(() => { feedbackState.value = "ready"; feedbackForm.type = ""; feedbackForm.description = ""; feedbackForm.contact = ""; toastMessage.value = "反馈已保存,服务接入后将提交给家谱助手"; toastVisible.value = true; timer = setTimeout(() => (toastVisible.value = false), 2200); }, 500);
|
||||
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";
|
||||
}
|
||||
};
|
||||
onUnmounted(() => clearTimeout(timer));
|
||||
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-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14rpx;margin-top:20rpx}.type-chip{@include adaptive.adaptive-profile-field;display:flex;min-height:76rpx;align-items:center;justify-content:center;padding:10rpx 16rpx;color:$ink-muted;font-size:22rpx;text-align:center}.type-chip.active{color:$brand-red;font-weight:700;filter:saturate(1.2)}.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}.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}.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}}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user