feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<view v-if="visible" class="add-dialog-layer" @click="emit('close')">
|
||||
<view class="add-dialog" @click.stop>
|
||||
<view class="add-dialog__content">
|
||||
<view class="add-dialog__body">
|
||||
<view class="add-dialog__heading">
|
||||
<text class="dialog-title">添加家谱</text>
|
||||
<text class="dialog-copy">建议先搜索已有家谱,避免重复创建</text>
|
||||
<text v-if="creationQuota" class="dialog-copy">
|
||||
{{ creationQuota.createRemaining === -1
|
||||
? "当前可继续创建家谱"
|
||||
: `还可创建 ${creationQuota.createRemaining} 部家谱` }}
|
||||
</text>
|
||||
<view
|
||||
class="add-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="add-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="emit('search')" />
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="继续创建家谱"
|
||||
:disabled="creationQuota?.canCreate === false"
|
||||
@click="emit('create')"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
creationQuota: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "search", "create"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.add-dialog-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(34, 20, 12, 0.68);
|
||||
}
|
||||
|
||||
.add-dialog {
|
||||
@include adaptive.adaptive-genealogy-add-sheet;
|
||||
width: 100%;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
}
|
||||
|
||||
.add-dialog__content {
|
||||
display: flex;
|
||||
min-height: 780rpx;
|
||||
max-height: calc(100vh - 80rpx);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
padding: 96rpx 52rpx calc(96rpx + env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.add-dialog__body {
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.add-dialog__heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog .dialog-title,
|
||||
.add-dialog .dialog-copy {
|
||||
display: block;
|
||||
grid-column: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.dialog-copy {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.add-dialog__close {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -22rpx;
|
||||
}
|
||||
|
||||
.add-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 62rpx -32rpx 0;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button {
|
||||
width: 595rpx;
|
||||
max-width: 100%;
|
||||
min-height: 96rpx;
|
||||
}
|
||||
|
||||
.add-dialog__actions > .app-button + .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<view
|
||||
class="genealogy-card"
|
||||
:class="{ 'genealogy-card--current': selected }"
|
||||
role="button"
|
||||
:aria-label="accessibleLabel"
|
||||
hover-class="genealogy-card--pressed"
|
||||
@click="$emit('select', genealogy)"
|
||||
>
|
||||
<image
|
||||
class="row-frame"
|
||||
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="surname-seal">
|
||||
<image
|
||||
class="surname-seal-frame"
|
||||
src="/static/assets/modules/genealogy/transparent/row-seal-frame.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="surname-seal-copy">
|
||||
<text class="seal-title">家谱</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card-main">
|
||||
<view class="card-title-row">
|
||||
<text class="card-name">{{ genealogy.name }}</text>
|
||||
<image
|
||||
class="card-chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="card-detail-row">
|
||||
<view class="card-meta-item card-meta-item--location">
|
||||
<image
|
||||
class="card-meta-icon"
|
||||
src="/static/assets/foundation/transparent/meta-location.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="card-meta">{{ genealogy.location }}</text>
|
||||
</view>
|
||||
<view class="card-meta-item card-meta-item--members">
|
||||
<image
|
||||
class="card-meta-icon"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="card-meta">{{ genealogy.memberCount }} 位成员</text>
|
||||
</view>
|
||||
<view class="card-trailing">
|
||||
<text class="card-role app-single-line">{{ role }}</text>
|
||||
<text v-if="genealogy.updatedAt" class="card-updated app-single-line"
|
||||
>更新于 {{ genealogy.updatedAt }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
genealogy: { type: Object, required: true },
|
||||
role: { type: String, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
});
|
||||
defineEmits(["select"]);
|
||||
|
||||
const accessibleLabel = computed(
|
||||
() =>
|
||||
`${props.genealogy.name},${props.role}${props.selected ? ',当前家谱' : ''},${props.genealogy.location},${props.genealogy.memberCount} 位成员`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.genealogy-card {
|
||||
--card-padding-x: 24rpx;
|
||||
--card-padding-y: 20rpx;
|
||||
display: grid;
|
||||
grid-template-columns: 68rpx minmax(0, 1fr);
|
||||
min-height: 204rpx;
|
||||
align-items: center;
|
||||
column-gap: 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.genealogy-card--current {
|
||||
background: transparent;
|
||||
}
|
||||
.genealogy-card--pressed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.row-frame {
|
||||
grid-area: 1 / 1 / 2 / -1;
|
||||
z-index: 1;
|
||||
width: calc(100% + var(--card-padding-x) + var(--card-padding-x));
|
||||
height: calc(100% + var(--card-padding-y) + var(--card-padding-y));
|
||||
margin: calc(-1 * var(--card-padding-y)) calc(-1 * var(--card-padding-x));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surname-seal {
|
||||
display: grid;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
z-index: 2;
|
||||
width: 68rpx;
|
||||
height: 106rpx;
|
||||
place-items: center;
|
||||
color: #fff5df;
|
||||
}
|
||||
|
||||
.surname-seal-frame {
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.surname-seal-frame,
|
||||
.surname-seal-copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.surname-seal-copy {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 78rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.seal-title {
|
||||
color: #fff5df;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
line-height: 1;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
.genealogy-card:not(.genealogy-card--current) .surname-seal-frame {
|
||||
opacity: 0.32;
|
||||
}
|
||||
.genealogy-card:not(.genealogy-card--current) .seal-title {
|
||||
color: $ink-muted;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.card-name {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(18px, 34rpx, 23px);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.card-detail-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
column-gap: 16rpx;
|
||||
row-gap: 4rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.card-meta-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.card-meta-item--location {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.card-meta-item--members {
|
||||
grid-column: 1;
|
||||
}
|
||||
.card-meta-icon {
|
||||
width: 38rpx;
|
||||
height: 38rpx;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 6rpx;
|
||||
opacity: 1;
|
||||
filter: saturate(1.35) brightness(0.82) contrast(1.15);
|
||||
}
|
||||
.card-meta {
|
||||
min-width: 0;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-trailing {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
row-gap: 4rpx;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-updated {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
color: #62584c;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.card-role {
|
||||
min-width: 0;
|
||||
padding: 3rpx 10rpx 4rpx;
|
||||
border: 1rpx solid rgba(181, 138, 75, 0.56);
|
||||
border-radius: 999rpx;
|
||||
color: #7f4f16;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-chevron {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 340px) {
|
||||
.genealogy-card {
|
||||
--card-padding-x: 18rpx;
|
||||
grid-template-columns: 62rpx minmax(0, 1fr);
|
||||
column-gap: 16rpx;
|
||||
min-height: 176rpx;
|
||||
padding-right: 18rpx;
|
||||
padding-left: 18rpx;
|
||||
}
|
||||
.surname-seal {
|
||||
width: 62rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
.card-name {
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
}
|
||||
.card-meta {
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.card-updated {
|
||||
display: none;
|
||||
}
|
||||
.card-chevron {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-order-layer" @click="requestClose">
|
||||
<view
|
||||
class="genealogy-order-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="调整家谱排序"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-order-dialog__head">
|
||||
<view>
|
||||
<text class="dialog-title">调整家谱排序</text>
|
||||
<text class="genealogy-order-dialog__copy">保存后将按此顺序显示我的家谱</text>
|
||||
</view>
|
||||
<view
|
||||
class="genealogy-order-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭排序"
|
||||
hover-class="action-hover"
|
||||
@click="requestClose"
|
||||
>
|
||||
<image
|
||||
class="genealogy-order-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="genealogy-order-list" scroll-y>
|
||||
<view
|
||||
v-for="(genealogy, index) in orderDraft"
|
||||
:key="genealogy.id"
|
||||
class="genealogy-order-item"
|
||||
>
|
||||
<view class="genealogy-order-item__main">
|
||||
<text class="genealogy-order-item__position">{{ index + 1 }}</text>
|
||||
<view>
|
||||
<text class="genealogy-order-item__name">{{ genealogy.name }}</text>
|
||||
<text class="genealogy-order-item__role">
|
||||
{{ genealogy.canManage ? "管理员" : "成员" }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="genealogy-order-item__actions">
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === 0"
|
||||
@click="moveOrderItem(index, -1)"
|
||||
>上移</button>
|
||||
<button
|
||||
class="genealogy-order-item__move"
|
||||
:disabled="saving || index === orderDraft.length - 1"
|
||||
@click="moveOrderItem(index, 1)"
|
||||
>下移</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<text v-if="orderError" class="genealogy-order-error">{{ orderError }}</text>
|
||||
<view class="genealogy-order-dialog__actions">
|
||||
<AppButton type="secondary" :disabled="saving" label="取消" @click="requestClose" />
|
||||
<AppButton
|
||||
:disabled="saving || !isOrderDirty"
|
||||
:label="saving ? '正在保存' : '保存排序'"
|
||||
@click="saveOrder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "saved"]);
|
||||
const orderDraft = ref([]);
|
||||
const saving = ref(false);
|
||||
const orderError = ref("");
|
||||
const orderSaveRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
|
||||
const isOrderDirty = computed(() =>
|
||||
orderDraft.value.some(
|
||||
(genealogy, index) => genealogy.id !== props.genealogies[index]?.id,
|
||||
),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
orderDraft.value = props.genealogies.map((genealogy) => ({ ...genealogy }));
|
||||
orderError.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const requestClose = () => {
|
||||
if (saving.value) return false;
|
||||
orderError.value = "";
|
||||
emit("close");
|
||||
return true;
|
||||
};
|
||||
|
||||
const moveOrderItem = (sourceIndex, direction) => {
|
||||
const targetIndex = sourceIndex + direction;
|
||||
if (
|
||||
saving.value ||
|
||||
targetIndex < 0 ||
|
||||
targetIndex >= orderDraft.value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextOrder = [...orderDraft.value];
|
||||
[nextOrder[sourceIndex], nextOrder[targetIndex]] = [
|
||||
nextOrder[targetIndex],
|
||||
nextOrder[sourceIndex],
|
||||
];
|
||||
orderDraft.value = nextOrder;
|
||||
orderError.value = "";
|
||||
};
|
||||
|
||||
const saveOrder = async () => {
|
||||
if (saving.value || !isOrderDirty.value) return;
|
||||
const draftIds = orderDraft.value.map((genealogy) => String(genealogy.id));
|
||||
const currentIds = props.genealogies.map((genealogy) => String(genealogy.id));
|
||||
if (
|
||||
draftIds.length !== currentIds.length ||
|
||||
new Set(draftIds).size !== draftIds.length ||
|
||||
draftIds.some((id) => !currentIds.includes(id))
|
||||
) {
|
||||
orderError.value = "当前家谱列表已变化,请关闭后重新调整。";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
orderError.value = "";
|
||||
try {
|
||||
const confirmedGenealogies = await genealogyApi.saveMyGenealogyOrder(draftIds, {
|
||||
requestController: orderSaveRequestController,
|
||||
});
|
||||
if (!componentActive) return;
|
||||
emit("saved", confirmedGenealogies);
|
||||
} catch (error) {
|
||||
if (componentActive && !isRequestCancelled(error)) {
|
||||
orderError.value = getRequestErrorMessage(error, "保存排序失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (componentActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
orderSaveRequestController.abort();
|
||||
});
|
||||
|
||||
defineExpose({ requestClose });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.genealogy-order-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 32rpx;
|
||||
background: rgba(36, 24, 16, 0.54);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog {
|
||||
width: 100%;
|
||||
max-width: 680rpx;
|
||||
max-height: 80vh;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid rgba(128, 78, 29, 0.28);
|
||||
border-radius: 24rpx;
|
||||
background: #f9f6ef;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__name,
|
||||
.genealogy-order-item__role,
|
||||
.genealogy-order-error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__copy,
|
||||
.genealogy-order-item__role {
|
||||
margin-top: 8rpx;
|
||||
color: #8a7564;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close {
|
||||
display: flex;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-list {
|
||||
max-height: 720rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
min-height: 108rpx;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid rgba(149, 103, 49, 0.16);
|
||||
}
|
||||
|
||||
.genealogy-order-item__main,
|
||||
.genealogy-order-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main {
|
||||
min-width: 0;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__main > view {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.genealogy-order-item__position {
|
||||
display: grid;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #7b4024;
|
||||
color: #fffaf0;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.genealogy-order-item__name {
|
||||
color: #3e2b20;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.genealogy-order-item__actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move {
|
||||
min-width: 82rpx;
|
||||
height: 54rpx;
|
||||
margin: 0;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(123, 64, 36, 0.46);
|
||||
border-radius: 8rpx;
|
||||
background: transparent;
|
||||
color: #7b4024;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 52rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.genealogy-order-item__move[disabled] {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.genealogy-order-error {
|
||||
margin-top: 20rpx;
|
||||
color: #b3432f;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 18rpx;
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.genealogy-order-dialog__actions .app-button {
|
||||
width: 220rpx;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<view class="genealogy-page-background">
|
||||
<image
|
||||
class="genealogy-page-background__art"
|
||||
src="/static/assets/modules/genealogy/opaque/genealogy-page-background-long.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.genealogy-page-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background: #e7ded1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.genealogy-page-background__art {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<RegionPickerSheet
|
||||
:visible="visible"
|
||||
:columns="columns"
|
||||
:indexes="indexes"
|
||||
:indicator-style="indicatorStyle"
|
||||
:close-on-mask="closeOnMask"
|
||||
@change="changeSelection"
|
||||
@cancel="close"
|
||||
@confirm="confirmSelection"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import RegionPickerSheet from "@/components/genealogy/RegionPickerSheet.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { regionApi } from "@/services/api/region-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
|
||||
const props = defineProps({
|
||||
closeOnMask: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxLevels: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
validator: (levelCount) => Number.isInteger(levelCount) && levelCount > 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"error-change",
|
||||
"loading-change",
|
||||
"select",
|
||||
"transient-change",
|
||||
]);
|
||||
|
||||
const indicatorStyle =
|
||||
"height: 104rpx; border-top: 1px solid rgba(159, 23, 15, .46); border-bottom: 1px solid rgba(159, 23, 15, .46); background: rgba(159, 23, 15, .08);";
|
||||
const visible = ref(false);
|
||||
const columns = ref([]);
|
||||
const indexes = ref([0]);
|
||||
const selectedTrail = ref([]);
|
||||
const preparedRegionCode = ref("");
|
||||
const regionRequestController = createRequestController();
|
||||
let componentActive = true;
|
||||
let loading = false;
|
||||
|
||||
const setLoading = (nextLoading) => {
|
||||
loading = nextLoading;
|
||||
emit("loading-change", nextLoading);
|
||||
};
|
||||
const setError = (message = "") => {
|
||||
emit("error-change", message);
|
||||
};
|
||||
const fetchRegionChildren = (parentCode) =>
|
||||
regionApi.getRegionChildren(parentCode, {
|
||||
requestController: regionRequestController,
|
||||
});
|
||||
|
||||
const loadColumns = async (requestedIndexes = []) => {
|
||||
const rootOptions = columns.value[0] || (await fetchRegionChildren("0"));
|
||||
if (!rootOptions.length) return false;
|
||||
const nextColumns = [rootOptions];
|
||||
const nextIndexes = [];
|
||||
let options = rootOptions;
|
||||
for (let level = 0; level < props.maxLevels; level += 1) {
|
||||
const requestedIndex = Number(requestedIndexes[level]);
|
||||
const selectedIndex = Number.isInteger(requestedIndex)
|
||||
? Math.min(Math.max(requestedIndex, 0), options.length - 1)
|
||||
: 0;
|
||||
const selectedOption = options[selectedIndex];
|
||||
if (!selectedOption) break;
|
||||
nextIndexes.push(selectedIndex);
|
||||
if (level === props.maxLevels - 1) break;
|
||||
const childOptions = await fetchRegionChildren(selectedOption.regionCode);
|
||||
if (!childOptions.length) break;
|
||||
nextColumns.push(childOptions);
|
||||
options = childOptions;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextColumns
|
||||
.map((column, level) => column[nextIndexes[level]])
|
||||
.filter(Boolean);
|
||||
return Boolean(selectedTrail.value.length);
|
||||
};
|
||||
|
||||
const loadRegionPath = async (regionCode) => {
|
||||
const regionPath = (await regionApi.getRegionPath(regionCode, {
|
||||
requestController: regionRequestController,
|
||||
})).filter((regionNode) => regionNode?.regionCode);
|
||||
if (
|
||||
!regionPath.length ||
|
||||
regionPath[regionPath.length - 1].regionCode !== regionCode
|
||||
) {
|
||||
throw new Error("REGION_PATH_MISMATCH");
|
||||
}
|
||||
const nextColumns = [];
|
||||
const nextIndexes = [];
|
||||
const nextTrail = [];
|
||||
let parentCode = "0";
|
||||
for (const regionPathNode of regionPath.slice(0, props.maxLevels)) {
|
||||
const options = await fetchRegionChildren(parentCode);
|
||||
const selectedIndex = options.findIndex(
|
||||
(option) => option.regionCode === regionPathNode.regionCode,
|
||||
);
|
||||
if (selectedIndex < 0) throw new Error("REGION_PATH_MISMATCH");
|
||||
nextColumns.push(options);
|
||||
nextIndexes.push(selectedIndex);
|
||||
nextTrail.push(options[selectedIndex]);
|
||||
parentCode = regionPathNode.regionCode;
|
||||
}
|
||||
if (!componentActive) return false;
|
||||
columns.value = nextColumns;
|
||||
indexes.value = nextIndexes;
|
||||
selectedTrail.value = nextTrail;
|
||||
return Boolean(nextTrail.length);
|
||||
};
|
||||
|
||||
const prepare = async (initialRegionCode = "") => {
|
||||
const normalizedRegionCode = String(initialRegionCode || "");
|
||||
if (loading) return false;
|
||||
if (
|
||||
columns.value.length &&
|
||||
preparedRegionCode.value === normalizedRegionCode
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
regionRequestController.abort();
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const ready = normalizedRegionCode
|
||||
? await loadRegionPath(normalizedRegionCode)
|
||||
: await loadColumns([0]);
|
||||
if (!componentActive || !ready) return false;
|
||||
preparedRegionCode.value = normalizedRegionCode;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return false;
|
||||
const fallback = normalizedRegionCode
|
||||
? "暂时无法定位当前地区,请稍后重试。"
|
||||
: "地区列表加载失败,请重试";
|
||||
setError(getRequestErrorMessage(error, fallback));
|
||||
return false;
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (initialRegionCode = "") => {
|
||||
if (!(await prepare(initialRegionCode))) return false;
|
||||
visible.value = true;
|
||||
emit("transient-change", true);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
visible.value = false;
|
||||
emit("transient-change", false);
|
||||
};
|
||||
const changeSelection = async (event) => {
|
||||
if (loading) return;
|
||||
const requestedIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const changedLevel = requestedIndexes.findIndex(
|
||||
(index, level) => index !== (indexes.value[level] || 0),
|
||||
);
|
||||
if (changedLevel < 0) return;
|
||||
setLoading(true);
|
||||
setError();
|
||||
try {
|
||||
const loaded = await loadColumns(requestedIndexes.slice(0, changedLevel + 1));
|
||||
if (!componentActive || !loaded) return;
|
||||
preparedRegionCode.value = "";
|
||||
} catch (error) {
|
||||
if (!componentActive || isRequestCancelled(error)) return;
|
||||
setError(getRequestErrorMessage(error, "地区列表加载失败,请重试"));
|
||||
} finally {
|
||||
if (componentActive) setLoading(false);
|
||||
}
|
||||
};
|
||||
const confirmSelection = () => {
|
||||
const trail = columns.value
|
||||
.map((column, level) => column[Number(indexes.value[level])])
|
||||
.filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
selectedTrail.value = trail;
|
||||
preparedRegionCode.value = region.regionCode;
|
||||
emit("select", { region, trail });
|
||||
close();
|
||||
};
|
||||
|
||||
defineExpose({ close, open, prepare });
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive = false;
|
||||
regionRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<view v-if="visible" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="requestMaskClose" />
|
||||
<view class="region-sheet__panel">
|
||||
<view class="region-sheet__intro">
|
||||
<text class="region-sheet__title">选择地区</text>
|
||||
</view>
|
||||
<view class="region-sheet__picker">
|
||||
<view class="region-sheet__column-headings">
|
||||
<text
|
||||
v-for="label in columnLabels"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>
|
||||
{{ label }}
|
||||
</text>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="indicatorStyle"
|
||||
:value="indexes"
|
||||
@change="$emit('change', $event)"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
indexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>
|
||||
{{ option.label }}
|
||||
</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="$emit('cancel')">取消</view>
|
||||
<button class="region-sheet__confirm" @click="$emit('confirm')">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
columns: { type: Array, default: () => [] },
|
||||
indexes: { type: Array, default: () => [] },
|
||||
indicatorStyle: { type: String, required: true },
|
||||
closeOnMask: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["change", "cancel", "confirm"]);
|
||||
|
||||
const regionLevelLabels = Object.freeze([
|
||||
"省份",
|
||||
"城市",
|
||||
"区县",
|
||||
"乡镇街道",
|
||||
"村社区",
|
||||
]);
|
||||
const columnLabels = computed(() =>
|
||||
regionLevelLabels.slice(0, props.columns.length),
|
||||
);
|
||||
|
||||
const requestMaskClose = () => {
|
||||
if (props.closeOnMask) emit("cancel");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.region-sheet {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.region-sheet__mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(43, 30, 20, 0.42);
|
||||
}
|
||||
.region-sheet__panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 22rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
border-radius: 30rpx 30rpx 0 0;
|
||||
background: #fdf9ef;
|
||||
box-shadow: 0 -12rpx 36rpx rgba(43, 30, 20, 0.2);
|
||||
}
|
||||
.region-sheet__intro {
|
||||
padding: 0 10rpx 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__title {
|
||||
display: block;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 36rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__picker {
|
||||
overflow: hidden;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.28);
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 252, 245, 0.8);
|
||||
}
|
||||
.region-sheet__column-headings {
|
||||
display: flex;
|
||||
height: 84rpx;
|
||||
border-bottom: 1rpx solid rgba(128, 89, 49, 0.18);
|
||||
}
|
||||
.region-sheet__column-heading {
|
||||
box-sizing: border-box;
|
||||
width: 33.333%;
|
||||
padding: 24rpx 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__column-heading + .region-sheet__column-heading {
|
||||
border-left: 1rpx solid rgba(128, 89, 49, 0.16);
|
||||
}
|
||||
.region-sheet__picker-view {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
}
|
||||
.region-sheet__picker-item {
|
||||
box-sizing: border-box;
|
||||
height: 104rpx;
|
||||
overflow: hidden;
|
||||
padding: 0 6rpx;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
line-height: 104rpx;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region-sheet__picker-item--selected {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.region-sheet__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 22rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
.region-sheet__cancel {
|
||||
min-width: 116rpx;
|
||||
padding: 20rpx 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
text-align: center;
|
||||
}
|
||||
.region-sheet__confirm {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 10rpx;
|
||||
background: $brand-red;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<view v-if="visible" class="genealogy-switcher-layer" @click="emit('close')">
|
||||
<view
|
||||
class="genealogy-switcher"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="切换当前家谱"
|
||||
@click.stop
|
||||
>
|
||||
<view class="genealogy-switcher__content">
|
||||
<text class="dialog-title">切换当前家谱</text>
|
||||
<view
|
||||
class="genealogy-switcher__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<image
|
||||
class="genealogy-switcher__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<button
|
||||
v-for="genealogy in genealogies"
|
||||
:key="genealogy.id"
|
||||
class="switcher-item"
|
||||
:class="{ 'switcher-item--active': genealogy.id === selectedGenealogyId }"
|
||||
:aria-pressed="genealogy.id === selectedGenealogyId"
|
||||
:aria-label="`${genealogy.name},${genealogy.location},${genealogy.memberCount} 位成员`"
|
||||
@click="emit('select', genealogy)"
|
||||
>
|
||||
<view>
|
||||
<text class="switcher-item__name">{{ genealogy.name }}</text>
|
||||
<text class="switcher-item__meta">
|
||||
{{ genealogy.location }} · {{ genealogy.memberCount }} 位成员
|
||||
</text>
|
||||
</view>
|
||||
<text class="switcher-item__state">
|
||||
{{ genealogy.id === selectedGenealogyId ? "当前" : "选择" }}
|
||||
</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, required: true },
|
||||
genealogies: { type: Array, required: true },
|
||||
selectedGenealogyId: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "select"]);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.genealogy-switcher-layer {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 40rpx;
|
||||
background: rgba(34, 20, 12, 0.58);
|
||||
}
|
||||
|
||||
.genealogy-switcher {
|
||||
@include adaptive.adaptive-genealogy-switcher;
|
||||
width: 670rpx;
|
||||
max-width: 100%;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
}
|
||||
|
||||
.genealogy-switcher__content {
|
||||
display: grid;
|
||||
min-height: 600rpx;
|
||||
max-height: calc(100vh - 120rpx);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 120rpx 58rpx 140rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__content > .dialog-title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
color: $brand-red;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 42rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close {
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: -42rpx;
|
||||
margin-right: -24rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__close-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
}
|
||||
|
||||
.genealogy-switcher__list {
|
||||
grid-area: 2 / 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 456rpx);
|
||||
margin-top: 24rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.switcher-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 112rpx;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 18rpx 16rpx;
|
||||
border: 1rpx solid transparent;
|
||||
border-bottom-color: rgba(181, 138, 75, 0.42);
|
||||
background: transparent;
|
||||
line-height: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.switcher-item::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.switcher-item__name,
|
||||
.switcher-item__meta {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.switcher-item__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.switcher-item__meta {
|
||||
margin-top: 6rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
|
||||
.switcher-item__state {
|
||||
color: $brand-red;
|
||||
font-size: clamp(15px, 25rpx, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.switcher-item--active {
|
||||
border-color: rgba(159, 23, 15, 0.22);
|
||||
background: rgba(159, 23, 15, 0.055);
|
||||
}
|
||||
|
||||
.switcher-item--active .switcher-item__name {
|
||||
color: $brand-red;
|
||||
}
|
||||
|
||||
.action-hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user