434 lines
13 KiB
Vue
434 lines
13 KiB
Vue
<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>
|