Files
jiapuapp/pages/genealogy/my-genealogies.vue
T
2026-09-13 17:45:52 +08:00

909 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view
class="page-shell genealogy-index"
:class="{ 'genealogy-index--split': isListLayout }"
>
<GenealogyPageBackground tone="light" />
<PageHeader
root
notice
title="我的家谱"
:unread-count="unreadCount"
@notice="toNotifications"
/>
<view class="genealogy-content">
<view v-if="isLoading" class="state-panel state-panel--loading">
<AppLoading
text="正在整理家谱"
description="请稍候,家族记忆正在归卷。"
/>
</view>
<view v-else-if="hasError" class="state-panel state-panel--error">
<view class="error-panel__content">
<image
class="error-panel__seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<text class="state-title">暂时无法读取家谱</text>
<text class="state-copy">{{ genealogyListError }}</text>
<image
class="error-panel__divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view
class="state-retry"
hover-class="action-hover"
@click="retryGenealogyLoad"
>
<text class="state-retry__copy">重新加载</text>
</view>
</view>
</view>
<view
v-else-if="contextInvalidated"
class="state-panel state-panel--context"
>
<text class="state-title">当前家谱已不可用</text>
<text class="state-copy"
>权限或家谱列表可能已经变化请明确选择仍可访问的家谱</text
>
<AppButton block label="选择可用家谱" @click="openSwitcher" />
</view>
<template v-else-if="hasGenealogies">
<scroll-view
class="genealogy-list-scroll"
scroll-y
:scroll-top="listScrollCommand"
@scroll="handleListScroll"
>
<view class="genealogy-lower">
<view class="genealogy-list-toolbar">
<view class="section-heading genealogy-list-heading">
<text>{{ managedGenealogies.length ? "我管理的" : "我加入的" }}</text>
</view>
</view>
<view v-if="managedGenealogies.length" class="list-section">
<GenealogyCard
v-for="genealogy in managedGenealogies"
:key="genealogy.id"
:genealogy="genealogy"
role="管理员"
:selected="genealogy.id === currentGenealogy.id"
@select="openGenealogy"
/>
</view>
<view v-if="memberGenealogies.length" class="list-section">
<view v-if="managedGenealogies.length" class="section-heading"><text>我加入的</text></view>
<GenealogyCard
v-for="genealogy in memberGenealogies"
:key="genealogy.id"
:genealogy="genealogy"
role="成员"
:selected="genealogy.id === currentGenealogy.id"
@select="openGenealogy"
/>
</view>
<view
v-if="genealogies.length > 1"
class="genealogy-order-trigger"
role="button"
aria-label="调整我的家谱排序"
hover-class="action-hover"
@click="openOrderDialog"
>
<view>
<text class="genealogy-order-trigger__title">调整家谱排序</text>
<text class="genealogy-order-trigger__copy"
>按上移下移调整显示顺序</text
>
</view>
<image
class="genealogy-order-trigger__chevron"
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view class="create-action" hover-class="action-hover" @click="openAddDialog">
<image
class="create-cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
<image
class="create-icon"
src="/static/assets/modules/genealogy/transparent/add.png"
mode="aspectFit"
aria-hidden="true"
/>
<text>添加家谱</text>
<image
class="create-cloud create-cloud--right"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
</view>
</scroll-view>
<HomeAdvertisementPanel
class="home-content-fixed"
@open="openContentHome"
/>
</template>
<view v-else class="genealogy-empty-state">
<view class="empty-panel">
<view class="empty-panel__content">
<view class="empty-seal">
<text class="empty-seal__text">家谱</text>
</view>
<text class="empty-title">还没有加入任何家谱</text>
<text class="empty-copy"
>先搜索家族是否已有家谱再选择加入或创建</text
>
<image
class="empty-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<view
class="empty-search-action"
hover-class="action-hover"
@click="applyToJoin"
>
<text
class="empty-search-action__copy empty-search-action__copy--primary"
>搜索家谱</text
>
</view>
<view
class="empty-create-action"
hover-class="action-hover"
@click="createGenealogy"
>
<text class="empty-create-action__copy">创建家谱</text>
</view>
<text class="empty-create-note"
>确认没有现有家谱后再创建避免重复建谱</text
>
</view>
</view>
</view>
</view>
<GenealogyAddDialog
:visible="addDialogVisible"
:creation-quota="creationQuota"
@close="closeAddDialog"
@search="applyToJoin"
@create="createGenealogy"
/>
<GenealogySwitcherDialog
:visible="switcherVisible"
:genealogies="genealogies"
:selected-genealogy-id="selectedGenealogyId"
@close="closeSwitcher"
@select="selectGenealogy"
/>
<GenealogyOrderDialog
ref="genealogyOrderDialog"
:visible="orderDialogVisible"
:genealogies="genealogies"
@close="closeOrderDialog"
@saved="applySavedGenealogyOrder"
/>
<AppTabbar active="genealogy" tone="light" />
</view>
</template>
<script setup>
import { computed, nextTick, ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import AppButton from "@/components/AppButton.vue";
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
import GenealogyCard from "@/components/genealogy/Card.vue";
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import {
consumeNavigationResult,
goRoot,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation/gateway.js";
const isLoading = ref(false);
const hasError = ref(false);
const genealogyListError = ref("");
const genealogies = ref([]);
const contextInvalidated = ref(false);
const contextReconcileFailed = ref(false);
const requestedGenealogyId = ref("");
const addDialogVisible = ref(false);
const switcherVisible = ref(false);
const orderDialogVisible = ref(false);
const genealogyOrderDialog = ref(null);
const selectedGenealogyId = ref(null);
const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
const unreadCount = ref(0);
const creationQuota = ref(null);
const genealogyListRequestController = createRequestController();
const unreadRequestController = createRequestController();
const quotaRequestController = createRequestController();
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
let genealogyLoadGeneration = 0;
let unreadLoadGeneration = 0;
let quotaLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
const reconcilePageGenealogyContext = () => {
try {
const availableIds = genealogies.value.map((genealogy) =>
String(genealogy.id),
);
const previousId = genealogyContext.getCurrentGenealogyId();
selectedGenealogyId.value =
genealogyContext.reconcileCurrentGenealogyId(
availableIds,
requestedGenealogyId.value,
) || null;
contextReconcileFailed.value = false;
contextInvalidated.value = Boolean(
availableIds.length &&
!selectedGenealogyId.value &&
(requestedGenealogyId.value ||
previousId ||
genealogyContext.isCurrentGenealogyInvalidated()),
);
return true;
} catch {
genealogyContext.invalidateCurrentGenealogyId();
selectedGenealogyId.value = null;
contextReconcileFailed.value = true;
contextInvalidated.value = false;
genealogyListError.value = "本机家谱选择状态异常,请重新加载。";
hasError.value = true;
return false;
}
};
const loadGenealogies = async () => {
const generation = ++genealogyLoadGeneration;
genealogyListRequestController.abort();
isLoading.value = true;
hasError.value = false;
genealogyListError.value = "";
try {
const loadedGenealogies = await genealogyApi.getMyGenealogies({
requestController: genealogyListRequestController,
});
if (!pageActive || generation !== genealogyLoadGeneration) return;
genealogies.value = loadedGenealogies;
reconcilePageGenealogyContext();
} catch (error) {
if (
!pageActive ||
generation !== genealogyLoadGeneration ||
isRequestCancelled(error)
) {
return;
}
genealogyListError.value =
error?.code === "GENEALOGY_RESPONSE_INVALID"
? "服务返回的家谱数据不完整,请联系管理员处理。"
: getRequestErrorMessage(error, "家谱服务暂时无法读取,请稍后重试。");
hasError.value = true;
} finally {
if (pageActive && generation === genealogyLoadGeneration) {
isLoading.value = false;
}
}
};
const loadUnreadCount = async () => {
const generation = ++unreadLoadGeneration;
unreadRequestController.abort();
try {
const count = await notificationApi.getUnreadNotificationCount({
requestController: unreadRequestController,
});
if (pageActive && generation === unreadLoadGeneration)
unreadCount.value = count;
} catch (error) {
if (!isRequestCancelled(error)) unreadCount.value = 0;
}
};
const loadQuota = async () => {
const generation = ++quotaLoadGeneration;
quotaRequestController.abort();
try {
const genealogyQuota = await genealogyApi.getGenealogyQuota({
requestController: quotaRequestController,
});
if (pageActive && generation === quotaLoadGeneration) {
creationQuota.value = genealogyQuota;
}
} catch (error) {
if (
!isRequestCancelled(error) &&
pageActive &&
generation === quotaLoadGeneration
)
creationQuota.value = null;
}
};
onLoad((query) => {
requestedGenealogyId.value = String(query?.genealogyId || "");
loadGenealogies();
loadUnreadCount();
loadQuota();
});
onShow(() => {
const navigationResult = consumeNavigationResult("G01");
if (
navigationResult?.operation === "genealogy-created" &&
navigationResult.refresh &&
navigationResult.entityId
) {
requestedGenealogyId.value = navigationResult.entityId;
loadGenealogies();
return;
}
if (skipNextShowRefresh) {
skipNextShowRefresh = false;
return;
}
loadGenealogies();
loadUnreadCount();
loadQuota();
});
onUnload(() => {
pageActive = false;
genealogyLoadGeneration += 1;
unreadLoadGeneration += 1;
quotaLoadGeneration += 1;
genealogyListRequestController.abort();
unreadRequestController.abort();
quotaRequestController.abort();
});
const hasGenealogies = computed(() => genealogies.value.length > 0);
const isListLayout = computed(
() =>
!isLoading.value &&
!hasError.value &&
!contextInvalidated.value &&
hasGenealogies.value,
);
const managedGenealogies = computed(() =>
genealogies.value.filter((genealogy) => genealogy.canManage),
);
const memberGenealogies = computed(() =>
genealogies.value.filter((genealogy) => !genealogy.canManage),
);
const currentGenealogy = computed(
() =>
genealogies.value.find(
(genealogy) => genealogy.id === selectedGenealogyId.value,
) || null,
);
const openGenealogy = (genealogy) =>
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
(opened) => {
if (opened) {
selectedGenealogyId.value = String(genealogy.id);
genealogyContext.setCurrentGenealogyId(genealogy.id);
}
return opened;
},
);
const createGenealogy = () => {
closeAddDialog();
return openPage("G03", {}, "G01");
};
const applyToJoin = () => {
closeAddDialog();
return openPage("G06", {}, "G01");
};
const toNotifications = () => {
const notificationParams = currentGenealogy.value
? { genealogyId: String(currentGenealogy.value.id) }
: {};
return openPage("N01", notificationParams, "G01");
};
const openAddDialog = () => {
addDialogVisible.value = true;
};
const closeAddDialog = () => {
addDialogVisible.value = false;
};
const openSwitcher = () => {
switcherVisible.value = true;
};
const closeSwitcher = () => {
switcherVisible.value = false;
};
const openContentHome = () => goRoot("F01");
const openOrderDialog = () => {
if (genealogies.value.length < 2) return;
orderDialogVisible.value = true;
};
const closeOrderDialog = () => {
orderDialogVisible.value = false;
};
const applySavedGenealogyOrder = async (confirmedGenealogies) => {
if (!pageActive) return;
genealogies.value = confirmedGenealogies;
reconcilePageGenealogyContext();
orderDialogVisible.value = false;
await resetListScroll();
};
const closeActiveOverlay = () => {
if (switcherVisible.value) closeSwitcher();
else if (orderDialogVisible.value) genealogyOrderDialog.value?.requestClose();
else closeAddDialog();
};
const requestBack = () =>
runBackGuard({
transientOpen:
switcherVisible.value || addDialogVisible.value || orderDialogVisible.value,
"close-transient": closeActiveOverlay,
});
onBackPress((event) => {
if (
!switcherVisible.value &&
!addDialogVisible.value &&
!orderDialogVisible.value
) {
return false;
}
return handleBackPress(event, requestBack);
});
const handleListScroll = (event) => {
currentListScrollTop.value = Number(event?.detail?.scrollTop || 0);
};
const resetListScroll = async () => {
listScrollCommand.value = currentListScrollTop.value;
await nextTick();
listScrollCommand.value = 0;
currentListScrollTop.value = 0;
};
const selectGenealogy = async (genealogy) => {
selectedGenealogyId.value = String(genealogy.id);
genealogyContext.setCurrentGenealogyId(String(genealogy.id));
contextInvalidated.value = false;
closeSwitcher();
await resetListScroll();
};
const retryGenealogyLoad = () => {
loadGenealogies();
};
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.genealogy-index {
display: flex;
min-height: 100vh;
flex-direction: column;
overflow: hidden;
background: #faf8f3;
}
.genealogy-content {
z-index: 1;
padding: 0 32rpx calc(176rpx + env(safe-area-inset-bottom));
}
.genealogy-index--split {
display: flex;
height: 100vh;
min-height: 0;
flex-direction: column;
padding-bottom: calc(112rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.genealogy-index--split .genealogy-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding-bottom: 0;
}
.genealogy-list-scroll {
width: 100%;
height: 0;
min-height: 0;
flex: 1;
}
.genealogy-list-toolbar {
display: flex;
align-items: center;
min-height: 70rpx;
padding: 0 4rpx 2rpx;
box-sizing: border-box;
}
.genealogy-list-heading {
flex: 1;
margin-bottom: 0;
}
.genealogy-lower {
padding: 0 0 24rpx;
}
.list-section {
margin-top: 6rpx;
}
.section-heading {
display: flex;
min-height: 56rpx;
align-items: center;
margin-bottom: 8rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
}
.section-heading::before {
width: 8rpx;
height: 34rpx;
margin-right: 14rpx;
border-radius: 8rpx;
background: $brand-red;
content: "";
}
.list-section + .list-section {
margin-top: 18rpx;
}
.genealogy-order-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
min-height: 112rpx;
margin: 20rpx 0 24rpx;
padding: 24rpx 28rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 253, 249, 0.84);
box-sizing: border-box;
}
.genealogy-order-trigger__title,
.genealogy-order-trigger__copy {
display: block;
}
.genealogy-order-trigger__title {
color: #5c4330;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.genealogy-order-trigger__copy {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.genealogy-order-trigger__chevron {
width: 26rpx;
height: 26rpx;
flex: 0 0 auto;
}
.create-action {
display: flex;
width: 350rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin: 8rpx auto 24rpx;
border: 2rpx solid $brand-red;
border-radius: 48rpx;
background: rgba(255, 253, 249, 0.8);
box-sizing: border-box;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
letter-spacing: 3rpx;
}
.create-icon {
width: 40rpx;
height: 40rpx;
margin: 0 8rpx;
}
.create-cloud {
width: 54rpx;
height: 28rpx;
flex: 0 0 auto;
}
.create-action > text {
flex: 0 0 auto;
text-align: center;
}
.create-cloud--right {
transform: scaleX(-1);
}
.home-content-fixed {
flex: 0 0 auto;
margin-top: 8rpx;
}
.state-panel {
display: flex;
min-height: 540rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 42rpx;
box-sizing: border-box;
text-align: center;
}
.state-title {
margin-top: 24rpx;
color: $ink;
font-family: serif;
font-size: clamp(20px, 38rpx, 26px);
font-weight: 700;
}
.state-copy {
margin-top: 14rpx;
color: $ink-muted;
font-size: clamp(15px, 25rpx, 18px);
line-height: 1.7;
}
.state-panel--error {
@include adaptive-genealogy-state-panel;
min-height: 1120rpx;
padding: 0;
}
.state-panel--context > .app-button {
margin-top: 30rpx;
}
.error-panel__content {
display: flex;
width: 100%;
min-height: 1120rpx;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 50rpx 28rpx 46rpx;
}
.error-panel__seal {
width: 132rpx;
height: 136rpx;
}
.state-panel--error .state-title {
display: block;
margin-top: 30rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(22px, 42rpx, 28px);
font-weight: 700;
letter-spacing: 2rpx;
}
.state-panel--error .state-copy {
display: block;
min-height: 72rpx;
margin-top: 16rpx;
color: #786654;
font-size: clamp(16px, 27rpx, 20px);
line-height: 1.65;
}
.error-panel__divider {
display: block;
width: 100%;
height: 30rpx;
margin-top: 28rpx;
}
.state-panel--error .state-retry {
width: 560rpx;
min-height: 124rpx;
margin-top: 22rpx;
}
.genealogy-empty-state {
min-height: 1120rpx;
}
.empty-panel {
@include adaptive-genealogy-state-panel;
min-height: 1120rpx;
}
.empty-panel__content {
display: flex;
min-height: 1120rpx;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 50rpx 28rpx 46rpx;
text-align: center;
}
.empty-seal {
display: flex;
width: 120rpx;
height: 184rpx;
align-items: center;
justify-content: center;
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png")
center / contain no-repeat;
}
.empty-seal__text {
color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
letter-spacing: 2rpx;
writing-mode: vertical-rl;
}
.empty-title {
display: block;
margin-top: 30rpx;
color: #392719;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(24px, 48rpx, 30px);
font-weight: 700;
letter-spacing: 2rpx;
}
.empty-copy {
display: block;
min-height: 84rpx;
margin-top: 18rpx;
color: #786654;
font-size: clamp(16px, 28rpx, 20px);
line-height: 1.65;
}
.empty-divider {
display: block;
width: 100%;
height: 30rpx;
margin-top: 26rpx;
}
.empty-search-action {
display: flex;
width: 560rpx;
min-height: 124rpx;
align-items: center;
justify-content: center;
}
.empty-search-action {
margin-top: 26rpx;
background: url("/static/assets/foundation/transparent/scroll-primary.png")
center / contain no-repeat;
}
.empty-search-action__copy {
color: #7b4e24;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
letter-spacing: 3rpx;
}
.empty-search-action__copy--primary {
color: #fff9ec;
}
.empty-create-action {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
margin-top: 14rpx;
color: $brand-red;
}
.empty-create-action__copy {
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 31rpx, 22px);
}
.empty-create-note {
display: block;
color: $ink-muted;
font-size: clamp(16px, 26rpx, 20px);
line-height: max(1.35em, clamp(20px, 38rpx, 26px));
}
.state-retry {
display: flex;
width: 360rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
margin-top: 30rpx;
background: url("/static/assets/foundation/transparent/scroll-primary.png")
center / contain no-repeat;
}
.state-retry__copy {
color: #fff9ec;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 29rpx, 20px);
}
.action-hover {
opacity: 0.82;
}
@media screen and (max-width: 340px) {
.genealogy-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.create-action {
width: 320rpx;
}
.create-cloud {
width: 46rpx;
}
}
</style>