Files
jiapuapp/components/records/CeremonyInvitationManager.vue
T

398 lines
14 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="invitation-summary-card">
<view class="invitation-summary-card__heading">
<view>
<text>活动邀请</text>
<text>仅活动管理员可管理为保护隐私不展示受邀人的个人信息</text>
</view>
<view class="invitation-summary-card__actions">
<AppButton
compact
type="secondary"
:disabled="invitationState === 'loading'"
:label="invitationState === 'loading' ? '加载中' : '查看概览'"
@click="loadInvitationSummary"
/>
<AppButton
compact
type="secondary"
:disabled="inviteeManagerState === 'loading'"
:label="inviteeManagerState === 'loading' ? '加载中' : '管理受邀人'"
@click="openInviteeManager"
/>
</view>
</view>
<AppLoading v-if="invitationState === 'loading'" text="正在加载活动邀请" />
<view v-else-if="invitationState === 'ready'" class="invitation-summary-card__stats">
<text> {{ invitationRows.length }} 条邀请</text>
<text v-for="item in invitationSummary" :key="item.status">
{{ item.label }} {{ item.count }}
</text>
</view>
<view v-else-if="invitationState === 'empty'" class="invitation-summary-card__empty">
<text>暂时没有活动邀请</text>
</view>
<view v-else-if="invitationState === 'error'" class="invitation-summary-card__error">
<text>暂时无法加载活动邀请</text>
<AppButton compact type="secondary" label="重新查看" @click="loadInvitationSummary" />
</view>
<view v-if="inviteeManagerVisible" class="invitee-manager">
<text class="invitee-manager__title">选择受邀成员</text>
<text class="invitee-manager__note"
>仅展示可以邀请的成员保存后会更新尚未处理的活动邀请已接受或拒绝的邀请不受影响</text
>
<AppLoading
v-if="inviteeManagerState === 'loading'"
text="正在加载可邀请成员"
/>
<view v-else-if="inviteeManagerState === 'ready'" class="invitee-manager__options">
<view
v-for="item in inviteeOptions"
:key="item.appUserId"
class="invitee-manager__option"
:class="{ 'invitee-manager__option--selected': selectedInviteeUserIds.includes(item.appUserId) }"
role="checkbox"
:aria-checked="selectedInviteeUserIds.includes(item.appUserId)"
@click="toggleInvitee(item.appUserId)"
>
<text>{{ item.displayName }}</text>
<text v-if="item.memberRole">{{ item.memberRole }}</text>
</view>
<text v-if="unresolvedPendingInviteeCount" class="invitee-manager__error"
> {{ unresolvedPendingInviteeCount }} 位受邀人暂未显示在此列表中原有邀请不会受影响</text
>
<view class="invitee-manager__actions">
<AppButton
type="secondary"
label="取消"
:disabled="inviteeSubmitting"
@click="closeInviteeManager"
/>
<AppButton
:label="inviteeSubmitting ? '正在保存' : '保存受邀人'"
:disabled="inviteeSubmitting || unresolvedPendingInviteeCount > 0"
@click="requestInviteeSave"
/>
</view>
</view>
<view v-else-if="inviteeManagerState === 'empty'" class="invitee-manager__empty">
<text>当前没有可邀请成员</text>
<AppButton type="secondary" label="关闭" @click="closeInviteeManager" />
</view>
<view v-else-if="inviteeManagerState === 'error'" class="invitee-manager__error">
<text>暂时无法加载可邀请成员</text>
<AppButton compact type="secondary" label="重新查看" @click="openInviteeManager" />
</view>
</view>
<AppDialog
:visible="inviteeConfirmVisible"
eyebrow="邀请确认"
title="保存本次受邀人调整?"
message="保存后会更新尚未处理的活动邀请;已接受或拒绝的邀请不会受影响。"
:confirm-text="inviteeSubmitting ? '正在保存' : '确认保存'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="confirmInviteeSave"
@cancel="inviteeConfirmVisible = false"
/>
</view>
</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 {
CEREMONY_INVITATION_STATUS,
CEREMONY_INVITATION_STATUS_LABELS
} from "@/services/api/ceremony-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
const props = defineProps({
genealogyId: { type: String, required: true },
ceremonyId: { type: String, required: true },
});
const emit = defineEmits(["busy-change", "transient-change"]);
const invitationRows = ref([]);
const invitationState = ref("idle");
const inviteeManagerVisible = ref(false);
const inviteeManagerState = ref("idle");
const inviteeOptions = ref([]);
const selectedInviteeUserIds = ref([]);
const inviteeSubmitting = ref(false);
const inviteeConfirmVisible = ref(false);
const invitationListRequestController = createRequestController();
const inviteeOptionsRequestController = createRequestController();
const inviteeSaveRequestController = createRequestController();
let isActive = true;
const hasValidContext = computed(
() => /^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.ceremonyId),
);
const invitationSummary = computed(() => {
const statusCounts = invitationRows.value.reduce((counts, invitation) => {
counts[invitation.inviteStatus] = (counts[invitation.inviteStatus] || 0) + 1;
return counts;
}, {});
return Object.entries(CEREMONY_INVITATION_STATUS_LABELS)
.map(([status, label]) => ({ status, label, count: statusCounts[status] || 0 }))
.filter((summary) => summary.count > 0);
});
const pendingInviteeUserIds = computed(() =>
invitationRows.value
.filter(
(invitation) =>
invitation.inviteStatus === CEREMONY_INVITATION_STATUS.PENDING,
)
.map((invitation) => invitation.userKey)
.filter(Boolean),
);
const unresolvedPendingInviteeCount = computed(
() =>
pendingInviteeUserIds.value.filter(
(userId) => !inviteeOptions.value.some((option) => option.appUserId === userId),
).length,
);
const hasTransient = computed(
() => inviteeConfirmVisible.value || inviteeManagerVisible.value,
);
watch(hasTransient, (value) => emit("transient-change", value), { immediate: true });
watch(inviteeSubmitting, (value) => emit("busy-change", value), { immediate: true });
const loadInvitationSummary = async () => {
if (!hasValidContext.value || invitationState.value === "loading") return;
invitationListRequestController.abort();
invitationState.value = "loading";
try {
const invitations = await ceremonyApi.getCeremonyInvitations(
props.genealogyId,
props.ceremonyId,
{ requestController: invitationListRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
invitationState.value = "error";
}
};
const closeInviteeManager = () => {
if (inviteeSubmitting.value) return;
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
};
const openInviteeManager = async () => {
if (!hasValidContext.value || inviteeManagerState.value === "loading") return;
inviteeManagerVisible.value = true;
inviteeManagerState.value = "loading";
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
try {
const [invitations, options] = await Promise.all([
ceremonyApi.getCeremonyInvitations(props.genealogyId, props.ceremonyId, {
requestController: invitationListRequestController,
}),
ceremonyApi.getCeremonyInviteeOptions(props.genealogyId, props.ceremonyId, {
requestController: inviteeOptionsRequestController,
}),
]);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeOptions.value = options.filter((option) => option.eligible);
selectedInviteeUserIds.value = pendingInviteeUserIds.value.slice();
inviteeManagerState.value = inviteeOptions.value.length ? "ready" : "empty";
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeOptions.value = [];
selectedInviteeUserIds.value = [];
inviteeManagerState.value = "error";
}
};
const toggleInvitee = (appUserId) => {
if (inviteeSubmitting.value) return;
selectedInviteeUserIds.value = selectedInviteeUserIds.value.includes(appUserId)
? selectedInviteeUserIds.value.filter((selectedUserId) => selectedUserId !== appUserId)
: [...selectedInviteeUserIds.value, appUserId];
};
const requestInviteeSave = () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeConfirmVisible.value = true;
};
const confirmInviteeSave = async () => {
if (inviteeSubmitting.value || unresolvedPendingInviteeCount.value) return;
inviteeSubmitting.value = true;
try {
const invitations = await ceremonyApi.replaceCeremonyInvitees(
props.genealogyId,
props.ceremonyId,
{ inviteeUserIds: selectedInviteeUserIds.value },
{ requestController: inviteeSaveRequestController },
);
if (!isActive) return;
invitationRows.value = invitations;
invitationState.value = invitations.length ? "ready" : "empty";
inviteeConfirmVisible.value = false;
inviteeManagerVisible.value = false;
} catch (error) {
if (!isActive || isRequestCancelled(error)) return;
inviteeManagerState.value = "error";
inviteeConfirmVisible.value = false;
} finally {
if (isActive) inviteeSubmitting.value = false;
}
};
const closeTransient = () => {
if (inviteeConfirmVisible.value) {
inviteeConfirmVisible.value = false;
return true;
}
if (inviteeManagerVisible.value) {
closeInviteeManager();
return true;
}
return false;
};
defineExpose({ closeTransient });
onUnmounted(() => {
isActive = false;
invitationListRequestController.abort();
inviteeOptionsRequestController.abort();
inviteeSaveRequestController.abort();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.invitation-summary-card {
@include adaptive-records-content;
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 18rpx;
padding: 28rpx 32rpx;
}
.invitation-summary-card__heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.invitation-summary-card__heading > view { min-width: 0; flex: 1; }
.invitation-summary-card__actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10rpx;
}
.invitation-summary-card__heading text,
.invitation-summary-card__stats text,
.invitation-summary-card__empty text,
.invitation-summary-card__error > text { display: block; }
.invitation-summary-card__heading text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.invitation-summary-card__heading text:last-child,
.invitation-summary-card__empty,
.invitation-summary-card__error {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitation-summary-card__stats { display: flex; flex-wrap: wrap; gap: 12rpx; }
.invitation-summary-card__stats text {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(181, 137, 63, 0.1);
color: $ink;
font-size: clamp(13px, 21rpx, 16px);
}
.invitation-summary-card__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
color: #a22b20;
}
.invitee-manager {
padding-top: 18rpx;
border-top: 1rpx solid rgba(181, 137, 63, 0.32);
}
.invitee-manager__title,
.invitee-manager__note,
.invitee-manager__option text,
.invitee-manager__empty text,
.invitee-manager__error > text { display: block; }
.invitee-manager__title {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.invitee-manager__note,
.invitee-manager__empty,
.invitee-manager__error {
margin-top: 8rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
}
.invitee-manager__options {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 16rpx;
}
.invitee-manager__option {
display: flex;
min-height: 72rpx;
align-items: center;
justify-content: space-between;
gap: 14rpx;
padding: 14rpx 18rpx;
border: 1rpx solid rgba(181, 137, 63, 0.42);
border-radius: 10rpx;
background: rgba(255, 252, 244, 0.56);
}
.invitee-manager__option--selected {
border-color: rgba(159, 23, 15, 0.72);
background: rgba(159, 23, 15, 0.08);
}
.invitee-manager__option text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.invitee-manager__option text:last-child {
flex: 0 0 auto;
color: $ink-muted;
font-size: clamp(12px, 20rpx, 15px);
}
.invitee-manager__error { color: #a22b20; }
.invitee-manager__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 8rpx;
}
.invitee-manager__empty {
display: flex;
flex-direction: column;
gap: 14rpx;
}
</style>