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