Files
jiapuapp/pages/profile/feedback.vue
T

620 lines
18 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="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="feedbackType in feedbackTypes"
:key="feedbackType.value"
class="type-chip"
:class="{ active: feedbackForm.feedbackType === feedbackType.value }"
:aria-pressed="feedbackForm.feedbackType === feedbackType.value"
:disabled="feedbackState === 'submitting'"
@click="selectFeedbackType(feedbackType.value)"
>
{{ feedbackType.label }}
</button>
</view>
</view>
<view class="form-panel">
<text class="form-label">问题描述必填</text>
<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 class="history-section">
<view class="history-heading">
<text class="history-title">我的反馈</text>
<text class="history-note">这里会显示处理进度和回复</text>
</view>
<view v-if="historyState === 'loading'" class="history-state" role="status">
正在读取反馈记录
</view>
<view v-else-if="historyState === 'empty'" class="history-state">
还没有提交过反馈
</view>
<view v-else-if="historyState === 'error'" class="history-state history-state--error" role="alert">
<text>{{ historyError }}</text>
<button class="history-retry" @click="loadFeedbackHistory">重新加载</button>
</view>
<view v-else class="history-list">
<view
v-for="feedback in feedbackHistory"
:key="String(feedback.feedbackId)"
class="history-card"
>
<view class="history-card__meta">
<text>{{ feedbackTypeLabel(feedback.feedbackType) }} · {{ feedbackReference(feedback) }}</text>
<text class="history-status">{{ feedbackStatusLabel(feedback.handleStatus) }}</text>
</view>
<text
class="history-content"
:class="{
'history-content--collapsed':
isFeedbackLong(feedback) && !isFeedbackExpanded(feedback),
}"
>{{ feedback.feedbackContent }}</text
>
<button
v-if="isFeedbackLong(feedback)"
class="history-expand"
:aria-expanded="isFeedbackExpanded(feedback)"
@click="toggleFeedbackExpanded(feedback)"
>
{{ isFeedbackExpanded(feedback) ? "收起" : "展开全文" }}
</button>
<view v-if="feedback.handleResult" class="history-reply">
<text class="history-reply__label">处理回复</text>
<text>{{ feedback.handleResult }}</text>
</view>
</view>
</view>
</view>
</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 { nextTick, reactive, ref, watch } from "vue";
import { onBackPress, onLoad, 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 {
FEEDBACK_TYPE_OPTIONS as feedbackTypes
} from "@/services/api/feedback-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { feedbackApi } from "@/services/api/feedback-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createFeedbackSubmissionSession } from "@/utils/profile/feedback-submission.js";
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const feedbackStatusLabels = {
0: "待处理",
1: "处理中",
2: "已处理",
3: "暂不处理",
};
const feedbackTypeLabel = (feedbackTypeValue) =>
feedbackTypes.find(
(feedbackType) => feedbackType.value === feedbackTypeValue,
)?.label || "其他";
const feedbackStatusLabel = (handleStatus) =>
feedbackStatusLabels[String(handleStatus)] || "状态待确认";
const feedbackForm = reactive({
feedbackType: "",
feedbackContent: "",
contactInfo: "",
});
const errors = reactive({ feedbackContent: "" });
const feedbackState = ref("ready");
const feedbackResult = ref("");
const feedbackResultTone = ref("");
const isDirty = ref(false);
const submitDisabled = ref(false);
const submitLabel = ref("提交反馈");
const feedbackContentFocused = ref(false);
const discardVisible = ref(false);
const feedbackHistory = ref([]);
const historyState = ref("loading");
const historyError = ref("");
const expandedFeedbackIds = ref(new Set());
const feedbackIdentity = (feedback) => String(feedback?.feedbackId || "");
const feedbackReference = (feedback) => {
const feedbackId = feedbackIdentity(feedback);
return feedbackId ? `反馈编号 ${feedbackId.slice(-8)}` : "反馈记录";
};
const isFeedbackLong = (feedback) =>
String(feedback?.feedbackContent || "").length > 120;
const isFeedbackExpanded = (feedback) =>
expandedFeedbackIds.value.has(feedbackIdentity(feedback));
const toggleFeedbackExpanded = (feedback) => {
const feedbackId = feedbackIdentity(feedback);
if (!feedbackId) return;
const nextExpandedIds = new Set(expandedFeedbackIds.value);
if (nextExpandedIds.has(feedbackId)) nextExpandedIds.delete(feedbackId);
else nextExpandedIds.add(feedbackId);
expandedFeedbackIds.value = nextExpandedIds;
};
let pageActive = true;
const feedbackHistoryRequestController = createRequestController();
const feedbackSubmissionRequestController = createRequestController();
const submissionSession = createFeedbackSubmissionSession(feedbackForm);
const syncSubmissionView = () => {
const submissionView = submissionSession.view(feedbackForm);
feedbackState.value = submissionView.phase;
feedbackResult.value = submissionView.message;
feedbackResultTone.value = submissionView.tone;
isDirty.value = submissionView.isDirty;
submitDisabled.value = submissionView.isSubmitDisabled;
submitLabel.value = submissionView.submitLabel;
};
const loadFeedbackHistory = async () => {
historyState.value = "loading";
historyError.value = "";
try {
const rows = await feedbackApi.getFeedback({ requestController: feedbackHistoryRequestController });
if (!pageActive) return;
feedbackHistory.value = rows;
historyState.value = rows.length ? "ready" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
historyState.value = "error";
historyError.value = getRequestErrorMessage(
error,
"暂时无法读取反馈记录,请稍后重试。",
);
}
};
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 submitFeedback = async () => {
if (!validateFeedback()) return;
const submission = submissionSession.begin(feedbackForm);
if (!submission) return;
syncSubmissionView();
try {
await feedbackApi.submitFeedback(submission.payload, {
requestController: feedbackSubmissionRequestController,
});
if (!pageActive) return;
await loadFeedbackHistory();
if (!pageActive) return;
submissionSession.succeed(submission, feedbackForm);
syncSubmissionView();
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
submissionSession.fail(submission, error, feedbackForm);
syncSubmissionView();
}
};
watch(
() => [
feedbackForm.feedbackType,
feedbackForm.feedbackContent,
feedbackForm.contactInfo,
],
() => {
errors.feedbackContent = "";
submissionSession.reconcile(feedbackForm);
syncSubmissionView();
},
);
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));
onLoad(loadFeedbackHistory);
onUnload(() => {
pageActive = false;
feedbackHistoryRequestController.abort();
feedbackSubmissionRequestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.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: clamp(14px, 23rpx, 17px);
line-height: 1.5;
text-align: center;
}
.type-field {
margin-top: 20rpx;
}
.field-note {
display: block;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
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-profile-field;
display: flex;
min-height: 88rpx;
align-items: center;
justify-content: center;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
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: 0.55;
}
.form-panel {
@include 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: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.form-panel textarea[disabled],
.contact-row input[disabled] {
opacity: 0.65;
}
.counter {
display: block;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
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, 0.38);
}
.form-label {
display: block;
margin-bottom: 12rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.history-section {
margin-top: 30rpx;
padding-top: 26rpx;
border-top: 1px solid rgba(181, 137, 63, 0.42);
}
.history-heading,
.history-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.history-title {
color: $ink;
font-size: clamp(17px, 28rpx, 21px);
font-weight: 700;
}
.history-note {
color: rgba(76, 57, 43, 0.72);
font-size: clamp(12px, 19rpx, 14px);
}
.history-state {
margin-top: 18rpx;
padding: 28rpx 22rpx;
border: 1px solid rgba(181, 137, 63, 0.42);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.72);
color: rgba(76, 57, 43, 0.78);
font-size: clamp(14px, 22rpx, 16px);
line-height: 1.6;
text-align: center;
}
.history-state--error {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.history-retry {
min-width: 180rpx;
min-height: 72rpx;
margin: 0;
padding: 0 28rpx;
border: 1px solid rgba(164, 41, 36, 0.55);
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
color: $brand-red;
font-size: clamp(14px, 22rpx, 16px);
line-height: 72rpx;
}
.history-list {
display: grid;
gap: 16rpx;
margin-top: 18rpx;
}
.history-card {
padding: 22rpx;
border: 1px solid rgba(181, 137, 63, 0.42);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.78);
box-shadow: 0 8rpx 20rpx rgba(93, 50, 30, 0.06);
}
.history-card__meta {
color: rgba(76, 57, 43, 0.72);
font-size: clamp(12px, 19rpx, 14px);
}
.history-status {
color: $brand-red;
font-weight: 700;
}
.history-content {
display: block;
margin-top: 12rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 16px);
line-height: 1.65;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.history-content--collapsed {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.history-expand {
min-height: 72rpx;
margin: 4rpx 0 0 auto;
padding: 0 8rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 15px);
line-height: 72rpx;
}
.history-expand::after {
border: 0;
}
.history-reply {
display: grid;
gap: 6rpx;
margin-top: 16rpx;
padding: 16rpx 18rpx;
border-left: 4rpx solid rgba(181, 137, 63, 0.72);
background: rgba(181, 137, 63, 0.08);
color: rgba(76, 57, 43, 0.88);
font-size: clamp(13px, 20rpx, 15px);
line-height: 1.6;
overflow-wrap: anywhere;
}
.history-reply__label {
color: $brand-red;
font-weight: 700;
}
.contact-row text {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.contact-row input {
width: auto;
min-width: 0;
min-height: 68rpx;
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
text-align: right;
}
.field-error {
display: block;
margin-top: 7rpx;
color: #b42318;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.4;
text-align: right;
}
.privacy-note {
display: block;
margin-top: 15rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.5;
text-align: center;
}
.feedback-result {
margin-top: 18rpx;
padding: 18rpx 20rpx;
border: 1px solid rgba(122, 91, 46, 0.24);
border-radius: 12rpx;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.55;
}
.feedback-result--success {
border-color: rgba(41, 112, 66, 0.34);
background: rgba(234, 246, 237, 0.88);
color: #245f39;
}
.feedback-result--error {
border-color: rgba(180, 35, 24, 0.3);
background: rgba(255, 241, 239, 0.9);
color: #8f241c;
}
.feedback-result--uncertain {
border-color: rgba(155, 101, 22, 0.34);
background: rgba(255, 248, 229, 0.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>