405 lines
12 KiB
Vue
405 lines
12 KiB
Vue
<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>
|