feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+156
View File
@@ -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>
+286
View File
@@ -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>
+404
View File
@@ -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>
+339
View File
@@ -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>
+31
View File
@@ -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>
+208
View File
@@ -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>
+187
View File
@@ -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>
+189
View File
@@ -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>