feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+619
View File
@@ -0,0 +1,619 @@
<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>