Files
jiapuapp/pages/genealogy/search.vue
T

434 lines
13 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="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>