feat: migrate app routes and business modules
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user