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