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
+433
View File
@@ -0,0 +1,433 @@
<template>
<view class="search-page">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
/></view>
<view class="page-content">
<view class="invite-card">
<text class="invite-card__title">邀请码加入</text>
<text class="invite-card__copy"
>输入收到的邀请码查看家谱信息后再确认加入</text
>
<input
v-model.trim="inviteToken"
class="invite-card__input"
type="password"
maxlength="128"
placeholder="请输入邀请码"
:disabled="inviteState === 'previewing' || inviteState === 'redeeming'"
@input="resetInvitePreview"
/>
<text v-if="inviteError" class="invite-card__error">{{ inviteError }}</text>
<AppButton
block
type="secondary"
:disabled="!inviteToken || inviteState === 'previewing' || inviteState === 'redeeming'"
:label="inviteState === 'previewing' ? '正在查看' : '查看家谱信息'"
@click="previewInvite"
/>
<view v-if="invitePreview" class="invite-preview">
<text>可加入家谱{{ invitePreview.genealogyName }}</text>
<text>有效至{{ invitePreview.expiresAt }}</text>
<AppButton
block
:disabled="inviteState === 'redeeming'"
:label="inviteState === 'redeeming' ? '正在加入' : '确认加入这部家谱'"
@click="openRedeemConfirmation"
/>
</view>
<view v-if="inviteResult" class="invite-result">
<text>{{ inviteResultCopy.title }}</text>
<text>{{ inviteResultCopy.copy }}</text>
<AppButton block :label="inviteResultCopy.action" @click="handleInviteResult" />
</view>
</view>
<view class="search-note"
><text>公开家谱</text
><text>以下是可加入的公开家谱</text></view
>
<view v-if="genealogySearchState === 'loading'" class="state-card"
><AppLoading
text="正在读取公开家谱"
variant="section"
description="正在查询可加入的公开家谱。"
/></view>
<view v-else-if="genealogySearchState === 'error'" class="state-card"
><text>暂时无法读取公开家谱</text
><AppButton
block
type="secondary"
label="重新加载"
@click="loadGenealogies"
/></view>
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
><text>暂未找到公开家谱</text></view
>
<view v-else class="result-list">
<view v-for="item in rows" :key="item.id" class="genealogy-card">
<view class="card-heading"
><text>{{ item.name }}</text
><text v-if="item.surname">{{ item.surname }}</text></view
>
<text v-if="item.location" class="card-meta">{{
item.location
}}</text>
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
<view class="card-footer"
><text>{{ item.memberCount }} 位成员</text
><AppButton
:label="item.canManage ? '已在我的家谱' : '申请加入'"
:disabled="item.canManage"
@click="applyToJoin(item)"
/></view>
</view>
</view>
</view>
<AppDialog
:visible="redeemConfirmationVisible"
:close-on-mask="false"
eyebrow="加入确认"
title="确认使用邀请码加入?"
:message="redeemConfirmationMessage"
confirm-text="确认加入"
cancel-text="暂不加入"
show-cancel
@confirm="redeemInvite"
@cancel="closeRedeemConfirmation"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
const rows = ref([]);
const genealogySearchState = ref("loading");
const inviteToken = ref("");
const inviteState = ref("idle");
const inviteError = ref("");
const invitePreview = ref(null);
const inviteResult = ref(null);
const redeemConfirmationVisible = ref(false);
const publicGenealogyListController = createRequestController();
const invitationPreviewController = createRequestController();
const invitationRedemptionController = createRequestController();
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
let isPageActive = true;
const loadGenealogies = async () => {
publicGenealogyListController.abort();
genealogySearchState.value = "loading";
try {
const publicGenealogies = await genealogyApi.getPublicGenealogies({
requestController: publicGenealogyListController,
});
if (!isPageActive) return;
rows.value = publicGenealogies;
genealogySearchState.value = rows.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
genealogySearchState.value = "error";
}
};
const applyToJoin = (item) =>
openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
const backToGenealogies = () => returnTo("G01");
const inviteResultCopy = computed(() =>
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
? {
title: "已加入家谱",
copy: "你已加入这部家谱。",
action: "查看我的家谱",
}
: {
title: "加入申请已提交",
copy: "申请已提交,请等待审核结果。",
action: "查看我的申请",
},
);
const redeemConfirmationMessage = computed(() =>
invitePreview.value
? `确认加入「${invitePreview.value.genealogyName}」?如果这部家谱需要审核,我们会先提交申请。`
: "请先查看家谱信息。",
);
const resetInvitePreview = () => {
if (inviteState.value === "previewing") invitationPreviewController.abort();
invitePreview.value = null;
inviteResult.value = null;
inviteError.value = "";
redeemConfirmationVisible.value = false;
if (inviteState.value !== "previewing" && inviteState.value !== "redeeming")
inviteState.value = "idle";
};
const previewInvite = async () => {
if (!inviteToken.value || inviteState.value === "previewing") return;
const token = inviteToken.value;
invitationPreviewController.abort();
inviteState.value = "previewing";
inviteError.value = "";
invitePreview.value = null;
inviteResult.value = null;
try {
const invitationPreview = await genealogyMembershipApi.previewGenealogyInvitation(token, {
requestController: invitationPreviewController,
});
if (!isPageActive || inviteToken.value !== token) return;
invitePreview.value = invitationPreview;
inviteState.value = "ready";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
inviteState.value = "idle";
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
}
};
const openRedeemConfirmation = () => {
if (inviteState.value === "ready" && invitePreview.value)
redeemConfirmationVisible.value = true;
};
const closeRedeemConfirmation = () => {
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
};
const redeemInvite = async () => {
if (inviteState.value !== "ready" || !invitePreview.value) return;
const redemptionPayload = { token: inviteToken.value };
const redemptionAttempt = invitationRedemptionGuard.begin(redemptionPayload);
if (redemptionAttempt === null) {
inviteError.value =
"上次兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
return;
}
inviteState.value = "redeeming";
inviteError.value = "";
try {
const redemptionResult = await genealogyMembershipApi.redeemGenealogyInvitation(
redemptionPayload,
{ requestController: invitationRedemptionController },
);
if (!isPageActive) return;
inviteResult.value = redemptionResult;
invitePreview.value = null;
inviteToken.value = "";
inviteState.value = "success";
redeemConfirmationVisible.value = false;
} catch (error) {
if (!isPageActive) return;
inviteState.value = "ready";
redeemConfirmationVisible.value = false;
if (invitationRedemptionGuard.recordFailure(redemptionAttempt, error)) {
inviteError.value =
"兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
return;
}
if (isRequestCancelled(error)) return;
inviteError.value = getRequestErrorMessage(error, "加入未完成,请重新查看邀请码信息。");
}
};
const handleInviteResult = () =>
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
? returnTo("G01")
: openPage("G09", {}, "G06");
onLoad(loadGenealogies);
onShow(() => {
if (genealogySearchState.value !== "loading") loadGenealogies();
});
onUnload(() => {
isPageActive = false;
publicGenealogyListController.abort();
invitationPreviewController.abort();
invitationRedemptionController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.search-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.search-note,
.state-card,
.genealogy-card,
.invite-card {
@include adaptive-genealogy-state-panel;
}
.invite-card {
padding: 28rpx 30rpx;
}
.invite-card__title,
.invite-card__copy,
.invite-card__error,
.invite-preview text,
.invite-result text {
display: block;
}
.invite-card__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(18px, 32rpx, 23px);
font-weight: 700;
}
.invite-card__copy,
.invite-preview text,
.invite-result text {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.invite-card__input {
@include adaptive-genealogy-form-field;
display: block;
width: 100%;
min-height: 78rpx;
margin-top: 18rpx;
padding: 0 22rpx;
box-sizing: border-box;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.invite-card__error {
margin-top: 12rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.invite-card > .app-button,
.invite-preview > .app-button,
.invite-result > .app-button {
margin-top: 18rpx;
}
.invite-preview,
.invite-result {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.24);
}
.invite-result text:first-child {
color: $brand-red;
font-weight: 700;
}
.search-note {
display: flex;
min-height: 112rpx;
flex-direction: column;
justify-content: center;
padding: 20rpx 30rpx;
}
.search-note text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.search-note text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.state-card {
display: flex;
min-height: 310rpx;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 18rpx;
padding: 42rpx;
text-align: center;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
}
.state-card .app-button {
width: 100%;
margin-top: 22rpx;
}
.result-list {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 18rpx;
}
.genealogy-card {
padding: 28rpx 32rpx;
}
.card-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 18rpx;
}
.card-heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 31rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.card-heading text:last-child {
flex: 0 0 auto;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.card-meta,
.card-copy {
display: block;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
}
.card-meta {
margin-top: 10rpx;
}
.card-copy {
margin-top: 8rpx;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-top: 20rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.card-footer .app-button {
min-width: 180rpx;
}
@media (max-width: 340px) {
.page-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.card-footer {
align-items: flex-start;
flex-direction: column;
}
.card-footer .app-button {
width: 100%;
}
}
</style>