Files
jiapuapp/components/genealogy/InvitationManager.vue
T

405 lines
12 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>
<AppDialog
:visible="managerVisible"
:close-on-mask="false"
eyebrow="家谱邀请"
title="邀请家人加入"
confirm-text="生成新邀请码"
cancel-text="关闭"
show-cancel
@confirm="requestIssueInvitation"
@cancel="closeManager"
>
<text class="invitation-manager__note">
邀请码只会显示一次请及时发送给家人有效期和使用状态以页面提示为准
</text>
<AppLoading v-if="invitationState === 'loading'" text="正在加载邀请记录" />
<view v-else-if="invitationState === 'error'" class="invitation-manager__state">
<text>{{ invitationError || "邀请记录暂时无法加载。" }}</text>
<AppButton compact type="secondary" label="重新加载" @click="loadInvitations" />
</view>
<view v-else-if="invitations.length" class="invitation-manager__list">
<view
v-for="invitation in invitations"
:key="invitation.id"
class="invitation-manager__row"
>
<view>
<text>{{ invitation.genealogyName }}</text>
<text>有效至 {{ formatInvitationTime(invitation.expiresAt) }}</text>
<text>{{ invitationStatusLabel(invitation.status) }}</text>
</view>
<AppButton
v-if="invitation.status === 'ACTIVE'"
compact
type="secondary"
label="撤销"
:disabled="revokingInvitationId === invitation.id"
@click="requestRevokeInvitation(invitation)"
/>
</view>
</view>
<view v-else class="invitation-manager__state">
<text>还没有发出过邀请码</text>
</view>
</AppDialog>
<AppDialog
:visible="issueConfirmationVisible"
:close-on-mask="false"
eyebrow="生成邀请码"
title="生成新的邀请码?"
message="生成后请只发送给要邀请的家人。邀请码是否有效,以页面提示为准。"
:confirm-text="issuingInvitation ? '正在生成' : '确认生成'"
cancel-text="暂不生成"
show-cancel
@confirm="issueInvitation"
@cancel="closeIssueConfirmation"
/>
<AppDialog
:visible="issuedInvitationVisible"
:close-on-mask="false"
eyebrow="邀请码已生成"
title="请立即保存并发送"
:message="issuedInvitationMessage"
confirm-text="我已保存"
:show-cancel="false"
@confirm="closeIssuedInvitation"
@cancel="closeIssuedInvitation"
>
<text v-if="issuedInvitation" class="issued-invitation__token" selectable>
{{ issuedInvitation.token }}
</text>
<text v-if="copyNotice" class="issued-invitation__notice">{{ copyNotice }}</text>
<AppButton
v-if="issuedInvitation"
block
type="secondary"
label="复制邀请码"
@click="copyIssuedInvitation"
/>
</AppDialog>
<AppDialog
:visible="revokeConfirmationVisible"
:close-on-mask="false"
eyebrow="撤销确认"
title="撤销这个邀请码?"
:message="revokeConfirmationMessage"
:confirm-text="revokingInvitationId ? '正在撤销' : '确认撤销'"
cancel-text="暂不撤销"
show-cancel
@confirm="revokeInvitation"
@cancel="closeRevokeConfirmation"
/>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
});
const emit = defineEmits(["busy-change", "transient-change"]);
const managerVisible = ref(false);
const invitationState = ref("idle");
const invitationError = ref("");
const invitations = ref([]);
const issueConfirmationVisible = ref(false);
const issuingInvitation = ref(false);
const issuedInvitationVisible = ref(false);
const issuedInvitation = ref(null);
const copyNotice = ref("");
const revokeConfirmationVisible = ref(false);
const revokeTarget = ref(null);
const revokingInvitationId = ref("");
const invitationListRequestController = createRequestController();
const invitationIssuanceRequestController = createRequestController();
const invitationRevocationRequestController = createRequestController();
const invitationIssuanceGuard = createNonIdempotentWriteGuard();
let componentActive = true;
const isBusy = computed(() =>
issuingInvitation.value || Boolean(revokingInvitationId.value),
);
const hasTransient = computed(() =>
managerVisible.value ||
issueConfirmationVisible.value ||
issuedInvitationVisible.value ||
revokeConfirmationVisible.value,
);
const issuedInvitationMessage = computed(() =>
issuedInvitation.value
? `适用于「${issuedInvitation.value.genealogyName}」,有效至 ${formatInvitationTime(
issuedInvitation.value.expiresAt,
)}。关闭后无法再次查看原始邀请码。`
: "",
);
const revokeConfirmationMessage = computed(() =>
revokeTarget.value
? `撤销后「${revokeTarget.value.genealogyName}」的邀请码将不能再被使用。`
: "",
);
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
watch(hasTransient, (visible) => emit("transient-change", visible), {
immediate: true,
});
const formatInvitationTime = (value) =>
typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : value;
const invitationStatusLabel = (status) =>
({
ACTIVE: "等待使用",
REDEEMED: "已被使用",
REVOKED: "已撤销",
EXPIRED: "已过期",
})[status] || "状态已更新";
const loadInvitations = async () => {
if (!props.genealogyId) return;
invitationListRequestController.abort();
invitationState.value = "loading";
invitationError.value = "";
try {
const invitationRows = await genealogyMembershipApi.getMyGenealogyInvitations({
requestController: invitationListRequestController,
});
if (!componentActive) return;
invitations.value = invitationRows.filter(
(invitation) => invitation.genealogyId === props.genealogyId,
);
invitationState.value = "ready";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
invitationState.value = "error";
invitationError.value = getRequestErrorMessage(
error,
"邀请记录加载失败,请稍后重试。",
);
}
};
const open = () => {
if (!props.genealogyId) return false;
managerVisible.value = true;
void loadInvitations();
return true;
};
const closeManager = () => {
if (isBusy.value) return false;
managerVisible.value = false;
return true;
};
const requestIssueInvitation = () => {
if (invitationState.value === "loading" || isBusy.value) return;
issueConfirmationVisible.value = true;
};
const closeIssueConfirmation = () => {
if (isBusy.value) return false;
issueConfirmationVisible.value = false;
return true;
};
const issueInvitation = async () => {
if (!props.genealogyId || isBusy.value) return;
const issueAttempt = invitationIssuanceGuard.begin({
genealogyId: props.genealogyId,
});
if (issueAttempt === null) {
managerVisible.value = true;
invitationError.value =
"上次生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
return;
}
issuingInvitation.value = true;
issueConfirmationVisible.value = false;
managerVisible.value = false;
copyNotice.value = "";
try {
const invitation = await genealogyMembershipApi.issueGenealogyInvitation(
props.genealogyId,
{ requestController: invitationIssuanceRequestController },
);
if (!componentActive) return;
issuedInvitation.value = invitation;
issuedInvitationVisible.value = true;
} catch (error) {
if (!componentActive) return;
if (invitationIssuanceGuard.recordFailure(issueAttempt, error)) {
managerVisible.value = true;
invitationError.value =
"邀请码生成结果暂时无法确认,请先检查邀请记录,避免重复生成。";
return;
}
if (isRequestCancelled(error)) return;
invitationError.value = getRequestErrorMessage(
error,
"邀请码生成失败,请稍后重试。",
);
invitationState.value = "error";
managerVisible.value = true;
} finally {
if (componentActive) issuingInvitation.value = false;
}
};
const closeIssuedInvitation = () => {
if (isBusy.value) return false;
issuedInvitationVisible.value = false;
issuedInvitation.value = null;
copyNotice.value = "";
return true;
};
const copyIssuedInvitation = () => {
const invitationToken = issuedInvitation.value?.token;
if (!invitationToken) return;
if (typeof uni === "undefined" || typeof uni.setClipboardData !== "function") {
copyNotice.value = "暂时无法自动复制,请手动保存邀请码。";
return;
}
uni.setClipboardData({
data: invitationToken,
success: () => {
copyNotice.value = "邀请码已复制,请发送给家人。";
},
fail: () => {
copyNotice.value = "复制失败,请手动保存邀请码。";
},
});
};
const requestRevokeInvitation = (invitation) => {
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
revokeTarget.value = invitation;
revokeConfirmationVisible.value = true;
};
const closeRevokeConfirmation = () => {
if (isBusy.value) return false;
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
return true;
};
const revokeInvitation = async () => {
const invitation = revokeTarget.value;
if (!invitation || invitation.status !== "ACTIVE" || isBusy.value) return;
revokingInvitationId.value = invitation.id;
invitationError.value = "";
try {
await genealogyMembershipApi.revokeGenealogyInvitation(invitation.id, {
requestController: invitationRevocationRequestController,
});
if (!componentActive) return;
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
await loadInvitations();
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
invitationError.value = getRequestErrorMessage(
error,
"邀请码撤销失败,请稍后重试。",
);
revokeConfirmationVisible.value = false;
revokeTarget.value = null;
} finally {
if (componentActive) revokingInvitationId.value = "";
}
};
const closeTransient = () => {
if (isBusy.value) return true;
if (revokeConfirmationVisible.value) return closeRevokeConfirmation();
if (issuedInvitationVisible.value) return closeIssuedInvitation();
if (issueConfirmationVisible.value) return closeIssueConfirmation();
if (managerVisible.value) return closeManager();
return false;
};
defineExpose({ closeTransient, open });
onUnmounted(() => {
componentActive = false;
invitationListRequestController.abort();
invitationIssuanceRequestController.abort();
invitationRevocationRequestController.abort();
});
</script>
<style scoped lang="scss">
.invitation-manager__note,
.invitation-manager__state text,
.invitation-manager__row text,
.issued-invitation__token,
.issued-invitation__notice {
display: block;
}
.invitation-manager__note,
.invitation-manager__state text,
.invitation-manager__row text:not(:first-child),
.issued-invitation__notice {
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.invitation-manager__state,
.invitation-manager__list {
width: 100%;
margin-top: 20rpx;
}
.invitation-manager__state .app-button {
margin-top: 14rpx;
}
.invitation-manager__row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 16rpx 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
text-align: left;
gap: 18rpx;
}
.invitation-manager__row > view {
min-width: 0;
flex: 1;
}
.invitation-manager__row text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 28rpx, 21px);
font-weight: 700;
overflow-wrap: anywhere;
}
.issued-invitation__token {
box-sizing: border-box;
width: 100%;
margin-top: 18rpx;
padding: 18rpx;
border: 1rpx dashed rgba(159, 23, 15, 0.48);
border-radius: 8rpx;
color: $brand-red;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.5;
overflow-wrap: anywhere;
}
.issued-invitation__notice {
margin-top: 12rpx;
text-align: center;
}
.issued-invitation__token + .app-button {
margin-top: 18rpx;
}
</style>