feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="申请审核" custom-back @back="returnToGenealogies" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card">
|
||||
<text>暂时无法打开审核页面</text>
|
||||
<text>未找到家谱信息,请返回后重新进入。</text>
|
||||
<AppButton block label="返回我的家谱" @click="returnToGenealogies" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取待审核申请" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'error'" class="state-card">
|
||||
<text>暂时无法读取待审核申请</text>
|
||||
<text>{{ applicationReviewError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadApplications" />
|
||||
</view>
|
||||
<view v-else-if="applicationReviewState === 'empty'" class="state-card">
|
||||
<text>暂无待审核申请</text>
|
||||
<text>新的加入申请会显示在这里。</text>
|
||||
<AppButton block label="返回家谱总览" @click="returnToOverview" />
|
||||
</view>
|
||||
<view v-else class="review-list">
|
||||
<text class="review-list__count">共 {{ applications.length }} 条待审核申请</text>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">{{ feedbackMessage }}</text>
|
||||
<view v-for="(item, index) in applications" :key="applicationKey(item, index)" class="review-card">
|
||||
<view class="review-card__heading">
|
||||
<text>{{ applicantName(item) }}</text>
|
||||
<text>{{ applicationTime(item) }}</text>
|
||||
</view>
|
||||
<text v-if="applicantPhone(item)" class="review-card__phone">{{ applicantPhone(item) }}</text>
|
||||
<text class="review-card__relation">{{ applicationRelation(item) }}</text>
|
||||
<text v-if="applicationReason(item)" class="review-card__reason">{{ applicationReason(item) }}</text>
|
||||
<view class="review-card__actions">
|
||||
<AppButton compact type="secondary" label="拒绝" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.REJECTED)" />
|
||||
<AppButton compact label="通过" :disabled="!applicationId(item) || operationPending" @click="openAudit(item, JOIN_APPLICATION_STATUS.APPROVED)" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(auditTarget)"
|
||||
eyebrow="申请审核"
|
||||
:title="auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '通过这条申请?' : '拒绝这条申请?'"
|
||||
:message="auditDialogMessage"
|
||||
:confirm-text="operationPending ? '正在提交…' : auditTarget?.decision === JOIN_APPLICATION_STATUS.APPROVED ? '确认通过' : '确认拒绝'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="submitAudit"
|
||||
@cancel="closeAudit"
|
||||
>
|
||||
<view v-if="auditTarget?.decision === JOIN_APPLICATION_STATUS.REJECTED" class="rejection-field">
|
||||
<text>拒绝原因</text>
|
||||
<textarea
|
||||
id="application-rejection-reason"
|
||||
v-model.trim="rejectionReason"
|
||||
auto-height
|
||||
maxlength="120"
|
||||
placeholder="请说明需要补充或核实的信息"
|
||||
:disabled="operationPending"
|
||||
:aria-invalid="!!rejectionError"
|
||||
aria-describedby="application-rejection-error"
|
||||
:focus="rejectionFocused"
|
||||
@input="clearRejectionError"
|
||||
/>
|
||||
<text v-if="rejectionError" id="application-rejection-error" class="rejection-field__error" role="alert">{{ rejectionError }}</text>
|
||||
</view>
|
||||
<text v-if="operationError" class="dialog-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
JOIN_APPLICATION_STATUS
|
||||
} from "@/services/api/genealogy-membership-contract.js";
|
||||
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 { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const applications = ref([]);
|
||||
const applicationReviewState = ref("loading");
|
||||
const applicationReviewError = ref("");
|
||||
const auditTarget = ref(null);
|
||||
const rejectionReason = ref("");
|
||||
const rejectionError = ref("");
|
||||
const rejectionFocused = ref(false);
|
||||
const operationError = ref("");
|
||||
const operationPending = ref(false);
|
||||
const feedbackMessage = ref("");
|
||||
const applicationListController = createRequestController();
|
||||
const applicationAuditController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const auditDialogMessage = computed(() => {
|
||||
const name = applicantName(auditTarget.value?.item);
|
||||
return auditTarget.value?.decision === JOIN_APPLICATION_STATUS.APPROVED
|
||||
? `通过后,“${name}”将加入家谱。`
|
||||
: "拒绝后,对方可在“我的申请”中查看处理结果。";
|
||||
});
|
||||
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const applicationId = (item) => {
|
||||
const applicationIdValue = item?.applyId ?? item?.applicationId ?? item?.id;
|
||||
const text = String(applicationIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationKey = (item, index) => applicationId(item) || `application-${index}`;
|
||||
const applicantName = (item) => String(item?.applicantName ?? item?.name ?? "申请人");
|
||||
const applicantPhone = (item) => String(item?.phone ?? item?.mobile ?? "");
|
||||
const applicationTime = (item) => String(item?.appliedAt ?? item?.applyTime ?? item?.createTime ?? item?.createdAt ?? "");
|
||||
const applicationRelation = (item) => String(item?.relationDesc ?? item?.relation ?? "未填写关系说明");
|
||||
const applicationReason = (item) => String(item?.applyReason ?? item?.reason ?? "");
|
||||
|
||||
const loadApplications = async () => {
|
||||
if (!valid.value) return;
|
||||
applicationListController.abort();
|
||||
applicationReviewState.value = "loading";
|
||||
applicationReviewError.value = "";
|
||||
feedbackMessage.value = "";
|
||||
try {
|
||||
const loadedApplications = await genealogyMembershipApi.getPendingApplications(genealogyId.value, {
|
||||
requestController: applicationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = loadedApplications;
|
||||
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
applicationReviewError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
applicationReviewState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
const returnToOverview = () => returnTo("G05", { genealogyId: genealogyId.value });
|
||||
const clearRejectionError = () => {
|
||||
rejectionError.value = "";
|
||||
operationError.value = "";
|
||||
};
|
||||
const openAudit = (item, decision) => {
|
||||
rejectionReason.value = "";
|
||||
rejectionError.value = "";
|
||||
rejectionFocused.value = false;
|
||||
operationError.value = "";
|
||||
auditTarget.value = { item, decision };
|
||||
};
|
||||
const resetAuditDialog = () => {
|
||||
auditTarget.value = null;
|
||||
rejectionReason.value = "";
|
||||
rejectionError.value = "";
|
||||
rejectionFocused.value = false;
|
||||
operationError.value = "";
|
||||
};
|
||||
const closeAudit = () => {
|
||||
if (operationPending.value) return;
|
||||
resetAuditDialog();
|
||||
};
|
||||
const submitAudit = async () => {
|
||||
const current = auditTarget.value;
|
||||
const id = applicationId(current?.item);
|
||||
if (!current || !id || operationPending.value) return;
|
||||
if (current.decision === JOIN_APPLICATION_STATUS.REJECTED && !rejectionReason.value.trim()) {
|
||||
rejectionError.value = "请填写拒绝原因,方便申请人补充资料。";
|
||||
rejectionFocused.value = false;
|
||||
await nextTick();
|
||||
rejectionFocused.value = true;
|
||||
return;
|
||||
}
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.auditApplication(
|
||||
genealogyId.value,
|
||||
id,
|
||||
{
|
||||
status: current.decision,
|
||||
...(current.decision === JOIN_APPLICATION_STATUS.REJECTED ? { auditRemark: rejectionReason.value.trim() } : {}),
|
||||
},
|
||||
{ requestController: applicationAuditController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
applications.value = applications.value.filter((item) => applicationId(item) !== id);
|
||||
applicationReviewState.value = applications.value.length ? "ready" : "empty";
|
||||
feedbackMessage.value = current.decision === JOIN_APPLICATION_STATUS.APPROVED ? "申请已通过。" : "申请已拒绝,并已附上审核说明。";
|
||||
resetAuditDialog();
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "提交审核失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(auditTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": closeAudit,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && applicationReviewState.value !== "loading" && !operationPending.value) loadApplications();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
applicationListController.abort();
|
||||
applicationAuditController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.review-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.state-card, .review-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.state-card text, .review-card__phone, .review-card__relation, .review-card__reason, .page-feedback { display: block; }
|
||||
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 35rpx, 24px); font-weight: 700; }
|
||||
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.review-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.review-list__count { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
|
||||
.review-card { padding: 28rpx 30rpx; }
|
||||
.review-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
|
||||
.review-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
|
||||
.review-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
|
||||
.review-card__phone, .review-card__relation, .review-card__reason { margin-top: 12rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.review-card__reason { color: #725840; }
|
||||
.review-card__actions { display: flex; justify-content: flex-end; margin-top: 22rpx; gap: 16rpx; }
|
||||
.review-card__actions .app-button { min-width: 140rpx; }
|
||||
.rejection-field { width: 100%; margin-top: 22rpx; text-align: left; }
|
||||
.rejection-field > text:first-child { display: block; color: $ink; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.rejection-field textarea { @include adaptive.adaptive-genealogy-form-field; box-sizing: border-box; display: block; width: 100%; min-height: 118rpx; margin-top: 10rpx; padding: 16rpx 20rpx; color: $ink; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; }
|
||||
.rejection-field__error, .dialog-error { display: block; width: 100%; margin-top: 10rpx; color: $brand-red; font-size: clamp(14px, 23rpx, 17px); line-height: 1.5; text-align: left; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-03;用途:创建家谱。首代人物需在服务端提供可恢复合同后另行录入。 -->
|
||||
<template>
|
||||
<view class="create-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -9,7 +8,7 @@
|
||||
<text class="create-card__eyebrow">立谱信息</text>
|
||||
<text class="create-card__title">为家族创建一部家谱</text>
|
||||
<text class="create-card__note"
|
||||
>创建成功后会回到我的家谱;首代人物可在后续合同开放后再录入。</text
|
||||
>创建成功后会回到“我的家谱”;可在世系树中录入首位成员。</text
|
||||
>
|
||||
|
||||
<view class="field-row">
|
||||
@@ -44,7 +43,12 @@
|
||||
fieldErrors.genealogyName
|
||||
}}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<view
|
||||
class="field-row field-row--selector"
|
||||
role="button"
|
||||
aria-label="选择所在地区"
|
||||
@click="openRegionPicker"
|
||||
>
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>所在地区</text
|
||||
>
|
||||
@@ -59,6 +63,12 @@
|
||||
: regionPickerError || "请选择所在地区"
|
||||
}}</text
|
||||
>
|
||||
<image
|
||||
class="region-selector-chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{
|
||||
fieldErrors.regionCode
|
||||
@@ -109,7 +119,7 @@
|
||||
<view>
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint"
|
||||
>选择图片后会取得真实上传回执,并在创建家谱时关联。</text
|
||||
>图片上传成功后,会作为家谱封面保存。</text
|
||||
>
|
||||
</view>
|
||||
<button
|
||||
@@ -127,7 +137,11 @@
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
class="access-rule__options"
|
||||
role="radiogroup"
|
||||
aria-label="家谱访问规则"
|
||||
>
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
@@ -136,6 +150,9 @@
|
||||
'access-rule__option--active':
|
||||
form.accessPreset === option.value,
|
||||
}"
|
||||
role="radio"
|
||||
:aria-checked="form.accessPreset === option.value"
|
||||
:aria-label="option.label"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
@@ -146,60 +163,20 @@
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:label="isSubmitting ? '正在创建…' : '确认创建家谱'"
|
||||
:label="isSubmitting ? '正在创建…' : createdGenealogyId ? '返回我的家谱' : '确认创建家谱'"
|
||||
@click="submitCreate"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" />
|
||||
<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 ['省份', '城市', '区县']"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>{{ label }}</text
|
||||
>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="regionPickerIndicatorStyle"
|
||||
:value="regionPickerIndexes"
|
||||
@change="handleRegionPickerChange"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in regionPickerColumns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
regionPickerIndexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker"
|
||||
>取消</view
|
||||
>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<RegionPickerDialog
|
||||
ref="regionPickerDialog"
|
||||
:max-levels="3"
|
||||
@select="selectRegion"
|
||||
@loading-change="regionLoading = $event"
|
||||
@error-change="regionPickerError = $event"
|
||||
@transient-change="regionPickerTransientOpen = $event"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="discardDialogVisible"
|
||||
@@ -221,28 +198,32 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
GENEALOGY_ACCESS_PRESET,
|
||||
GENEALOGY_ACCESS_PRESET_OPTIONS,
|
||||
toApiGenealogyAccess,
|
||||
} from "@/utils/genealogy-contracts.js";
|
||||
} from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import {
|
||||
finishPage,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const form = reactive({
|
||||
surname: "",
|
||||
@@ -260,22 +241,21 @@ const fieldErrors = reactive({
|
||||
});
|
||||
const submitError = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const createdGenealogyId = ref("");
|
||||
const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const createController = createRequestController();
|
||||
const regionController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const genealogyCreateRequestController = createRequestController();
|
||||
const genealogyCreateGuard = createNonIdempotentWriteGuard();
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerDialog = ref(null);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerTransientOpen = ref(false);
|
||||
const discardDialogVisible = ref(false);
|
||||
const regionPickerIndicatorStyle =
|
||||
"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);";
|
||||
let pageActive = true;
|
||||
const hasDraft = computed(
|
||||
() =>
|
||||
@@ -312,118 +292,36 @@ const validate = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, {
|
||||
requestController: regionController,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
regionPickerTrail.value = [];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces =
|
||||
regionPickerColumns.value[0] || (await fetchRegionChildren("0"));
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) {
|
||||
regionPickerOpen.value = true;
|
||||
}
|
||||
await regionPickerDialog.value?.open(selectedRegion.value?.regionCode || "");
|
||||
};
|
||||
|
||||
const closeRegionPicker = () => {
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const confirmRegionSelection = () => {
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const trail = [];
|
||||
regionPickerColumns.value.forEach((column, index) => {
|
||||
const option = column[Number(indexes[index])];
|
||||
if (option) trail.push(option);
|
||||
});
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
regionPickerIndexes.value = trail.map(
|
||||
(_, index) => Number(indexes[index]) || 0,
|
||||
);
|
||||
const selectRegion = ({ region, trail }) => {
|
||||
selectedRegion.value = region;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRegionRoot();
|
||||
void regionPickerDialog.value?.prepare();
|
||||
});
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: regionPickerOpen.value || discardDialogVisible.value,
|
||||
dirty: hasDraft.value,
|
||||
submitting: isSubmitting.value || isUploading.value,
|
||||
"close-transient": () =>
|
||||
regionPickerOpen.value ? closeRegionPicker() : cancelDiscard(),
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
createdGenealogyId.value
|
||||
? returnTo("G01")
|
||||
: runBackGuard({
|
||||
transientOpen:
|
||||
regionPickerTransientOpen.value || discardDialogVisible.value,
|
||||
dirty: hasDraft.value,
|
||||
submitting: isSubmitting.value || isUploading.value,
|
||||
"close-transient": () =>
|
||||
regionPickerTransientOpen.value
|
||||
? regionPickerDialog.value?.close()
|
||||
: cancelDiscard(),
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
@@ -433,7 +331,7 @@ const uploadCover = async () => {
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: createController,
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
@@ -444,7 +342,7 @@ const uploadCover = async () => {
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
@@ -452,43 +350,71 @@ const uploadCover = async () => {
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
return;
|
||||
if (isSubmitting.value || isUploading.value) return;
|
||||
if (!createdGenealogyId.value && !validate()) return;
|
||||
|
||||
let createPayload = null;
|
||||
let createAttempt = null;
|
||||
if (!createdGenealogyId.value) {
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "请选择家谱开放方式";
|
||||
return;
|
||||
}
|
||||
createPayload = {
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
};
|
||||
createAttempt = genealogyCreateGuard.begin(createPayload);
|
||||
if (createAttempt === null) {
|
||||
submitError.value =
|
||||
"上次创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
const created = await appApi.createGenealogy(
|
||||
{
|
||||
surname: form.surname,
|
||||
genealogyName: form.genealogyName,
|
||||
regionCode: selectedRegion.value.regionCode,
|
||||
ancestralHall: form.ancestralHall,
|
||||
originPlace: form.originPlace,
|
||||
addressDetail: form.addressDetail,
|
||||
intro: form.intro,
|
||||
coverOssId: coverOssId.value,
|
||||
...access,
|
||||
},
|
||||
{ requestController: createController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
if (!createdGenealogyId.value) {
|
||||
const created = await genealogyApi.createGenealogy(
|
||||
createPayload,
|
||||
{ requestController: genealogyCreateRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
createdGenealogyId.value = created.id;
|
||||
}
|
||||
await finishPage(
|
||||
"G01",
|
||||
{},
|
||||
{
|
||||
operation: "genealogy-created",
|
||||
entityId: created.id,
|
||||
entityId: createdGenealogyId.value,
|
||||
refresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "创建失败,请稍后重试";
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!createdGenealogyId.value &&
|
||||
createAttempt &&
|
||||
genealogyCreateGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
submitError.value =
|
||||
"创建结果暂时无法确认,请先返回“我的家谱”检查,避免重复创建。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
submitError.value = createdGenealogyId.value
|
||||
? "家谱已经创建,但页面返回失败。请再次点击“返回我的家谱”,不要重复创建。"
|
||||
: getRequestErrorMessage(error, "创建失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
@@ -496,8 +422,8 @@ const submitCreate = async () => {
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
createController.abort();
|
||||
regionController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
genealogyCreateRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -590,6 +516,13 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.region-selector-chevron {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 12rpx;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.region-selector-value--placeholder {
|
||||
color: #ab9a86;
|
||||
}
|
||||
@@ -644,7 +577,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 60rpx;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -681,6 +614,11 @@ onUnload(() => {
|
||||
}
|
||||
.access-rule__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 12rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
border-radius: 12rpx;
|
||||
@@ -697,108 +635,4 @@ onUnload(() => {
|
||||
.create-card .app-button {
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.region-sheet__title {
|
||||
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-muted;
|
||||
font-size: clamp(16px, 26rpx, 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 {
|
||||
flex: 1;
|
||||
height: 82rpx;
|
||||
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: 82rpx;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,232 +0,0 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view class="search-note"
|
||||
><text>公开家谱</text
|
||||
><text>以下结果来自服务端公开家谱列表。</text></view
|
||||
>
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取公开家谱"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取公开家谱</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂未找到公开家谱</text></view
|
||||
>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.surname">{{ item.surname }}氏</text></view
|
||||
>
|
||||
<text v-if="item.location" class="card-meta">{{
|
||||
item.location
|
||||
}}</text>
|
||||
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
|
||||
<view class="card-footer"
|
||||
><text>{{ item.memberCount }} 位成员</text
|
||||
><AppButton
|
||||
:label="item.canManage ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.canManage"
|
||||
@click="applyToJoin(item)"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadGenealogies = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPublicGenealogies({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
rows.value = result
|
||||
.map((item) => ({
|
||||
id: String(item.genealogyId),
|
||||
name: String(item.genealogyName || "未命名家谱"),
|
||||
surname: String(item.surname || ""),
|
||||
location: String(
|
||||
item.regionFullName ||
|
||||
item.regionName ||
|
||||
item.originPlace ||
|
||||
item.addressDetail ||
|
||||
"",
|
||||
),
|
||||
intro: String(item.intro || ""),
|
||||
memberCount: Number.isSafeInteger(item.memberCount)
|
||||
? item.memberCount
|
||||
: 0,
|
||||
canManage: item.canManage === true,
|
||||
}))
|
||||
.filter((item) => /^[1-9]\d*$/.test(item.id));
|
||||
state.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const applyToJoin = (item) =>
|
||||
openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
|
||||
const backToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadGenealogies);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadGenealogies();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.search-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.search-note,
|
||||
.state-card,
|
||||
.genealogy-card {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.search-note {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
.search-note text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 310rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.genealogy-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-heading text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.card-meta,
|
||||
.card-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-meta {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.card-copy {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.card-footer .app-button {
|
||||
min-width: 180rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.card-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-footer .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<!-- 页面编号:G-09;用途:读取当前账号的加入申请。 -->
|
||||
<template>
|
||||
<view class="applications-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="我的申请" custom-back @back="returnToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取我的申请"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取我的申请</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadApplications"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂无加入申请</text><text>申请记录会直接从服务端读取。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>已读取 {{ applications.length }} 条申请</text
|
||||
><text>申请详情接口尚未开放,当前不会用本地数据补造申请内容。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const loadApplications = async () => {
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getMyJoinApplications({
|
||||
requestController: controller,
|
||||
});
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad(loadApplications);
|
||||
onShow(() => {
|
||||
if (state.value !== "loading") loadApplications();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.applications-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +0,0 @@
|
||||
<!-- 页面编号:G-10;用途:读取当前家谱待审核的加入申请。 -->
|
||||
<template>
|
||||
<view class="review-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="申请审核" custom-back @back="returnToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!valid" class="state-card"
|
||||
><text>申请审核入口无效</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="state === 'loading'" class="state-card"
|
||||
><AppLoading text="正在读取待审核申请"
|
||||
/></view>
|
||||
<view v-else-if="state === 'error'" class="state-card"
|
||||
><text>暂时无法读取待审核申请</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadApplications"
|
||||
/></view>
|
||||
<view v-else-if="state === 'empty'" class="state-card"
|
||||
><text>暂无待审核申请</text><text>待审核记录会直接从服务端读取。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
<view v-else class="state-card"
|
||||
><text>已读取 {{ applications.length }} 条待审核申请</text
|
||||
><text
|
||||
>审核详情接口尚未开放,当前不会用本地数据补造申请内容或审核结果。</text
|
||||
><AppButton block label="返回我的家谱" @click="returnToGenealogies"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const applications = ref([]);
|
||||
const state = ref("loading");
|
||||
const controller = createRequestController();
|
||||
let active = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const loadApplications = async () => {
|
||||
if (!valid.value) return;
|
||||
controller.abort();
|
||||
state.value = "loading";
|
||||
try {
|
||||
applications.value = await appApi.getPendingApplications(
|
||||
genealogyId.value,
|
||||
{ requestController: controller },
|
||||
);
|
||||
if (!active) return;
|
||||
state.value = applications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!active || isRequestCancelled(error)) return;
|
||||
state.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (valid.value && state.value !== "loading") loadApplications();
|
||||
});
|
||||
onUnload(() => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.review-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.state-card text {
|
||||
display: block;
|
||||
}
|
||||
.state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-12;用途:按 Apifox GenerationPoemView 读取字辈,并以服务端预览维护。 -->
|
||||
<template>
|
||||
<view class="poem-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -45,29 +44,37 @@
|
||||
<view
|
||||
v-if="remainingPoemCount > 0"
|
||||
class="poem-load-more"
|
||||
role="button"
|
||||
:aria-label="'继续加载后续字辈,剩余 ' + remainingPoemCount + ' 代'"
|
||||
@click="loadMorePoems"
|
||||
>
|
||||
<text>继续加载后续字辈(剩余 {{ remainingPoemCount }} 代)</text>
|
||||
</view>
|
||||
<view class="poem-action" @click="enterManagement">
|
||||
<view
|
||||
class="poem-action"
|
||||
role="button"
|
||||
:aria-label="canManage ? '继续维护字辈诗' : '查看字辈列表'"
|
||||
@click="enterManagement"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ canManage ? "继续维护字辈诗" : "读取维护列表" }}</text>
|
||||
/><text>{{ canManage ? "继续维护字辈诗" : "查看字辈列表" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="poemState === 'edit'" class="poem-editor">
|
||||
<text class="poem-list__eyebrow">服务端批量维护</text>
|
||||
<text class="poem-list__title">录入完整字辈序列</text>
|
||||
<text class="poem-list__eyebrow">统一维护</text>
|
||||
<text class="poem-list__title">填写完整字辈</text>
|
||||
<text class="poem-list__copy"
|
||||
>提交前会先请求服务端批量预览。无分隔符时每个字符对应一代;也可用空格、逗号、分号、顿号、斜杠或竖线分隔多字字辈。</text
|
||||
>保存前可先查看调整结果。没有分隔符时,每个字对应一代;多个字可用空格、逗号、分号、顿号、斜杠或竖线分开。</text
|
||||
>
|
||||
<view class="poem-field">
|
||||
<text>字辈内容</text>
|
||||
<textarea
|
||||
v-model="poemDraft"
|
||||
auto-height
|
||||
:disabled="saving"
|
||||
:maxlength="MAX_GENERATION_POEM_INPUT_LENGTH"
|
||||
placeholder="例如:启宗敦本,或 克勤 克俭 承先 启后"
|
||||
placeholder-class="poem-placeholder"
|
||||
@@ -76,13 +83,21 @@
|
||||
</view>
|
||||
<text v-if="poemError" class="poem-field-error">{{ poemError }}</text>
|
||||
<view class="poem-policy">
|
||||
<text>未被新文本覆盖的后续世代</text>
|
||||
<view class="poem-policy__option" @click="toggleDisableMissing">
|
||||
<text>这次未填写到的后续字辈</text>
|
||||
<view
|
||||
class="poem-policy__option"
|
||||
:class="{ 'poem-policy__option--disabled': saving }"
|
||||
role="checkbox"
|
||||
:aria-checked="disableMissing"
|
||||
:aria-disabled="saving"
|
||||
aria-label="停用未填写到的后续字辈"
|
||||
@click="toggleDisableMissing"
|
||||
>
|
||||
<image
|
||||
:src="
|
||||
disableMissing
|
||||
? '/static/assets/foundation/transparent/a01-scroll-primary-v3.png'
|
||||
: '/static/assets/foundation/transparent/a01-scroll-secondary-v3.png'
|
||||
? '/static/assets/foundation/transparent/scroll-primary.png'
|
||||
: '/static/assets/foundation/transparent/scroll-secondary.png'
|
||||
"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
@@ -94,31 +109,44 @@
|
||||
</view>
|
||||
<text class="poem-preview">{{ previewSummary }}</text>
|
||||
<view class="poem-editor__actions">
|
||||
<view class="poem-action" @click="requestLeaveEditor">
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': previewing || saving }"
|
||||
role="button"
|
||||
:aria-disabled="previewing || saving"
|
||||
aria-label="取消"
|
||||
@click="requestLeaveEditor"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
mode="aspectFit"
|
||||
/><text class="poem-action__secondary">取消</text>
|
||||
</view>
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': previewing || saving }"
|
||||
role="button"
|
||||
:aria-disabled="previewing || saving"
|
||||
aria-label="查看调整结果"
|
||||
@click="previewPoems"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-secondary.png"
|
||||
mode="aspectFit"
|
||||
/><text class="poem-action__secondary">{{
|
||||
previewing ? "正在预览" : "服务端预览"
|
||||
previewing ? "正在查看" : "查看调整结果"
|
||||
}}</text>
|
||||
</view>
|
||||
<view
|
||||
class="poem-action"
|
||||
:class="{ 'poem-action--disabled': !canSave || saving }"
|
||||
role="button"
|
||||
:aria-disabled="!canSave || saving"
|
||||
aria-label="保存字辈"
|
||||
@click="savePoems"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{ saving ? "正在保存" : "保存字辈" }}</text>
|
||||
</view>
|
||||
@@ -131,16 +159,21 @@
|
||||
}}</text>
|
||||
<text class="poem-list__title">{{
|
||||
poemState === "empty"
|
||||
? "当前读取没有正常状态字辈"
|
||||
? "暂时没有可显示的字辈"
|
||||
: "暂时无法读取字辈诗"
|
||||
}}</text>
|
||||
<text class="poem-list__copy">{{ stateCopy }}</text>
|
||||
<view class="poem-action" @click="handleStateAction">
|
||||
<view
|
||||
class="poem-action"
|
||||
role="button"
|
||||
:aria-label="poemState === 'empty' ? '查看字辈列表' : '重新读取'"
|
||||
@click="handleStateAction"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/><text>{{
|
||||
poemState === "empty" ? "读取维护列表" : "重新读取"
|
||||
poemState === "empty" ? "查看字辈列表" : "重新读取"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -148,7 +181,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃字辈修改?"
|
||||
message="当前草稿尚未保存,确认后不会提交服务端。"
|
||||
message="当前修改还没有保存,确认返回后将不会保留。"
|
||||
confirm-text="放弃修改"
|
||||
cancel-text="继续编辑"
|
||||
show-cancel
|
||||
@@ -168,20 +201,21 @@ import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { generationPoemApi } from "@/services/api/generation-poem-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
MAX_GENERATION_POEM_INPUT_LENGTH,
|
||||
GENERATION_POEM_STATUS,
|
||||
validateGenerationPoemText,
|
||||
} from "@/utils/generation-poem.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
} from "@/utils/genealogy/generation-poem.js";
|
||||
import { handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const POEM_RENDER_BATCH_SIZE = 50;
|
||||
const genealogyId = ref("");
|
||||
@@ -200,8 +234,11 @@ const poemRows = ref([]);
|
||||
const visiblePoemCount = ref(POEM_RENDER_BATCH_SIZE);
|
||||
const preview = ref(null);
|
||||
const previewSignature = ref("");
|
||||
const requestController = createRequestController();
|
||||
const poemListController = createRequestController();
|
||||
const poemPreviewController = createRequestController();
|
||||
const poemSaveController = createRequestController();
|
||||
let requestSequence = 0;
|
||||
let pageActive = true;
|
||||
|
||||
const visiblePoemRows = computed(() =>
|
||||
poemRows.value.slice(0, visiblePoemCount.value),
|
||||
@@ -226,9 +263,9 @@ const canSave = computed(
|
||||
Boolean(preview.value) && previewSignature.value === draftSignature.value,
|
||||
);
|
||||
const previewSummary = computed(() => {
|
||||
if (previewing.value) return "正在请求服务端预览。";
|
||||
if (!preview.value) return "尚未请求服务端预览;预览成功后才可保存。";
|
||||
return `服务端预览:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
||||
if (previewing.value) return "正在查看调整结果。";
|
||||
if (!preview.value) return "请先查看调整结果,再保存。";
|
||||
return `调整结果:新增 ${preview.value.createCount} 条,更新 ${preview.value.updateCount} 条,保留 ${preview.value.keepCount} 条,停用 ${preview.value.disableCount} 条。`;
|
||||
});
|
||||
const stateCopy = computed(() =>
|
||||
poemState.value === "empty"
|
||||
@@ -266,19 +303,19 @@ const loadPoems = async ({ management = false } = {}) => {
|
||||
poemError.value = "";
|
||||
try {
|
||||
const rows = management
|
||||
? await appApi.getGenerationPoemManagement(genealogyId.value, {
|
||||
requestController,
|
||||
? await generationPoemApi.getGenerationPoemManagement(genealogyId.value, {
|
||||
requestController: poemListController,
|
||||
})
|
||||
: await appApi.getGenerationPoems(genealogyId.value, {
|
||||
requestController,
|
||||
: await generationPoemApi.getGenerationPoems(genealogyId.value, {
|
||||
requestController: poemListController,
|
||||
});
|
||||
if (sequence !== requestSequence) return false;
|
||||
if (!pageActive || sequence !== requestSequence) return false;
|
||||
applyRows(rows);
|
||||
canManage.value = management;
|
||||
poemState.value = rows.length ? "list" : "empty";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || sequence !== requestSequence) return false;
|
||||
if (!pageActive || isRequestCancelled(error) || sequence !== requestSequence) return false;
|
||||
poemState.value = "error";
|
||||
return false;
|
||||
}
|
||||
@@ -295,6 +332,7 @@ const invalidatePreview = () => {
|
||||
previewSignature.value = "";
|
||||
};
|
||||
const toggleDisableMissing = () => {
|
||||
if (saving.value) return;
|
||||
disableMissing.value = !disableMissing.value;
|
||||
invalidatePreview();
|
||||
};
|
||||
@@ -317,9 +355,10 @@ const openEditor = () => {
|
||||
const enterManagement = async () => {
|
||||
if (previewing.value || saving.value) return;
|
||||
const loaded = await loadPoems({ management: true });
|
||||
if (loaded) openEditor();
|
||||
if (pageActive && loaded) openEditor();
|
||||
};
|
||||
const requestLeaveEditor = async () => {
|
||||
if (previewing.value || saving.value) return false;
|
||||
if (isDirty.value) {
|
||||
const confirmed = await requestDiscardConfirmation();
|
||||
if (!confirmed) return false;
|
||||
@@ -339,23 +378,27 @@ const validateDraft = () => {
|
||||
};
|
||||
const previewPoems = async () => {
|
||||
if (previewing.value || saving.value || !validateDraft()) return;
|
||||
const submittedDraft = {
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
};
|
||||
const submittedSignature = draftSignature.value;
|
||||
previewing.value = true;
|
||||
poemError.value = "";
|
||||
try {
|
||||
preview.value = await appApi.previewGenerationPoemBatch(
|
||||
const previewResult = await generationPoemApi.previewGenerationPoemBatch(
|
||||
genealogyId.value,
|
||||
{
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
},
|
||||
{ requestController },
|
||||
submittedDraft,
|
||||
{ requestController: poemPreviewController },
|
||||
);
|
||||
previewSignature.value = draftSignature.value;
|
||||
if (!pageActive || submittedSignature !== draftSignature.value) return;
|
||||
preview.value = previewResult;
|
||||
previewSignature.value = submittedSignature;
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
poemError.value = error?.message || "服务端预览失败,请稍后重试。";
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
poemError.value = getRequestErrorMessage(error, "暂时无法查看调整结果,请稍后重试。");
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
if (pageActive) previewing.value = false;
|
||||
}
|
||||
};
|
||||
const savePoems = async () => {
|
||||
@@ -363,27 +406,29 @@ const savePoems = async () => {
|
||||
saving.value = true;
|
||||
poemError.value = "";
|
||||
try {
|
||||
await appApi.saveGenerationPoemBatch(
|
||||
await generationPoemApi.saveGenerationPoemBatch(
|
||||
genealogyId.value,
|
||||
{
|
||||
poemText: poemDraft.value,
|
||||
disableMissing: disableMissing.value,
|
||||
},
|
||||
{ requestController },
|
||||
{ requestController: poemSaveController },
|
||||
);
|
||||
feedbackMessage.value = "服务端字辈已保存,正在刷新维护列表。";
|
||||
if (!pageActive) return;
|
||||
feedbackMessage.value = "字辈已保存,正在刷新列表。";
|
||||
const loaded = await loadPoems({ management: true });
|
||||
if (!pageActive) return;
|
||||
if (!loaded) {
|
||||
poemError.value = "字辈已提交,但维护列表刷新失败;请稍后重新查看。";
|
||||
poemError.value = "字辈已保存,但页面暂时未更新,请稍后重新查看。";
|
||||
return;
|
||||
}
|
||||
editorSnapshot.value = null;
|
||||
feedbackMessage.value = "服务端字辈已保存并已刷新。";
|
||||
feedbackMessage.value = "字辈已保存。";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
poemError.value = error?.message || "字辈保存失败,请稍后重试。";
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
poemError.value = getRequestErrorMessage(error, "字辈保存失败,请稍后重试。");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
if (pageActive) saving.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
@@ -404,8 +449,11 @@ onLoad((query) => {
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
++requestSequence;
|
||||
requestController.abort();
|
||||
poemListController.abort();
|
||||
poemPreviewController.abort();
|
||||
poemSaveController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -463,11 +511,11 @@ onUnload(() => {
|
||||
}
|
||||
.poem-load-more {
|
||||
display: flex;
|
||||
min-height: 64rpx;
|
||||
min-height: 88rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 14rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.poem-load-more text {
|
||||
@@ -518,7 +566,7 @@ onUnload(() => {
|
||||
.poem-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
min-height: 88rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.poem-action--disabled {
|
||||
@@ -595,7 +643,10 @@ onUnload(() => {
|
||||
.poem-policy__option {
|
||||
display: grid;
|
||||
width: 248rpx;
|
||||
min-height: 62rpx;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
.poem-policy__option--disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.poem-policy__option image {
|
||||
grid-area: 1 / 1;
|
||||
@@ -662,7 +713,7 @@ onUnload(() => {
|
||||
justify-content: center;
|
||||
padding: 0 34rpx;
|
||||
transform: translateX(-50%);
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-secondary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.poem-feedback text {
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-08;用途:按 APP GenealogyJoinApplyBody 提交加入申请。 -->
|
||||
<template>
|
||||
<view class="join-page">
|
||||
<GenealogyPageBackground />
|
||||
@@ -7,14 +6,14 @@
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view v-if="!hasValidContext" class="state-card">
|
||||
<text>申请入口无效</text>
|
||||
<text>没有取得可申请的家谱标识。</text>
|
||||
<text>暂时无法提交申请</text>
|
||||
<text>未找到可申请的家谱,请返回后重新进入。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
</view>
|
||||
<view v-else-if="state === 'success'" class="state-card">
|
||||
<view v-else-if="joinApplicationState === 'success'" class="state-card">
|
||||
<text>申请已提交</text>
|
||||
<text>服务端已确认本次加入申请,可返回继续查看公开家谱。</text>
|
||||
<AppButton block label="返回搜索家谱" @click="backToSearch" />
|
||||
<text>申请已提交,可在“我的申请”中查看审核进度。</text>
|
||||
<AppButton block label="查看我的申请" @click="openMyApplications" />
|
||||
</view>
|
||||
<view v-else class="join-form">
|
||||
<view class="form-heading">
|
||||
@@ -29,7 +28,7 @@
|
||||
auto-height
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<input
|
||||
@@ -38,16 +37,19 @@
|
||||
:type="field.inputType || 'text'"
|
||||
:maxlength="field.maxlength"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="state === 'submitting'"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<text v-if="field.key === 'relationDesc'" class="form-field__hint"
|
||||
>请填写您与家谱成员的关系,例如:我是某某某的堂侄。</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="error" class="form-error">{{ error }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="state === 'submitting'"
|
||||
:label="state === 'submitting' ? '正在提交' : '提交申请'"
|
||||
@click="submit"
|
||||
:disabled="joinApplicationState === 'submitting'"
|
||||
:label="joinApplicationState === 'submitting' ? '正在提交' : '提交申请'"
|
||||
@click="submitJoinApplication"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -67,23 +69,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const genealogyName = ref("");
|
||||
const state = ref("form");
|
||||
const joinApplicationState = ref("form");
|
||||
const error = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const form = reactive({
|
||||
@@ -120,7 +124,9 @@ const fields = [
|
||||
placeholder: "说明申请加入的原因",
|
||||
},
|
||||
];
|
||||
const controller = createRequestController();
|
||||
const joinApplicationSubmitController = createRequestController();
|
||||
const joinApplicationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const dirty = computed(() => Object.values(form).some((value) => value.trim()));
|
||||
const confirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -135,37 +141,52 @@ onLoad((query) => {
|
||||
});
|
||||
|
||||
const backToSearch = () => returnTo("G06");
|
||||
const submit = async () => {
|
||||
if (state.value === "submitting" || !hasValidContext.value) return;
|
||||
state.value = "submitting";
|
||||
const openMyApplications = () => openPage("G09", {}, "G08");
|
||||
const submitJoinApplication = async () => {
|
||||
if (joinApplicationState.value === "submitting" || !hasValidContext.value) return;
|
||||
const payload = { ...form };
|
||||
const submitAttempt = joinApplicationGuard.begin(payload);
|
||||
if (submitAttempt === null) {
|
||||
error.value =
|
||||
"上次提交结果暂时无法确认,请先查看“我的申请”,避免重复提交。";
|
||||
return;
|
||||
}
|
||||
joinApplicationState.value = "submitting";
|
||||
error.value = "";
|
||||
try {
|
||||
await appApi.applyToJoin(
|
||||
await genealogyMembershipApi.applyToJoin(
|
||||
genealogyId.value,
|
||||
{ ...form },
|
||||
{ requestController: controller },
|
||||
payload,
|
||||
{ requestController: joinApplicationSubmitController },
|
||||
);
|
||||
state.value = "success";
|
||||
if (!pageActive) return;
|
||||
joinApplicationState.value = "success";
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) {
|
||||
state.value = "form";
|
||||
error.value = cause?.message || "申请提交失败,请稍后重试。";
|
||||
if (!pageActive) return;
|
||||
joinApplicationState.value = "form";
|
||||
if (joinApplicationGuard.recordFailure(submitAttempt, cause)) {
|
||||
error.value =
|
||||
"申请结果暂时无法确认,请先查看“我的申请”,避免重复提交。";
|
||||
return;
|
||||
}
|
||||
if (!isRequestCancelled(cause))
|
||||
error.value = getRequestErrorMessage(cause, "申请提交失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: dirty.value && state.value === "form",
|
||||
submitting: state.value === "submitting",
|
||||
dirty: dirty.value && joinApplicationState.value === "form",
|
||||
submitting: joinApplicationState.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": confirmation.request,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
controller.abort();
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
joinApplicationSubmitController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -248,6 +269,13 @@ onUnmounted(() => {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.form-field > text.form-field__hint {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-field input {
|
||||
min-height: 76rpx;
|
||||
padding-top: 0;
|
||||
@@ -0,0 +1,728 @@
|
||||
<template>
|
||||
<view class="member-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家谱成员" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view v-if="!hasValidGenealogyId" class="state-card">
|
||||
<text>暂时无法打开成员页面</text>
|
||||
<text>没有找到家谱信息,请返回家谱总览后重新进入。</text>
|
||||
<AppButton block label="返回家谱总览" @click="returnToOverview" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取家谱成员" description="请稍候,正在同步成员和管理权限。" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'error'" class="state-card">
|
||||
<text>暂时无法读取成员</text>
|
||||
<text>{{ memberListError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadMembers" />
|
||||
</view>
|
||||
<view v-else-if="memberListState === 'empty'" class="state-card">
|
||||
<text>{{ hasLeftGenealogy ? "已退出这部家谱" : "还没有成员记录" }}</text>
|
||||
<text>{{ hasLeftGenealogy ? "你的退出操作已经完成。" : "家人加入后,会显示在这里。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:label="hasLeftGenealogy ? '返回我的家谱' : '返回家谱总览'"
|
||||
@click="returnToOverview"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="member-list">
|
||||
<view class="member-intro">
|
||||
<text>共 {{ members.length }} 位成员</text>
|
||||
<text>这里管理的是已加入家谱的账号成员;家谱中的人物资料请到“世系树”维护。</text>
|
||||
</view>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">
|
||||
{{ feedbackMessage }}
|
||||
</text>
|
||||
|
||||
<view
|
||||
v-for="member in members"
|
||||
:key="member.memberId"
|
||||
class="member-card"
|
||||
>
|
||||
<view class="member-card__heading">
|
||||
<view class="member-card__identity">
|
||||
<text>{{ member.memberName }}</text>
|
||||
<text
|
||||
v-if="member.appUserNickName && member.appUserNickName !== member.memberName"
|
||||
>
|
||||
账号昵称:{{ member.appUserNickName }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="role-badge">{{ roleLabel(member.roleType) }}</text>
|
||||
</view>
|
||||
<view class="member-card__details">
|
||||
<text>与家谱关系:{{ member.relationName || "未填写" }}</text>
|
||||
<text>世系人物:{{ lineageLabel(member) }}</text>
|
||||
<text v-if="member.joinTime">
|
||||
加入时间:{{ formatMinuteTimestamp(member.joinTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="hasMemberActions(member)" class="member-card__actions">
|
||||
<AppButton
|
||||
v-if="member.capabilities.canEdit"
|
||||
compact
|
||||
type="secondary"
|
||||
label="编辑资料"
|
||||
@click="openMemberEditor(member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canEdit && member.lineagePersonId"
|
||||
compact
|
||||
type="secondary"
|
||||
label="解除人物绑定"
|
||||
@click="openConfirmation('unlink', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canTransferOwner"
|
||||
compact
|
||||
type="secondary"
|
||||
label="转让谱主"
|
||||
@click="openConfirmation('transfer', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canRemove"
|
||||
compact
|
||||
type="secondary"
|
||||
label="移出家谱"
|
||||
@click="openConfirmation('remove', member)"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="member.capabilities.canLeave"
|
||||
compact
|
||||
type="secondary"
|
||||
label="退出家谱"
|
||||
@click="openConfirmation('leave', member)"
|
||||
/>
|
||||
</view>
|
||||
<text v-else class="member-card__readonly">当前账号只能查看这位成员。</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(editTarget)"
|
||||
eyebrow="成员资料"
|
||||
:title="`编辑${editTarget?.memberName || '成员'}`"
|
||||
message="称呼和关系需要填写;角色、世系人物请从已有选项中选择。"
|
||||
:confirm-text="operationPending ? '正在保存…' : '保存修改'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="saveEdit"
|
||||
@cancel="closeEdit"
|
||||
>
|
||||
<view class="edit-form">
|
||||
<label class="edit-field">
|
||||
<text>成员称呼</text>
|
||||
<input v-model.trim="editForm.memberName" maxlength="50" placeholder="例如:王叔叔" />
|
||||
</label>
|
||||
<label class="edit-field">
|
||||
<text>与家谱关系</text>
|
||||
<input v-model.trim="editForm.relationName" maxlength="100" placeholder="例如:本族成员" />
|
||||
</label>
|
||||
<picker
|
||||
:disabled="editTarget?.roleType === GENEALOGY_MEMBER_ROLE.OWNER"
|
||||
:range="roleOptions.map((roleOption) => roleOption.label)"
|
||||
:value="roleIndex"
|
||||
@change="selectRole"
|
||||
>
|
||||
<view class="edit-field edit-field--picker">
|
||||
<text>成员角色</text><text>{{ roleLabel(editForm.roleType) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker :disabled="personOptionsState === 'loading'" :range="personOptionLabels" :value="personOptionIndex" @change="selectPerson">
|
||||
<view class="edit-field edit-field--picker">
|
||||
<text>绑定世系人物</text>
|
||||
<text>{{ selectedPersonLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="personOptionsState === 'loading'" class="edit-hint">正在读取世系人物…</text>
|
||||
<text v-else-if="personOptionsState === 'error'" class="edit-error">人物选项暂时无法读取,本次可先修改其他资料。</text>
|
||||
<text v-if="editError" class="edit-error" role="alert">{{ editError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="Boolean(confirmTarget)"
|
||||
eyebrow="请确认"
|
||||
:title="confirmationCopy.title"
|
||||
:message="confirmationCopy.message"
|
||||
:confirm-text="operationPending ? '正在处理…' : confirmationCopy.confirm"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="runConfirmedOperation"
|
||||
@cancel="closeConfirmation"
|
||||
>
|
||||
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
GENEALOGY_MEMBER_ROLE,
|
||||
GENEALOGY_MEMBER_ROLE_LABELS
|
||||
} from "@/services/api/genealogy-member-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { formatMinuteTimestamp } from "@/utils/display-time.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const members = ref([]);
|
||||
const memberListState = ref("loading");
|
||||
const memberListError = ref("");
|
||||
const feedbackMessage = ref("");
|
||||
const editTarget = ref(null);
|
||||
const confirmTarget = ref(null);
|
||||
const editError = ref("");
|
||||
const operationError = ref("");
|
||||
const operationPending = ref(false);
|
||||
const hasLeftGenealogy = ref(false);
|
||||
const personOptions = ref([]);
|
||||
const personOptionsState = ref("idle");
|
||||
const memberListController = createRequestController();
|
||||
const personOptionsController = createRequestController();
|
||||
const memberUpdateController = createRequestController();
|
||||
const membershipOperationController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const editForm = reactive({
|
||||
memberName: "",
|
||||
relationName: "",
|
||||
roleType: GENEALOGY_MEMBER_ROLE.MEMBER,
|
||||
lineagePersonId: "",
|
||||
});
|
||||
const hasValidGenealogyId = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isOwnerViewer = computed(() =>
|
||||
members.value.some((member) => member.capabilities.canTransferOwner),
|
||||
);
|
||||
const roleOptions = computed(() => {
|
||||
const options = [
|
||||
...(isOwnerViewer.value
|
||||
? [{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.ADMIN], value: GENEALOGY_MEMBER_ROLE.ADMIN }]
|
||||
: []),
|
||||
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.EDITOR], value: GENEALOGY_MEMBER_ROLE.EDITOR },
|
||||
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.MEMBER], value: GENEALOGY_MEMBER_ROLE.MEMBER },
|
||||
];
|
||||
const currentRole = editTarget.value?.roleType;
|
||||
if (currentRole && !options.some((option) => option.value === currentRole)) {
|
||||
options.unshift({
|
||||
label: GENEALOGY_MEMBER_ROLE_LABELS[currentRole] || "当前角色",
|
||||
value: currentRole,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const roleIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
roleOptions.value.findIndex(
|
||||
(roleOption) => roleOption.value === editForm.roleType,
|
||||
),
|
||||
),
|
||||
);
|
||||
const personOptionLabels = computed(() => [
|
||||
"保持当前绑定",
|
||||
...personOptions.value.map(
|
||||
(person) =>
|
||||
`${person.name}${person.generation ? ` · 第${person.generation}世` : ""}`,
|
||||
),
|
||||
]);
|
||||
const personOptionIndex = computed(() => {
|
||||
if (!editForm.lineagePersonId || editForm.lineagePersonId === editTarget.value?.lineagePersonId) return 0;
|
||||
const index = personOptions.value.findIndex(
|
||||
(person) => person.id === editForm.lineagePersonId,
|
||||
);
|
||||
return index < 0 ? 0 : index + 1;
|
||||
});
|
||||
const selectedPersonLabel = computed(
|
||||
() => personOptionLabels.value[personOptionIndex.value] || "保持当前绑定",
|
||||
);
|
||||
const confirmationCopy = computed(() => {
|
||||
const memberName = confirmTarget.value?.member?.memberName || "这位成员";
|
||||
const copyByOperation = {
|
||||
unlink: {
|
||||
title: "解除人物绑定?",
|
||||
message: `解除后,“${memberName}”的账号仍在家谱中,但不再对应世系树人物。`,
|
||||
confirm: "确认解除",
|
||||
},
|
||||
remove: {
|
||||
title: "将成员移出家谱?",
|
||||
message: `移出后,“${memberName}”将不能再以成员身份访问这部家谱。`,
|
||||
confirm: "确认移出",
|
||||
},
|
||||
leave: {
|
||||
title: "退出这部家谱?",
|
||||
message: "退出后,你将不能再查看仅对成员开放的内容。",
|
||||
confirm: "确认退出",
|
||||
},
|
||||
transfer: {
|
||||
title: "转让谱主身份?",
|
||||
message: `转让后,“${memberName}”将成为新谱主,你会变为管理员。`,
|
||||
confirm: "确认转让",
|
||||
},
|
||||
};
|
||||
return copyByOperation[confirmTarget.value?.kind] || {
|
||||
title: "确认操作?",
|
||||
message: "请确认是否继续。",
|
||||
confirm: "确认",
|
||||
};
|
||||
});
|
||||
|
||||
const roleLabel = (role) => GENEALOGY_MEMBER_ROLE_LABELS[role] || "成员";
|
||||
const lineageLabel = (member) =>
|
||||
member.lineagePersonName || (member.lineagePersonId ? "已绑定" : "未绑定");
|
||||
const hasMemberActions = (member) =>
|
||||
Object.values(member.capabilities).some(Boolean);
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!hasValidGenealogyId.value) return;
|
||||
memberListController.abort();
|
||||
memberListState.value = "loading";
|
||||
memberListError.value = "";
|
||||
try {
|
||||
const loadedMembers = await genealogyMemberApi.getMembers(genealogyId.value, {
|
||||
requestController: memberListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
members.value = loadedMembers;
|
||||
memberListState.value = members.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
memberListError.value = getRequestErrorMessage(error, "成员加载失败,请稍后重试。");
|
||||
memberListState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
if (personOptionsState.value === "loading" || personOptionsState.value === "ready") return;
|
||||
personOptionsState.value = "loading";
|
||||
try {
|
||||
const loadedPersonOptions = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
personOptions.value = loadedPersonOptions;
|
||||
personOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
personOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
const openMemberEditor = (member) => {
|
||||
if (!member.capabilities.canEdit || operationPending.value) return;
|
||||
editTarget.value = member;
|
||||
editForm.memberName = member.memberName;
|
||||
editForm.relationName = member.relationName;
|
||||
editForm.roleType = member.roleType;
|
||||
editForm.lineagePersonId = member.lineagePersonId || "";
|
||||
editError.value = "";
|
||||
void loadPersonOptions();
|
||||
};
|
||||
const closeEdit = () => {
|
||||
if (operationPending.value) return;
|
||||
editTarget.value = null;
|
||||
editError.value = "";
|
||||
};
|
||||
const selectRole = (event) => {
|
||||
editForm.roleType = roleOptions.value[Number(event.detail.value)]?.value || editForm.roleType;
|
||||
};
|
||||
const selectPerson = (event) => {
|
||||
const index = Number(event.detail.value);
|
||||
editForm.lineagePersonId = index > 0 ? personOptions.value[index - 1]?.id || "" : editTarget.value?.lineagePersonId || "";
|
||||
};
|
||||
const saveEdit = async () => {
|
||||
const target = editTarget.value;
|
||||
if (!target || operationPending.value) return;
|
||||
if (!editForm.memberName.trim()) {
|
||||
editError.value = "请填写成员称呼。";
|
||||
return;
|
||||
}
|
||||
operationPending.value = true;
|
||||
editError.value = "";
|
||||
try {
|
||||
const updated = await genealogyMemberApi.updateMember(
|
||||
genealogyId.value,
|
||||
target.memberId,
|
||||
{
|
||||
memberName: editForm.memberName,
|
||||
relationName: editForm.relationName,
|
||||
...(editForm.roleType !== target.roleType
|
||||
? { roleType: editForm.roleType }
|
||||
: {}),
|
||||
...(editForm.lineagePersonId &&
|
||||
editForm.lineagePersonId !== target.lineagePersonId
|
||||
? { lineagePersonId: editForm.lineagePersonId }
|
||||
: {}),
|
||||
},
|
||||
{ requestController: memberUpdateController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
members.value = members.value.map((member) =>
|
||||
member.memberId === updated.memberId ? updated : member,
|
||||
);
|
||||
feedbackMessage.value = `“${updated.memberName}”的成员资料已更新。`;
|
||||
editTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
editError.value = getRequestErrorMessage(error, "保存失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const openConfirmation = (kind, member) => {
|
||||
if (operationPending.value) return;
|
||||
confirmTarget.value = { kind, member };
|
||||
operationError.value = "";
|
||||
};
|
||||
const closeConfirmation = () => {
|
||||
if (operationPending.value) return;
|
||||
confirmTarget.value = null;
|
||||
operationError.value = "";
|
||||
};
|
||||
const runConfirmedOperation = async () => {
|
||||
const target = confirmTarget.value;
|
||||
if (!target || operationPending.value) return;
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
let operationCommitted = false;
|
||||
try {
|
||||
const requestOptions = {
|
||||
requestController: membershipOperationController,
|
||||
};
|
||||
switch (target.kind) {
|
||||
case "unlink":
|
||||
await genealogyMemberApi.unlinkMemberLineagePerson(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
case "remove":
|
||||
await genealogyMemberApi.removeMember(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
case "leave":
|
||||
await genealogyMemberApi.leaveGenealogy(genealogyId.value, requestOptions);
|
||||
break;
|
||||
case "transfer":
|
||||
await genealogyMemberApi.transferGenealogyOwner(
|
||||
genealogyId.value,
|
||||
target.member.memberId,
|
||||
requestOptions,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`不支持的成员操作:${target.kind}`);
|
||||
}
|
||||
operationCommitted = true;
|
||||
if (!isPageActive) return;
|
||||
confirmTarget.value = null;
|
||||
if (target.kind === "leave") {
|
||||
hasLeftGenealogy.value = true;
|
||||
members.value = [];
|
||||
memberListState.value = "empty";
|
||||
await returnTo("G01");
|
||||
return;
|
||||
}
|
||||
feedbackMessage.value =
|
||||
({
|
||||
unlink: "人物绑定已解除。",
|
||||
remove: "成员已移出家谱。",
|
||||
transfer: "谱主身份已转让。",
|
||||
})[target.kind] || "操作已完成。";
|
||||
await loadMembers();
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
if (operationCommitted && target.kind === "leave") {
|
||||
hasLeftGenealogy.value = true;
|
||||
members.value = [];
|
||||
memberListState.value = "empty";
|
||||
confirmTarget.value = null;
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "操作失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const returnToOverview = () =>
|
||||
hasLeftGenealogy.value
|
||||
? returnTo("G01")
|
||||
: returnTo("G05", { genealogyId: genealogyId.value });
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(editTarget.value || confirmTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": () =>
|
||||
editTarget.value ? closeEdit() : closeConfirmation(),
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (hasValidGenealogyId.value) void loadMembers();
|
||||
});
|
||||
onShow(() => {
|
||||
if (
|
||||
!hasLeftGenealogy.value &&
|
||||
hasValidGenealogyId.value &&
|
||||
memberListState.value !== "loading" &&
|
||||
!operationPending.value
|
||||
) {
|
||||
void loadMembers();
|
||||
}
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
memberListController.abort();
|
||||
personOptionsController.abort();
|
||||
memberUpdateController.abort();
|
||||
membershipOperationController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.member-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
|
||||
.state-card,
|
||||
.member-intro,
|
||||
.member-card {
|
||||
box-sizing: border-box;
|
||||
@include adaptive.adaptive-genealogy-state-panel;
|
||||
}
|
||||
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(19px, 35rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.member-intro {
|
||||
padding: 24rpx 28rpx;
|
||||
}
|
||||
|
||||
.member-intro text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-intro text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-intro text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.page-feedback {
|
||||
display: block;
|
||||
padding: 18rpx 22rpx;
|
||||
border: 1rpx solid rgba(66, 107, 88, 0.32);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(66, 107, 88, 0.08);
|
||||
color: #426b58;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
|
||||
.member-card {
|
||||
padding: 26rpx 28rpx;
|
||||
}
|
||||
|
||||
.member-card__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
|
||||
.member-card__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-card__identity text {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.member-card__identity text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-card__identity text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 7rpx 14rpx;
|
||||
border: 1rpx solid rgba(174, 113, 39, 0.38);
|
||||
border-radius: 999rpx;
|
||||
background: rgba(205, 161, 84, 0.12);
|
||||
color: #8f160f;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
|
||||
.member-card__details {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.member-card__details text,
|
||||
.member-card__readonly {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.member-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.member-card__actions .app-button {
|
||||
width: auto;
|
||||
min-width: 138rpx;
|
||||
}
|
||||
|
||||
.member-card__readonly {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
@include adaptive.adaptive-genealogy-form-field;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 86rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
}
|
||||
|
||||
.edit-field > text:first-child {
|
||||
flex: 0 0 auto;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edit-field input,
|
||||
.edit-field > text:last-child {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.edit-field--picker > text:last-child {
|
||||
color: #8f160f;
|
||||
}
|
||||
|
||||
.edit-hint,
|
||||
.edit-error {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12rpx;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edit-hint {
|
||||
color: $ink-muted;
|
||||
}
|
||||
|
||||
.edit-error {
|
||||
color: $brand-red;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<view class="applications-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header">
|
||||
<PageHeader title="我的申请" custom-back @back="returnToGenealogies" />
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="applicationListState === 'loading'" class="state-card">
|
||||
<AppLoading text="正在读取我的申请" />
|
||||
</view>
|
||||
<view v-else-if="applicationListState === 'error'" class="state-card">
|
||||
<text>暂时无法读取我的申请</text>
|
||||
<text>{{ applicationListError || "请检查网络后重试。" }}</text>
|
||||
<AppButton block type="secondary" label="重新加载" @click="loadApplications" />
|
||||
</view>
|
||||
<view v-else-if="applicationListState === 'empty'" class="state-card">
|
||||
<text>暂无加入申请</text>
|
||||
<text>从公开家谱提交的申请会显示在这里。</text>
|
||||
<AppButton block label="查找公开家谱" @click="toSearch" />
|
||||
</view>
|
||||
<view v-else class="application-list">
|
||||
<text class="application-list__count">共 {{ visibleApplications.length }} 条申请</text>
|
||||
<text v-if="feedbackMessage" class="page-feedback" role="status">{{ feedbackMessage }}</text>
|
||||
<view
|
||||
v-for="(item, index) in visibleApplications"
|
||||
:key="applicationKey(item, index)"
|
||||
class="application-card"
|
||||
>
|
||||
<view class="application-card__heading">
|
||||
<text>{{ applicationGenealogyName(item) }}</text>
|
||||
<text>{{ applicationTime(item) }}</text>
|
||||
</view>
|
||||
<text class="application-card__relation">{{ applicationRelation(item) }}</text>
|
||||
<text
|
||||
class="application-card__status"
|
||||
:class="`application-card__status--${applicationStatusTone(applicationStatus(item))}`"
|
||||
>{{ statusLabel(applicationStatus(item)) }}</text>
|
||||
<text v-if="applicationRemark(item)" class="application-card__remark">{{
|
||||
applicationRemark(item)
|
||||
}}</text>
|
||||
<view class="application-card__actions">
|
||||
<AppButton
|
||||
v-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.PENDING"
|
||||
compact
|
||||
type="secondary"
|
||||
label="撤回申请"
|
||||
:disabled="!applicationId(item) || operationPending"
|
||||
@click="openWithdraw(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-else-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.APPROVED"
|
||||
compact
|
||||
label="查看家谱"
|
||||
:disabled="!applicationGenealogyId(item)"
|
||||
@click="openGenealogy(item)"
|
||||
/>
|
||||
<AppButton
|
||||
v-else-if="applicationStatus(item) === JOIN_APPLICATION_STATUS.REJECTED"
|
||||
compact
|
||||
type="secondary"
|
||||
label="重新申请"
|
||||
:disabled="!applicationGenealogyId(item)"
|
||||
@click="reapply(item)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="Boolean(withdrawTarget)"
|
||||
eyebrow="申请管理"
|
||||
title="撤回这条申请?"
|
||||
:message="withdrawTarget ? `将撤回“${applicationGenealogyName(withdrawTarget)}”的加入申请。` : ''"
|
||||
:confirm-text="operationPending ? '正在撤回…' : '确认撤回'"
|
||||
cancel-text="暂不撤回"
|
||||
show-cancel
|
||||
:close-on-mask="!operationPending"
|
||||
@confirm="withdrawApplication"
|
||||
@cancel="closeWithdraw"
|
||||
>
|
||||
<text v-if="operationError" class="dialog-error" role="alert">{{ operationError }}</text>
|
||||
</AppDialog>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
JOIN_APPLICATION_STATUS,
|
||||
JOIN_APPLICATION_STATUS_LABELS
|
||||
} from "@/services/api/genealogy-membership-contract.js";
|
||||
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 { handleBackPress, openPage, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const applications = ref([]);
|
||||
const applicationListState = ref("loading");
|
||||
const applicationListError = ref("");
|
||||
const requestedStatus = ref("");
|
||||
const withdrawTarget = ref(null);
|
||||
const operationPending = ref(false);
|
||||
const operationError = ref("");
|
||||
const feedbackMessage = ref("");
|
||||
const applicationListController = createRequestController();
|
||||
const applicationWithdrawalController = createRequestController();
|
||||
let isPageActive = true;
|
||||
|
||||
const applicationId = (item) => {
|
||||
const applicationIdValue = item?.applyId ?? item?.applicationId ?? item?.id;
|
||||
const text = String(applicationIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationKey = (item, index) => applicationId(item) || `application-${index}`;
|
||||
const applicationGenealogyId = (item) => {
|
||||
const genealogyIdValue = item?.genealogyId ?? item?.familyId;
|
||||
const text = String(genealogyIdValue ?? "").trim();
|
||||
return /^[1-9]\d*$/.test(text) ? text : "";
|
||||
};
|
||||
const applicationGenealogyName = (item) =>
|
||||
String(item?.genealogyName ?? item?.familyName ?? item?.genealogyTitle ?? "家谱加入申请");
|
||||
const applicationTime = (item) =>
|
||||
String(item?.appliedAt ?? item?.applyTime ?? item?.createTime ?? item?.createdAt ?? "");
|
||||
const applicationRelation = (item) =>
|
||||
String(item?.relationDesc ?? item?.relation ?? "未填写关系说明");
|
||||
const applicationRemark = (item) =>
|
||||
String(item?.auditRemark ?? item?.rejectionReason ?? item?.reason ?? "");
|
||||
const applicationStatus = (item) =>
|
||||
String(item?.status ?? JOIN_APPLICATION_STATUS.PENDING).trim();
|
||||
const statusLabel = (status) => JOIN_APPLICATION_STATUS_LABELS[status] || "状态待确认";
|
||||
const applicationStatusTones = Object.freeze({
|
||||
[JOIN_APPLICATION_STATUS.PENDING]: "pending",
|
||||
[JOIN_APPLICATION_STATUS.APPROVED]: "approved",
|
||||
[JOIN_APPLICATION_STATUS.REJECTED]: "rejected",
|
||||
[JOIN_APPLICATION_STATUS.CANCELLED]: "cancelled",
|
||||
});
|
||||
const applicationStatusTone = (status) => applicationStatusTones[status] || "unknown";
|
||||
const visibleApplications = computed(() =>
|
||||
requestedStatus.value
|
||||
? applications.value.filter((item) => applicationStatus(item) === requestedStatus.value)
|
||||
: applications.value,
|
||||
);
|
||||
|
||||
const loadApplications = async () => {
|
||||
applicationListController.abort();
|
||||
applicationListState.value = "loading";
|
||||
applicationListError.value = "";
|
||||
feedbackMessage.value = "";
|
||||
try {
|
||||
const loadedApplications = await genealogyMembershipApi.getMyJoinApplications({
|
||||
requestController: applicationListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = loadedApplications;
|
||||
applicationListState.value = visibleApplications.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
applicationListError.value = getRequestErrorMessage(error, "请稍后重试。");
|
||||
applicationListState.value = "error";
|
||||
}
|
||||
};
|
||||
const returnToGenealogies = () => returnTo("G01");
|
||||
const toSearch = () => openPage("G06", {}, "G09");
|
||||
const openGenealogy = (item) =>
|
||||
openPage("G05", { genealogyId: applicationGenealogyId(item) }, "G09");
|
||||
const reapply = (item) =>
|
||||
openPage("G08", { genealogyId: applicationGenealogyId(item), source: "search" }, "G09");
|
||||
const openWithdraw = (item) => {
|
||||
operationError.value = "";
|
||||
withdrawTarget.value = item;
|
||||
};
|
||||
const closeWithdraw = () => {
|
||||
if (operationPending.value) return;
|
||||
withdrawTarget.value = null;
|
||||
operationError.value = "";
|
||||
};
|
||||
const withdrawApplication = async () => {
|
||||
const application = withdrawTarget.value;
|
||||
const id = applicationId(application);
|
||||
if (!application || !id || operationPending.value) return;
|
||||
operationPending.value = true;
|
||||
operationError.value = "";
|
||||
try {
|
||||
await genealogyMembershipApi.withdrawJoinApplication(id, {
|
||||
requestController: applicationWithdrawalController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
applications.value = applications.value.map((item) =>
|
||||
applicationId(item) === id
|
||||
? { ...item, status: JOIN_APPLICATION_STATUS.CANCELLED }
|
||||
: item,
|
||||
);
|
||||
applicationListState.value = visibleApplications.value.length ? "ready" : "empty";
|
||||
feedbackMessage.value = "申请已撤回。";
|
||||
withdrawTarget.value = null;
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
operationError.value = getRequestErrorMessage(error, "撤回申请失败,请稍后重试。");
|
||||
} finally {
|
||||
if (isPageActive) operationPending.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: Boolean(withdrawTarget.value),
|
||||
submitting: operationPending.value,
|
||||
"close-transient": closeWithdraw,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
const status = String(query?.status || "").trim();
|
||||
requestedStatus.value = Object.values(JOIN_APPLICATION_STATUS).includes(status)
|
||||
? status
|
||||
: "";
|
||||
loadApplications();
|
||||
});
|
||||
onShow(() => {
|
||||
if (applicationListState.value !== "loading" && !operationPending.value) loadApplications();
|
||||
});
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
applicationListController.abort();
|
||||
applicationWithdrawalController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.applications-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header, .page-content { z-index: 1; }
|
||||
.page-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.state-card, .application-card { box-sizing: border-box; @include adaptive.adaptive-genealogy-state-panel; }
|
||||
.state-card { min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.state-card text, .application-card__relation, .application-card__remark, .page-feedback { display: block; }
|
||||
.state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(19px, 35rpx, 24px); font-weight: 700; }
|
||||
.state-card text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.application-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.application-list__count { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); }
|
||||
.page-feedback { padding: 18rpx 22rpx; border: 1rpx solid rgba(66, 107, 88, .32); border-radius: 12rpx; background: rgba(66, 107, 88, .08); color: #426b58; font-size: clamp(14px, 23rpx, 17px); }
|
||||
.application-card { padding: 28rpx 30rpx; }
|
||||
.application-card__heading { display: flex; align-items: start; justify-content: space-between; gap: 20rpx; }
|
||||
.application-card__heading text:first-child { min-width: 0; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: clamp(18px, 32rpx, 23px); font-weight: 700; overflow-wrap: anywhere; }
|
||||
.application-card__heading text:last-child { flex: 0 0 auto; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); text-align: right; }
|
||||
.application-card__relation { margin-top: 14rpx; color: $ink-muted; font-size: clamp(15px, 24rpx, 18px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.application-card__status { display: block; margin-top: 16rpx; color: $brand-red; font-size: clamp(15px, 24rpx, 18px); font-weight: 700; }
|
||||
.application-card__status--approved { color: #426b58; }
|
||||
.application-card__status--rejected { color: #886037; }
|
||||
.application-card__status--cancelled, .application-card__status--unknown { color: $ink-muted; }
|
||||
.application-card__remark { margin-top: 8rpx; color: $ink-muted; font-size: clamp(14px, 22rpx, 17px); line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.application-card__actions { display: flex; justify-content: flex-end; margin-top: 22rpx; }
|
||||
.application-card__actions .app-button { min-width: 164rpx; }
|
||||
.dialog-error { display: block; width: 100%; margin-top: 18rpx; color: $brand-red; font-size: clamp(14px, 23rpx, 17px); line-height: 1.5; text-align: left; }
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-01;用途:我的家谱列表与首次分流;本轮仍待用户重新审核。 -->
|
||||
<template>
|
||||
<view
|
||||
class="page-shell genealogy-index"
|
||||
@@ -38,7 +37,7 @@
|
||||
<view
|
||||
class="state-retry"
|
||||
hover-class="action-hover"
|
||||
@click="retryLoad"
|
||||
@click="retryGenealogyLoad"
|
||||
>
|
||||
<text class="state-retry__copy">重新加载</text>
|
||||
</view>
|
||||
@@ -103,13 +102,13 @@
|
||||
|
||||
<view class="shortcut-grid">
|
||||
<view
|
||||
v-for="item in visibleShortcuts"
|
||||
:key="item.key"
|
||||
v-for="shortcut in visibleShortcuts"
|
||||
:key="shortcut.key"
|
||||
class="shortcut-item"
|
||||
@click="openShortcut(item.key)"
|
||||
@click="openShortcut(shortcut.key)"
|
||||
>
|
||||
<image class="shortcut-icon" :src="item.icon" mode="aspectFit" />
|
||||
<text class="shortcut-label">{{ item.label }}</text>
|
||||
<image class="shortcut-icon" :src="shortcut.icon" mode="aspectFit" />
|
||||
<text class="shortcut-label">{{ shortcut.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -127,63 +126,49 @@
|
||||
@scroll="handleListScroll"
|
||||
>
|
||||
<view class="genealogy-lower">
|
||||
<view v-if="createdGenealogies.length" class="list-section">
|
||||
<view v-if="managedGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我管理的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in createdGenealogies"
|
||||
:key="item.id"
|
||||
:genealogy="item"
|
||||
v-for="genealogy in managedGenealogies"
|
||||
:key="genealogy.id"
|
||||
:genealogy="genealogy"
|
||||
role="管理员"
|
||||
:selected="item.id === currentGenealogy.id"
|
||||
:selected="genealogy.id === currentGenealogy.id"
|
||||
@select="openGenealogy"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view v-if="joinedGenealogies.length" class="list-section">
|
||||
<view v-if="memberGenealogies.length" class="list-section">
|
||||
<view class="section-heading"><text>我加入的</text></view>
|
||||
<GenealogyCard
|
||||
v-for="item in joinedGenealogies"
|
||||
:key="item.id"
|
||||
:genealogy="item"
|
||||
v-for="genealogy in memberGenealogies"
|
||||
:key="genealogy.id"
|
||||
:genealogy="genealogy"
|
||||
role="成员"
|
||||
:selected="item.id === currentGenealogy.id"
|
||||
:selected="genealogy.id === currentGenealogy.id"
|
||||
@select="openGenealogy"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="applicationRecords.length"
|
||||
class="list-section application-section"
|
||||
v-if="genealogies.length > 1"
|
||||
class="genealogy-order-trigger"
|
||||
role="button"
|
||||
aria-label="调整我的家谱排序"
|
||||
hover-class="action-hover"
|
||||
@click="openOrderDialog"
|
||||
>
|
||||
<view class="section-heading"><text>加入申请</text></view>
|
||||
<view
|
||||
v-for="item in applicationRecords"
|
||||
:key="item.id"
|
||||
class="application-record"
|
||||
hover-class="action-hover"
|
||||
@click="openApplication(item)"
|
||||
>
|
||||
<view class="application-record__main">
|
||||
<view class="application-record__title-row">
|
||||
<text class="application-record__name">{{
|
||||
item.name
|
||||
}}</text>
|
||||
<text
|
||||
class="application-record__status"
|
||||
:class="`application-record__status--${item.tone}`"
|
||||
>{{ item.statusLabel }}</text
|
||||
>
|
||||
</view>
|
||||
<text class="application-record__copy">{{
|
||||
item.description
|
||||
}}</text>
|
||||
</view>
|
||||
<image
|
||||
class="application-record__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<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" @click="openAddDialog">
|
||||
@@ -204,6 +189,7 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
@@ -233,13 +219,6 @@
|
||||
>搜索家谱</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="empty-invite-action"
|
||||
hover-class="action-hover"
|
||||
@click="joinByInvite"
|
||||
>
|
||||
<text class="empty-invite-action__copy">邀请码加入</text>
|
||||
</view>
|
||||
<view
|
||||
class="empty-create-action"
|
||||
hover-class="action-hover"
|
||||
@@ -255,110 +234,29 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="addDialogVisible"
|
||||
class="add-dialog-layer"
|
||||
@click="closeAddDialog"
|
||||
>
|
||||
<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="quota" class="dialog-copy">{{
|
||||
quota.createRemaining === -1
|
||||
? "当前可继续创建家谱"
|
||||
: `还可创建 ${quota.createRemaining} 部家谱`
|
||||
}}</text>
|
||||
<view
|
||||
class="add-dialog__close"
|
||||
role="button"
|
||||
aria-label="关闭"
|
||||
hover-class="action-hover"
|
||||
@click="closeAddDialog"
|
||||
>
|
||||
<image
|
||||
class="add-dialog__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-dialog__actions">
|
||||
<AppButton block label="搜索家谱" @click="applyToJoin" />
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="邀请码加入"
|
||||
@click="joinByInvite"
|
||||
/>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="继续创建家谱"
|
||||
:disabled="quota?.canCreate === false"
|
||||
@click="createGenealogy"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<GenealogyAddDialog
|
||||
:visible="addDialogVisible"
|
||||
:creation-quota="creationQuota"
|
||||
@close="closeAddDialog"
|
||||
@search="applyToJoin"
|
||||
@create="createGenealogy"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-if="switcherVisible"
|
||||
class="genealogy-switcher-layer"
|
||||
@click="closeSwitcher"
|
||||
>
|
||||
<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="closeSwitcher"
|
||||
>
|
||||
<image
|
||||
class="genealogy-switcher__close-icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g01-dialog-close.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<scroll-view class="genealogy-switcher__list" scroll-y>
|
||||
<button
|
||||
v-for="item in availableGenealogies"
|
||||
:key="item.id"
|
||||
class="switcher-item"
|
||||
:class="{
|
||||
'switcher-item--active': item.id === selectedGenealogyId,
|
||||
}"
|
||||
:aria-pressed="item.id === selectedGenealogyId"
|
||||
:aria-label="`${item.name},${item.location},${item.memberCount} 位成员`"
|
||||
@click="selectGenealogy(item)"
|
||||
>
|
||||
<view>
|
||||
<text class="switcher-item__name">{{ item.name }}</text>
|
||||
<text class="switcher-item__meta"
|
||||
>{{ item.location }} · {{ item.memberCount }} 位成员</text
|
||||
>
|
||||
</view>
|
||||
<text class="switcher-item__state">{{
|
||||
item.id === selectedGenealogyId ? "当前" : "选择"
|
||||
}}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<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" />
|
||||
</view>
|
||||
@@ -370,58 +268,58 @@ 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 GenealogyCard from "@/components/GenealogyCard.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
|
||||
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
|
||||
import GenealogyCard from "@/components/genealogy/Card.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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.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,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const isLoading = ref(false);
|
||||
const hasError = ref(false);
|
||||
const list = ref([]);
|
||||
const forceEmptyState = ref(false);
|
||||
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 quota = ref(null);
|
||||
const listRequestController = createRequestController();
|
||||
const creationQuota = ref(null);
|
||||
const genealogyListRequestController = createRequestController();
|
||||
const unreadRequestController = createRequestController();
|
||||
const quotaRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
|
||||
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
|
||||
let genealogyLoadGeneration = 0;
|
||||
let unreadLoadGeneration = 0;
|
||||
let quotaLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let presentationState = "default";
|
||||
let skipNextShowRefresh = true;
|
||||
|
||||
const syncEmptyStateFromRoute = (query = {}) => {
|
||||
presentationState = ["empty", "loading", "error"].includes(query?.state)
|
||||
? query.state
|
||||
: "default";
|
||||
forceEmptyState.value = presentationState === "empty";
|
||||
isLoading.value = presentationState === "loading";
|
||||
hasError.value = presentationState === "error";
|
||||
};
|
||||
|
||||
const reconcilePageGenealogyContext = () => {
|
||||
try {
|
||||
const availableIds = list.value.map((item) => String(item.id));
|
||||
const availableIds = genealogies.value.map((genealogy) =>
|
||||
String(genealogy.id),
|
||||
);
|
||||
const previousId = genealogyContext.getCurrentGenealogyId();
|
||||
selectedGenealogyId.value =
|
||||
genealogyContext.reconcileCurrentGenealogyId(
|
||||
@@ -448,28 +346,28 @@ const reconcilePageGenealogyContext = () => {
|
||||
};
|
||||
|
||||
const loadGenealogies = async () => {
|
||||
const generation = ++loadGeneration;
|
||||
listRequestController.abort();
|
||||
const generation = ++genealogyLoadGeneration;
|
||||
genealogyListRequestController.abort();
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
try {
|
||||
const result = await appApi.getMyGenealogies({
|
||||
requestController: listRequestController,
|
||||
const loadedGenealogies = await genealogyApi.getMyGenealogies({
|
||||
requestController: genealogyListRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
list.value = result;
|
||||
if (!pageActive || generation !== genealogyLoadGeneration) return;
|
||||
genealogies.value = loadedGenealogies;
|
||||
reconcilePageGenealogyContext();
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== loadGeneration ||
|
||||
generation !== genealogyLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
if (pageActive && generation === loadGeneration) {
|
||||
if (pageActive && generation === genealogyLoadGeneration) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -479,7 +377,7 @@ const loadUnreadCount = async () => {
|
||||
const generation = ++unreadLoadGeneration;
|
||||
unreadRequestController.abort();
|
||||
try {
|
||||
const count = await appApi.getUnreadNotificationCount({
|
||||
const count = await notificationApi.getUnreadNotificationCount({
|
||||
requestController: unreadRequestController,
|
||||
});
|
||||
if (pageActive && generation === unreadLoadGeneration)
|
||||
@@ -493,40 +391,37 @@ const loadQuota = async () => {
|
||||
const generation = ++quotaLoadGeneration;
|
||||
quotaRequestController.abort();
|
||||
try {
|
||||
const result = await appApi.getGenealogyQuota({
|
||||
const genealogyQuota = await genealogyApi.getGenealogyQuota({
|
||||
requestController: quotaRequestController,
|
||||
});
|
||||
if (pageActive && generation === quotaLoadGeneration) quota.value = result;
|
||||
if (pageActive && generation === quotaLoadGeneration) {
|
||||
creationQuota.value = genealogyQuota;
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!isRequestCancelled(error) &&
|
||||
pageActive &&
|
||||
generation === quotaLoadGeneration
|
||||
)
|
||||
quota.value = null;
|
||||
creationQuota.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
syncEmptyStateFromRoute(query);
|
||||
requestedGenealogyId.value = String(query?.genealogyId || "");
|
||||
if (presentationState === "default") {
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
} else {
|
||||
reconcilePageGenealogyContext();
|
||||
}
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
const result = consumeNavigationResult("G01");
|
||||
const navigationResult = consumeNavigationResult("G01");
|
||||
if (
|
||||
result?.operation === "genealogy-created" &&
|
||||
result.refresh &&
|
||||
result.entityId
|
||||
navigationResult?.operation === "genealogy-created" &&
|
||||
navigationResult.refresh &&
|
||||
navigationResult.entityId
|
||||
) {
|
||||
requestedGenealogyId.value = result.entityId;
|
||||
requestedGenealogyId.value = navigationResult.entityId;
|
||||
loadGenealogies();
|
||||
return;
|
||||
}
|
||||
@@ -534,24 +429,22 @@ onShow(() => {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (presentationState === "default") loadGenealogies();
|
||||
if (presentationState === "default") loadUnreadCount();
|
||||
if (presentationState === "default") loadQuota();
|
||||
loadGenealogies();
|
||||
loadUnreadCount();
|
||||
loadQuota();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
genealogyLoadGeneration += 1;
|
||||
unreadLoadGeneration += 1;
|
||||
quotaLoadGeneration += 1;
|
||||
listRequestController.abort();
|
||||
genealogyListRequestController.abort();
|
||||
unreadRequestController.abort();
|
||||
quotaRequestController.abort();
|
||||
});
|
||||
|
||||
const hasGenealogies = computed(
|
||||
() => !forceEmptyState.value && list.value.length > 0,
|
||||
);
|
||||
const hasGenealogies = computed(() => genealogies.value.length > 0);
|
||||
const isListLayout = computed(
|
||||
() =>
|
||||
!isLoading.value &&
|
||||
@@ -559,29 +452,25 @@ const isListLayout = computed(
|
||||
!contextInvalidated.value &&
|
||||
hasGenealogies.value,
|
||||
);
|
||||
const createdGenealogies = computed(() =>
|
||||
list.value.filter((item) => item.accessRole === "owner"),
|
||||
const managedGenealogies = computed(() =>
|
||||
genealogies.value.filter((genealogy) => genealogy.canManage),
|
||||
);
|
||||
const joinedGenealogies = computed(() =>
|
||||
list.value.filter((item) => item.accessRole === "member"),
|
||||
const memberGenealogies = computed(() =>
|
||||
genealogies.value.filter((genealogy) => !genealogy.canManage),
|
||||
);
|
||||
const availableGenealogies = computed(() => list.value);
|
||||
const currentGenealogy = computed(
|
||||
() =>
|
||||
availableGenealogies.value.find(
|
||||
(item) => item.id === selectedGenealogyId.value,
|
||||
genealogies.value.find(
|
||||
(genealogy) => genealogy.id === selectedGenealogyId.value,
|
||||
) || null,
|
||||
);
|
||||
const isCurrentGenealogyOwner = computed(
|
||||
() => currentGenealogy.value?.accessRole === "owner",
|
||||
const canManageCurrentGenealogy = computed(
|
||||
() => currentGenealogy.value?.canManage === true,
|
||||
);
|
||||
const currentRoleLabel = computed(() =>
|
||||
isCurrentGenealogyOwner.value ? "管理员" : "成员",
|
||||
canManageCurrentGenealogy.value ? "管理员" : "成员",
|
||||
);
|
||||
|
||||
// 普通加入申请批次接通前不展示本地伪记录。
|
||||
const applicationRecords = ref([]);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: "tree",
|
||||
@@ -605,9 +494,9 @@ const shortcuts = [
|
||||
},
|
||||
];
|
||||
const visibleShortcuts = computed(() =>
|
||||
isCurrentGenealogyOwner.value
|
||||
canManageCurrentGenealogy.value
|
||||
? shortcuts
|
||||
: shortcuts.filter((item) => item.key !== "applications"),
|
||||
: shortcuts.filter((shortcut) => shortcut.key !== "applications"),
|
||||
);
|
||||
|
||||
const openGenealogy = (genealogy) =>
|
||||
@@ -628,10 +517,6 @@ const applyToJoin = () => {
|
||||
closeAddDialog();
|
||||
return openPage("G06", {}, "G01");
|
||||
};
|
||||
const joinByInvite = () => {
|
||||
closeAddDialog();
|
||||
return openPage("G06", { mode: "invite" }, "G01");
|
||||
};
|
||||
const toNotifications = () => {
|
||||
const notificationParams = currentGenealogy.value
|
||||
? { genealogyId: String(currentGenealogy.value.id) }
|
||||
@@ -651,17 +536,39 @@ const openSwitcher = () => {
|
||||
const closeSwitcher = () => {
|
||||
switcherVisible.value = false;
|
||||
};
|
||||
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,
|
||||
transientOpen:
|
||||
switcherVisible.value || addDialogVisible.value || orderDialogVisible.value,
|
||||
"close-transient": closeActiveOverlay,
|
||||
});
|
||||
onBackPress((event) => {
|
||||
if (!switcherVisible.value && !addDialogVisible.value) return false;
|
||||
if (
|
||||
!switcherVisible.value &&
|
||||
!addDialogVisible.value &&
|
||||
!orderDialogVisible.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return handleBackPress(event, requestBack);
|
||||
});
|
||||
const handleListScroll = (event) => {
|
||||
@@ -680,23 +587,11 @@ const selectGenealogy = async (genealogy) => {
|
||||
closeSwitcher();
|
||||
await resetListScroll();
|
||||
};
|
||||
const openApplication = (record) => {
|
||||
if (record.id === "pending")
|
||||
return openPage("G09", { status: "pending" }, "G01");
|
||||
return openPage(
|
||||
"G08",
|
||||
{
|
||||
genealogyId: String(record.genealogyId),
|
||||
source: "search",
|
||||
},
|
||||
"G01",
|
||||
);
|
||||
};
|
||||
const retryLoad = () => {
|
||||
const retryGenealogyLoad = () => {
|
||||
loadGenealogies();
|
||||
};
|
||||
|
||||
const openShortcut = (key) => {
|
||||
const openShortcut = (shortcutKey) => {
|
||||
if (!currentGenealogy.value) return;
|
||||
const genealogyId = String(currentGenealogy.value.id);
|
||||
const actions = {
|
||||
@@ -705,7 +600,7 @@ const openShortcut = (key) => {
|
||||
poem: () => openPage("G12", { genealogyId }, "G01"),
|
||||
applications: () => openPage("G10", { genealogyId }, "G01"),
|
||||
};
|
||||
return actions[key]?.();
|
||||
return actions[shortcutKey]?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -923,57 +818,41 @@ const openShortcut = (key) => {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.application-record {
|
||||
@include adaptive-genealogy-list-card;
|
||||
display: flex;
|
||||
min-height: 138rpx;
|
||||
align-items: center;
|
||||
padding: 22rpx 26rpx;
|
||||
}
|
||||
.application-record + .application-record {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
.application-record__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.application-record__title-row {
|
||||
.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, 250, 240, 0.72);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.application-record__name {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(17px, 32rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.application-record__status {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 16rpx;
|
||||
color: #7f4f16;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 600;
|
||||
}
|
||||
.application-record__status--rejected {
|
||||
color: #a7160c;
|
||||
}
|
||||
.application-record__status--muted {
|
||||
color: #62584c;
|
||||
}
|
||||
.application-record__copy {
|
||||
|
||||
.genealogy-order-trigger__title,
|
||||
.genealogy-order-trigger__copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: #62584c;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.application-record__chevron {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-left: 14rpx;
|
||||
|
||||
.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 {
|
||||
@@ -1169,8 +1048,7 @@ const openShortcut = (key) => {
|
||||
margin-top: 26rpx;
|
||||
}
|
||||
|
||||
.empty-search-action,
|
||||
.empty-invite-action {
|
||||
.empty-search-action {
|
||||
display: flex;
|
||||
width: 560rpx;
|
||||
min-height: 124rpx;
|
||||
@@ -1180,18 +1058,11 @@ const openShortcut = (key) => {
|
||||
|
||||
.empty-search-action {
|
||||
margin-top: 26rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
|
||||
.empty-invite-action {
|
||||
margin-top: 22rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-secondary-v3.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
|
||||
.empty-search-action__copy,
|
||||
.empty-invite-action__copy {
|
||||
.empty-search-action__copy {
|
||||
color: #7b4e24;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
@@ -1227,7 +1098,7 @@ const openShortcut = (key) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 30rpx;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.state-retry__copy {
|
||||
@@ -1236,189 +1107,6 @@ const openShortcut = (key) => {
|
||||
font-size: clamp(16px, 29rpx, 20px);
|
||||
}
|
||||
|
||||
.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-g01-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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-g01-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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
<!-- 页面编号:G-05;用途:家谱总览与加载、空数据、失败状态。 -->
|
||||
<template>
|
||||
<view class="overview-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="overview-page__header">
|
||||
<PageHeader
|
||||
:title="
|
||||
viewMode === 'public'
|
||||
? '家谱公开预览'
|
||||
: viewMode === 'preview'
|
||||
? '创建流程预览'
|
||||
: '家谱总览'
|
||||
"
|
||||
title="家谱总览"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/>
|
||||
@@ -20,13 +13,10 @@
|
||||
class="overview-surface"
|
||||
:class="{
|
||||
'overview-surface--state': overviewState !== 'ready',
|
||||
'overview-surface--member':
|
||||
overviewState === 'ready' && viewMode === 'member',
|
||||
'overview-surface--member': overviewState === 'ready',
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-if="overviewState === 'ready' && genealogy && viewMode === 'member'"
|
||||
>
|
||||
<template v-if="overviewState === 'ready' && genealogy">
|
||||
<view class="overview-ready">
|
||||
<view class="overview-hero">
|
||||
<view class="overview-hero__identity">
|
||||
@@ -48,7 +38,7 @@
|
||||
genealogy.intro || "简介待补充"
|
||||
}}</text>
|
||||
<view class="overview-hero__stats">
|
||||
<text>共 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text>已加入 {{ genealogy.memberCount || 0 }} 人</text>
|
||||
<text v-if="genealogy.personCount !== null"
|
||||
>世系 {{ genealogy.personCount }} 人</text
|
||||
>
|
||||
@@ -79,8 +69,40 @@
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-action" @click="openInvitationManager">
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-members.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">邀请家人</text>
|
||||
<text>生成或撤销我发出的邀请码</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="overview-action" @click="toMembers">
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/shortcut-members.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
<text class="overview-action__title">成员管理</text>
|
||||
<text>查看成员身份、角色和人物绑定</text>
|
||||
</view>
|
||||
<image
|
||||
class="overview-action__chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
v-if="canManageGenealogy"
|
||||
class="overview-action overview-action--review"
|
||||
@click="toApplications"
|
||||
>
|
||||
@@ -119,13 +141,13 @@
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
v-if="accessRole === 'owner'"
|
||||
v-if="canManageGenealogy"
|
||||
class="overview-action overview-action--settings"
|
||||
@click="toSettings"
|
||||
>
|
||||
<image
|
||||
class="overview-action__icon"
|
||||
src="/static/assets/modules/genealogy/transparent/g05-settings-gear.png"
|
||||
src="/static/assets/modules/genealogy/transparent/settings-gear.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="overview-action__copy">
|
||||
@@ -169,71 +191,6 @@
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view
|
||||
v-else-if="overviewState === 'ready' && genealogy"
|
||||
class="overview-public"
|
||||
>
|
||||
<view class="overview-public__hero">
|
||||
<text class="overview-public__eyebrow">{{
|
||||
viewMode === "preview" ? "本地流程预览" : "公开家谱"
|
||||
}}</text>
|
||||
<text class="overview-public__title">{{ genealogy.name }}</text>
|
||||
<text class="overview-public__source">{{
|
||||
genealogy.source || "来源信息待同步"
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="overview-public__details">
|
||||
<view
|
||||
><text>姓氏</text><text>{{ genealogy.surname }}氏</text></view
|
||||
>
|
||||
<view
|
||||
><text>地区</text
|
||||
><text>{{ genealogy.location || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>堂号</text
|
||||
><text>{{ genealogy.hall || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>当前支系</text
|
||||
><text>{{ genealogy.branchName || "待补充" }}</text></view
|
||||
>
|
||||
<view
|
||||
><text>{{
|
||||
viewMode === "preview" ? "首代人物" : "所属上级谱"
|
||||
}}</text
|
||||
><text>{{
|
||||
(viewMode === "preview"
|
||||
? genealogy.ancestorName
|
||||
: genealogy.parentName) || "待补充"
|
||||
}}</text></view
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
viewMode === 'public' &&
|
||||
(genealogy.manager || genealogy.certification)
|
||||
"
|
||||
class="overview-public__trust"
|
||||
>
|
||||
<text>{{ genealogy.manager || "管理者待确认" }}</text>
|
||||
<text>{{ genealogy.certification || "认证信息待确认" }}</text>
|
||||
<text>{{ genealogy.memberCount || 0 }} 位成员</text>
|
||||
<text>更新于 {{ genealogy.updatedAt || "待同步" }}</text>
|
||||
</view>
|
||||
<view class="overview-public__notice">
|
||||
<text>{{ viewMode === "preview" ? "预览说明" : "公开说明" }}</text>
|
||||
<text>{{ genealogy.publicDescription || "公开说明待补充" }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="viewMode === 'public' && publicActionLabel"
|
||||
class="overview-public__action"
|
||||
@click="applyToJoin"
|
||||
>
|
||||
<text>{{ publicActionLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="overviewState === 'loading'"
|
||||
class="overview-state overview-state--loading"
|
||||
@@ -274,6 +231,12 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<InvitationManager
|
||||
ref="invitationManager"
|
||||
:genealogy-id="genealogyId"
|
||||
@busy-change="invitationBusy = $event"
|
||||
@transient-change="invitationTransientOpen = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -281,45 +244,36 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import InvitationManager from "@/components/genealogy/InvitationManager.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy-contracts.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
goBack,
|
||||
goRoot,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogy = ref(null);
|
||||
const genealogyId = ref("");
|
||||
const overviewState = ref("loading");
|
||||
const loadError = ref("");
|
||||
const viewMode = ref("member");
|
||||
const accessRole = ref("guest");
|
||||
const publicRelation = ref("unknown");
|
||||
const publicCanApply = ref(false);
|
||||
const overviewRequestController = createRequestController();
|
||||
const overviewLoadError = ref("");
|
||||
const canManageGenealogy = ref(false);
|
||||
const invitationManager = ref(null);
|
||||
const invitationBusy = ref(false);
|
||||
const invitationTransientOpen = ref(false);
|
||||
const genealogyOverviewRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const publicActionLabel = computed(() => {
|
||||
if (publicRelation.value === "pending") return "查看申请进度";
|
||||
if (!publicCanApply.value) return "";
|
||||
return (
|
||||
{
|
||||
available: "申请加入这部家谱",
|
||||
rejected: "修改后重新申请",
|
||||
removed: "重新申请加入",
|
||||
}[publicRelation.value] || ""
|
||||
);
|
||||
});
|
||||
|
||||
const stateTitle = computed(
|
||||
() =>
|
||||
@@ -327,17 +281,17 @@ const stateTitle = computed(
|
||||
loading: "正在展开家谱…",
|
||||
empty: "还没有可查看的家谱",
|
||||
error: "家谱暂时无法打开",
|
||||
"no-permission": "当前账号无权查看",
|
||||
"no-permission": "暂时无法查看",
|
||||
})[overviewState.value] || "",
|
||||
);
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
loadError.value ||
|
||||
overviewLoadError.value ||
|
||||
{
|
||||
loading: "请稍候,正在读取家谱概览。",
|
||||
loading: "请稍候,正在加载家谱概览。",
|
||||
empty: "请从“我的家谱”选择一部家谱后再进入。",
|
||||
error: "可能是网络波动或家谱不存在。",
|
||||
"no-permission": "这部家谱未向当前账号开放,请返回我的家谱或联系管理员。",
|
||||
"no-permission": "这部家谱暂未向你开放,请返回我的家谱或联系管理员。",
|
||||
}[overviewState.value] ||
|
||||
"",
|
||||
);
|
||||
@@ -347,38 +301,25 @@ const formatGenealogyTime = (value) =>
|
||||
|
||||
const loadGenealogy = async (query = {}) => {
|
||||
const generation = ++loadGeneration;
|
||||
overviewRequestController.abort();
|
||||
genealogyOverviewRequestController.abort();
|
||||
overviewState.value = "loading";
|
||||
loadError.value = "";
|
||||
overviewLoadError.value = "";
|
||||
genealogy.value = null;
|
||||
genealogyId.value = String(query.genealogyId || genealogyId.value || "");
|
||||
viewMode.value = "member";
|
||||
accessRole.value = "guest";
|
||||
publicRelation.value = "unknown";
|
||||
publicCanApply.value = false;
|
||||
canManageGenealogy.value = false;
|
||||
|
||||
if (query.state === "empty" || !genealogyId.value) {
|
||||
if (!genealogyId.value) {
|
||||
overviewState.value = "empty";
|
||||
return;
|
||||
}
|
||||
if (query.state === "error") {
|
||||
overviewState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (query.state === "no-permission") {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
if (query.state === "loading") return;
|
||||
|
||||
try {
|
||||
const result = await appApi.getOverview(genealogyId.value, {
|
||||
requestController: overviewRequestController,
|
||||
const overviewDetails = await genealogyApi.getOverview(genealogyId.value, {
|
||||
requestController: genealogyOverviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
genealogy.value = result;
|
||||
viewMode.value = "member";
|
||||
accessRole.value = result.accessRole;
|
||||
genealogy.value = overviewDetails;
|
||||
canManageGenealogy.value = overviewDetails.canManage;
|
||||
overviewState.value = "ready";
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -392,13 +333,11 @@ const loadGenealogy = async (query = {}) => {
|
||||
overviewState.value = "no-permission";
|
||||
return;
|
||||
}
|
||||
loadError.value = error?.message || "家谱概览读取失败,请稍后重试。";
|
||||
overviewLoadError.value = getRequestErrorMessage(error, "家谱概览加载失败,请稍后重试。");
|
||||
overviewState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onLoad(loadGenealogy);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
@@ -410,7 +349,7 @@ onShow(() => {
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadGeneration += 1;
|
||||
overviewRequestController.abort();
|
||||
genealogyOverviewRequestController.abort();
|
||||
});
|
||||
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
|
||||
const toGenealogies = () => returnTo("G01", {});
|
||||
@@ -422,24 +361,24 @@ const toSettings = () =>
|
||||
openPage("G11", { genealogyId: genealogyId.value }, "G05");
|
||||
const toGenerationPoems = () =>
|
||||
openPage("G12", { genealogyId: genealogyId.value }, "G05");
|
||||
const applyToJoin = () => {
|
||||
if (publicRelation.value === "pending")
|
||||
return openPage("G09", { status: "pending" }, "G05");
|
||||
if (!publicCanApply.value) return false;
|
||||
return openPage(
|
||||
"G08",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
genealogyName: genealogy.name,
|
||||
source: "search",
|
||||
},
|
||||
"G05",
|
||||
);
|
||||
const toMembers = () =>
|
||||
openPage("G13", { genealogyId: genealogyId.value }, "G05");
|
||||
const openInvitationManager = () => {
|
||||
if (overviewState.value !== "ready" || !genealogyId.value) return;
|
||||
invitationManager.value?.open();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (invitationBusy.value || invitationTransientOpen.value) {
|
||||
invitationManager.value?.closeTransient();
|
||||
return true;
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
.overview-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -450,7 +389,7 @@ const applyToJoin = () => {
|
||||
z-index: 3;
|
||||
}
|
||||
.overview-surface {
|
||||
@include adaptive-g05-overview-surface;
|
||||
@include adaptive.adaptive-genealogy-overview-surface;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
min-height: min(483px, calc(100vw * 1.3422));
|
||||
@@ -485,8 +424,8 @@ const applyToJoin = () => {
|
||||
margin: 0;
|
||||
padding: 34rpx 42rpx 28rpx;
|
||||
background: #10293b
|
||||
url("/static/assets/modules/genealogy/opaque/g05-overview-surface.png")
|
||||
center top / auto 1220rpx no-repeat;
|
||||
url("/static/assets/modules/genealogy/opaque/overview-surface.png")
|
||||
center top / auto 400% no-repeat;
|
||||
color: #fff8ec;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -667,7 +606,7 @@ const applyToJoin = () => {
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-state {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
@include adaptive.adaptive-genealogy-state-panel;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
@@ -715,7 +654,7 @@ const applyToJoin = () => {
|
||||
margin-top: 34rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
background: url("/static/assets/foundation/transparent/scroll-primary.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.overview-state-panel__action text {
|
||||
@@ -724,112 +663,6 @@ const applyToJoin = () => {
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-public {
|
||||
display: flex;
|
||||
min-height: min(540px, calc(100vw * 1.5));
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 58rpx 0 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.overview-public__hero {
|
||||
margin: 0 8%;
|
||||
color: #fff8ec;
|
||||
}
|
||||
.overview-public__eyebrow {
|
||||
display: block;
|
||||
color: #dec483;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.overview-public__title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(22px, 43rpx, 28px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
.overview-public__source {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
color: #e9dcc1;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.overview-public__details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18rpx 28rpx;
|
||||
min-height: 240rpx;
|
||||
margin: 126rpx 9% 0;
|
||||
}
|
||||
.overview-public__details > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) {
|
||||
grid-column: 1 / -1;
|
||||
align-self: start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.overview-public__details > view:nth-child(5) text:last-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.overview-public__details text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.overview-public__details text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
}
|
||||
.overview-public__trust {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 10rpx 24rpx;
|
||||
margin: 28rpx 9% 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.overview-public__notice {
|
||||
display: flex;
|
||||
margin: 54rpx 9% 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 19rpx, 16px);
|
||||
line-height: max(1.35em, clamp(16px, 26rpx, 20px));
|
||||
}
|
||||
.overview-public__notice text:first-child {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12rpx;
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.overview-public__action {
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 204rpx 9% 0;
|
||||
background: url("/static/assets/foundation/transparent/a01-scroll-primary-v3.png")
|
||||
center / contain no-repeat;
|
||||
}
|
||||
.overview-public__action text {
|
||||
z-index: 1;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(16px, 27rpx, 20px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.overview-action {
|
||||
padding: 0 28rpx;
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<view class="search-page">
|
||||
<GenealogyPageBackground />
|
||||
<view class="page-header"
|
||||
><PageHeader title="搜索家谱" custom-back @back="backToGenealogies"
|
||||
/></view>
|
||||
<view class="page-content">
|
||||
<view class="invite-card">
|
||||
<text class="invite-card__title">邀请码加入</text>
|
||||
<text class="invite-card__copy"
|
||||
>输入收到的邀请码,查看家谱信息后再确认加入。</text
|
||||
>
|
||||
<input
|
||||
v-model.trim="inviteToken"
|
||||
class="invite-card__input"
|
||||
type="password"
|
||||
maxlength="128"
|
||||
placeholder="请输入邀请码"
|
||||
:disabled="inviteState === 'previewing' || inviteState === 'redeeming'"
|
||||
@input="resetInvitePreview"
|
||||
/>
|
||||
<text v-if="inviteError" class="invite-card__error">{{ inviteError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="!inviteToken || inviteState === 'previewing' || inviteState === 'redeeming'"
|
||||
:label="inviteState === 'previewing' ? '正在查看' : '查看家谱信息'"
|
||||
@click="previewInvite"
|
||||
/>
|
||||
<view v-if="invitePreview" class="invite-preview">
|
||||
<text>可加入家谱:{{ invitePreview.genealogyName }}</text>
|
||||
<text>有效至:{{ invitePreview.expiresAt }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="inviteState === 'redeeming'"
|
||||
:label="inviteState === 'redeeming' ? '正在加入' : '确认加入这部家谱'"
|
||||
@click="openRedeemConfirmation"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="inviteResult" class="invite-result">
|
||||
<text>{{ inviteResultCopy.title }}</text>
|
||||
<text>{{ inviteResultCopy.copy }}</text>
|
||||
<AppButton block :label="inviteResultCopy.action" @click="handleInviteResult" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-note"
|
||||
><text>公开家谱</text
|
||||
><text>以下是可加入的公开家谱。</text></view
|
||||
>
|
||||
<view v-if="genealogySearchState === 'loading'" class="state-card"
|
||||
><AppLoading
|
||||
text="正在读取公开家谱"
|
||||
variant="section"
|
||||
description="正在查询可加入的公开家谱。"
|
||||
/></view>
|
||||
<view v-else-if="genealogySearchState === 'error'" class="state-card"
|
||||
><text>暂时无法读取公开家谱</text
|
||||
><AppButton
|
||||
block
|
||||
type="secondary"
|
||||
label="重新加载"
|
||||
@click="loadGenealogies"
|
||||
/></view>
|
||||
<view v-else-if="genealogySearchState === 'empty'" class="state-card"
|
||||
><text>暂未找到公开家谱</text></view
|
||||
>
|
||||
<view v-else class="result-list">
|
||||
<view v-for="item in rows" :key="item.id" class="genealogy-card">
|
||||
<view class="card-heading"
|
||||
><text>{{ item.name }}</text
|
||||
><text v-if="item.surname">{{ item.surname }}氏</text></view
|
||||
>
|
||||
<text v-if="item.location" class="card-meta">{{
|
||||
item.location
|
||||
}}</text>
|
||||
<text v-if="item.intro" class="card-copy">{{ item.intro }}</text>
|
||||
<view class="card-footer"
|
||||
><text>{{ item.memberCount }} 位成员</text
|
||||
><AppButton
|
||||
:label="item.canManage ? '已在我的家谱' : '申请加入'"
|
||||
:disabled="item.canManage"
|
||||
@click="applyToJoin(item)"
|
||||
/></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="redeemConfirmationVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="加入确认"
|
||||
title="确认使用邀请码加入?"
|
||||
:message="redeemConfirmationMessage"
|
||||
confirm-text="确认加入"
|
||||
cancel-text="暂不加入"
|
||||
show-cancel
|
||||
@confirm="redeemInvite"
|
||||
@cancel="closeRedeemConfirmation"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMembershipApi } from "@/services/api/genealogy-membership-service.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rows = ref([]);
|
||||
const genealogySearchState = ref("loading");
|
||||
const inviteToken = ref("");
|
||||
const inviteState = ref("idle");
|
||||
const inviteError = ref("");
|
||||
const invitePreview = ref(null);
|
||||
const inviteResult = ref(null);
|
||||
const redeemConfirmationVisible = ref(false);
|
||||
const publicGenealogyListController = createRequestController();
|
||||
const invitationPreviewController = createRequestController();
|
||||
const invitationRedemptionController = createRequestController();
|
||||
const invitationRedemptionGuard = createNonIdempotentWriteGuard();
|
||||
let isPageActive = true;
|
||||
const loadGenealogies = async () => {
|
||||
publicGenealogyListController.abort();
|
||||
genealogySearchState.value = "loading";
|
||||
try {
|
||||
const publicGenealogies = await genealogyApi.getPublicGenealogies({
|
||||
requestController: publicGenealogyListController,
|
||||
});
|
||||
if (!isPageActive) return;
|
||||
rows.value = publicGenealogies;
|
||||
genealogySearchState.value = rows.value.length ? "ready" : "empty";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
genealogySearchState.value = "error";
|
||||
}
|
||||
};
|
||||
const applyToJoin = (item) =>
|
||||
openPage("G08", { genealogyId: item.id, genealogyName: item.name }, "G06");
|
||||
const backToGenealogies = () => returnTo("G01");
|
||||
const inviteResultCopy = computed(() =>
|
||||
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
|
||||
? {
|
||||
title: "已加入家谱",
|
||||
copy: "你已加入这部家谱。",
|
||||
action: "查看我的家谱",
|
||||
}
|
||||
: {
|
||||
title: "加入申请已提交",
|
||||
copy: "申请已提交,请等待审核结果。",
|
||||
action: "查看我的申请",
|
||||
},
|
||||
);
|
||||
const redeemConfirmationMessage = computed(() =>
|
||||
invitePreview.value
|
||||
? `确认加入「${invitePreview.value.genealogyName}」?如果这部家谱需要审核,我们会先提交申请。`
|
||||
: "请先查看家谱信息。",
|
||||
);
|
||||
const resetInvitePreview = () => {
|
||||
if (inviteState.value === "previewing") invitationPreviewController.abort();
|
||||
invitePreview.value = null;
|
||||
inviteResult.value = null;
|
||||
inviteError.value = "";
|
||||
redeemConfirmationVisible.value = false;
|
||||
if (inviteState.value !== "previewing" && inviteState.value !== "redeeming")
|
||||
inviteState.value = "idle";
|
||||
};
|
||||
const previewInvite = async () => {
|
||||
if (!inviteToken.value || inviteState.value === "previewing") return;
|
||||
const token = inviteToken.value;
|
||||
invitationPreviewController.abort();
|
||||
inviteState.value = "previewing";
|
||||
inviteError.value = "";
|
||||
invitePreview.value = null;
|
||||
inviteResult.value = null;
|
||||
try {
|
||||
const invitationPreview = await genealogyMembershipApi.previewGenealogyInvitation(token, {
|
||||
requestController: invitationPreviewController,
|
||||
});
|
||||
if (!isPageActive || inviteToken.value !== token) return;
|
||||
invitePreview.value = invitationPreview;
|
||||
inviteState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!isPageActive || isRequestCancelled(error)) return;
|
||||
inviteState.value = "idle";
|
||||
inviteError.value = getRequestErrorMessage(error, "邀请码暂时无法查看,请检查后重试。");
|
||||
}
|
||||
};
|
||||
const openRedeemConfirmation = () => {
|
||||
if (inviteState.value === "ready" && invitePreview.value)
|
||||
redeemConfirmationVisible.value = true;
|
||||
};
|
||||
const closeRedeemConfirmation = () => {
|
||||
if (inviteState.value !== "redeeming") redeemConfirmationVisible.value = false;
|
||||
};
|
||||
const redeemInvite = async () => {
|
||||
if (inviteState.value !== "ready" || !invitePreview.value) return;
|
||||
const redemptionPayload = { token: inviteToken.value };
|
||||
const redemptionAttempt = invitationRedemptionGuard.begin(redemptionPayload);
|
||||
if (redemptionAttempt === null) {
|
||||
inviteError.value =
|
||||
"上次兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
|
||||
return;
|
||||
}
|
||||
inviteState.value = "redeeming";
|
||||
inviteError.value = "";
|
||||
try {
|
||||
const redemptionResult = await genealogyMembershipApi.redeemGenealogyInvitation(
|
||||
redemptionPayload,
|
||||
{ requestController: invitationRedemptionController },
|
||||
);
|
||||
if (!isPageActive) return;
|
||||
inviteResult.value = redemptionResult;
|
||||
invitePreview.value = null;
|
||||
inviteToken.value = "";
|
||||
inviteState.value = "success";
|
||||
redeemConfirmationVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!isPageActive) return;
|
||||
inviteState.value = "ready";
|
||||
redeemConfirmationVisible.value = false;
|
||||
if (invitationRedemptionGuard.recordFailure(redemptionAttempt, error)) {
|
||||
inviteError.value =
|
||||
"兑换结果暂时无法确认,请先返回“我的家谱”检查,避免重复兑换。";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
inviteError.value = getRequestErrorMessage(error, "加入未完成,请重新查看邀请码信息。");
|
||||
}
|
||||
};
|
||||
const handleInviteResult = () =>
|
||||
inviteResult.value?.redemptionResult === "DIRECT_MEMBER"
|
||||
? returnTo("G01")
|
||||
: openPage("G09", {}, "G06");
|
||||
onLoad(loadGenealogies);
|
||||
onShow(() => {
|
||||
if (genealogySearchState.value !== "loading") loadGenealogies();
|
||||
});
|
||||
onUnload(() => {
|
||||
isPageActive = false;
|
||||
publicGenealogyListController.abort();
|
||||
invitationPreviewController.abort();
|
||||
invitationRedemptionController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
.search-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-content {
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.search-note,
|
||||
.state-card,
|
||||
.genealogy-card,
|
||||
.invite-card {
|
||||
@include adaptive-genealogy-state-panel;
|
||||
}
|
||||
.invite-card {
|
||||
padding: 28rpx 30rpx;
|
||||
}
|
||||
.invite-card__title,
|
||||
.invite-card__copy,
|
||||
.invite-card__error,
|
||||
.invite-preview text,
|
||||
.invite-result text {
|
||||
display: block;
|
||||
}
|
||||
.invite-card__title {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(18px, 32rpx, 23px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.invite-card__copy,
|
||||
.invite-preview text,
|
||||
.invite-result text {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.invite-card__input {
|
||||
@include adaptive-genealogy-form-field;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 78rpx;
|
||||
margin-top: 18rpx;
|
||||
padding: 0 22rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
}
|
||||
.invite-card__error {
|
||||
margin-top: 12rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invite-card > .app-button,
|
||||
.invite-preview > .app-button,
|
||||
.invite-result > .app-button {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.invite-preview,
|
||||
.invite-result {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1rpx solid rgba(128, 89, 49, 0.24);
|
||||
}
|
||||
.invite-result text:first-child {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note {
|
||||
display: flex;
|
||||
min-height: 112rpx;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 20rpx 30rpx;
|
||||
}
|
||||
.search-note text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 30rpx, 22px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.search-note text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.state-card {
|
||||
display: flex;
|
||||
min-height: 310rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 18rpx;
|
||||
padding: 42rpx;
|
||||
text-align: center;
|
||||
color: $ink;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
}
|
||||
.state-card .app-button {
|
||||
width: 100%;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
.genealogy-card {
|
||||
padding: 28rpx 32rpx;
|
||||
}
|
||||
.card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
}
|
||||
.card-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: clamp(17px, 31rpx, 22px);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-heading text:last-child {
|
||||
flex: 0 0 auto;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.card-meta,
|
||||
.card-copy {
|
||||
display: block;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-meta {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.card-copy {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
margin-top: 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(13px, 21rpx, 16px);
|
||||
}
|
||||
.card-footer .app-button {
|
||||
min-width: 180rpx;
|
||||
}
|
||||
@media (max-width: 340px) {
|
||||
.page-content {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.card-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-footer .app-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:G-11;用途:按 GenealogyUpdateBody 更新当前家谱。 -->
|
||||
<template>
|
||||
<view class="settings-page" :class="`settings-state--${pageState}`">
|
||||
<GenealogyPageBackground />
|
||||
@@ -15,6 +14,10 @@
|
||||
|
||||
<view v-else-if="pageState === 'form'" class="settings-card">
|
||||
<text class="settings-card__title">编辑家谱信息</text>
|
||||
<view v-if="lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED" class="lifecycle-note">
|
||||
<text>这部家谱已归档</text>
|
||||
<text>归档期间可以查看内容,但不能修改。恢复后可继续编辑。</text>
|
||||
</view>
|
||||
<text class="settings-card__note"
|
||||
>带红色星号的内容不能为空;其余内容可按需要补充。</text
|
||||
>
|
||||
@@ -51,7 +54,12 @@
|
||||
fieldErrors.genealogyName
|
||||
}}</text>
|
||||
|
||||
<view class="field-row field-row--selector" @click="openRegionPicker">
|
||||
<view
|
||||
class="field-row field-row--selector"
|
||||
role="button"
|
||||
aria-label="选择所在地区"
|
||||
@click="openRegionPicker"
|
||||
>
|
||||
<text class="field-row__label"
|
||||
><text class="required-mark">*</text>所在地区</text
|
||||
>
|
||||
@@ -65,6 +73,12 @@
|
||||
: regionPickerError || "请选择所在地区")
|
||||
}}</text
|
||||
>
|
||||
<image
|
||||
class="region-selector-chevron"
|
||||
src="/static/assets/foundation/transparent/chevron-right.png"
|
||||
mode="aspectFit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="fieldErrors.regionCode" class="field-error">{{
|
||||
fieldErrors.regionCode
|
||||
@@ -114,8 +128,8 @@
|
||||
<text class="cover-field__label">封面图片</text>
|
||||
<text class="cover-field__hint">{{
|
||||
coverOssId
|
||||
? "当前已关联封面;重新选择后会以新的真实上传回执更新。"
|
||||
: "选择图片后会取得真实上传回执,并在保存时关联。"
|
||||
? "当前已有封面;重新选择后会用新图片替换。"
|
||||
: "图片上传成功后,会作为家谱封面保存。"
|
||||
}}</text>
|
||||
</view>
|
||||
<button
|
||||
@@ -133,7 +147,11 @@
|
||||
|
||||
<view class="access-rule">
|
||||
<text class="access-rule__label">访问规则</text>
|
||||
<view class="access-rule__options">
|
||||
<view
|
||||
class="access-rule__options"
|
||||
role="radiogroup"
|
||||
aria-label="家谱访问规则"
|
||||
>
|
||||
<view
|
||||
v-for="option in GENEALOGY_ACCESS_PRESET_OPTIONS"
|
||||
:key="option.value"
|
||||
@@ -142,6 +160,9 @@
|
||||
'access-rule__option--active':
|
||||
form.accessPreset === option.value,
|
||||
}"
|
||||
role="radio"
|
||||
:aria-checked="form.accessPreset === option.value"
|
||||
:aria-label="option.label"
|
||||
@click="form.accessPreset = option.value"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
@@ -151,10 +172,20 @@
|
||||
<text v-if="submitError" class="submit-error">{{ submitError }}</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="isSubmitting || isUploading"
|
||||
:disabled="isSubmitting || isUploading || lifecycleStatus === GENEALOGY_LIFECYCLE_STATUS.ARCHIVED"
|
||||
:label="isSubmitting ? '正在保存…' : '保存设置'"
|
||||
@click="submitUpdate"
|
||||
/>
|
||||
<view v-if="canArchive || canRestore" class="lifecycle-actions">
|
||||
<text>{{ canRestore ? "需要继续维护这部家谱时,可以恢复编辑。" : "暂时不再维护时,可以归档;内容仍可查看。" }}</text>
|
||||
<AppButton
|
||||
block
|
||||
type="secondary"
|
||||
:disabled="lifecycleSubmitting"
|
||||
:label="lifecycleSubmitting ? '正在处理' : canRestore ? '恢复家谱' : '归档家谱'"
|
||||
@click="requestLifecycleChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-card">
|
||||
@@ -163,55 +194,26 @@
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="lifecycleDialogVisible"
|
||||
eyebrow="家谱状态"
|
||||
:title="canRestore ? '恢复这部家谱?' : '归档这部家谱?'"
|
||||
:message="canRestore ? '恢复后可继续修改家谱内容。' : '归档后内容仍可查看,但暂时不能修改。'"
|
||||
:confirm-text="lifecycleSubmitting ? '正在处理' : canRestore ? '确认恢复' : '确认归档'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmLifecycleChange"
|
||||
@cancel="lifecycleDialogVisible = false"
|
||||
/>
|
||||
|
||||
<view v-if="regionPickerOpen" class="region-sheet">
|
||||
<view class="region-sheet__mask" @click="closeRegionPicker" />
|
||||
<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 ['省份', '城市', '区县']"
|
||||
:key="label"
|
||||
class="region-sheet__column-heading"
|
||||
>{{ label }}</text
|
||||
>
|
||||
</view>
|
||||
<picker-view
|
||||
class="region-sheet__picker-view"
|
||||
:indicator-style="regionPickerIndicatorStyle"
|
||||
:value="regionPickerIndexes"
|
||||
@change="handleRegionPickerChange"
|
||||
>
|
||||
<picker-view-column
|
||||
v-for="(column, columnIndex) in regionPickerColumns"
|
||||
:key="columnIndex"
|
||||
>
|
||||
<view
|
||||
v-for="(option, optionIndex) in column"
|
||||
:key="option.regionCode"
|
||||
class="region-sheet__picker-item"
|
||||
:class="{
|
||||
'region-sheet__picker-item--selected':
|
||||
regionPickerIndexes[columnIndex] === optionIndex,
|
||||
}"
|
||||
>{{ option.label }}</view
|
||||
>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
</view>
|
||||
<view class="region-sheet__footer">
|
||||
<view class="region-sheet__cancel" @click="closeRegionPicker"
|
||||
>取消</view
|
||||
>
|
||||
<button class="region-sheet__confirm" @click="confirmRegionSelection">
|
||||
确认选择
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<RegionPickerDialog
|
||||
ref="regionPickerDialog"
|
||||
close-on-mask
|
||||
@select="selectRegion"
|
||||
@loading-change="regionLoading = $event"
|
||||
@error-change="regionPickerError = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -219,24 +221,31 @@
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import GenealogyPageBackground from "@/components/GenealogyPageBackground.vue";
|
||||
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
|
||||
import RegionPickerDialog from "@/components/genealogy/RegionPickerDialog.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
GENEALOGY_LIFECYCLE_STATUS
|
||||
} from "@/services/api/genealogy-contract.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyApi } from "@/services/api/genealogy-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
GENEALOGY_ACCESS_PRESET,
|
||||
GENEALOGY_ACCESS_PRESET_OPTIONS,
|
||||
isGenealogyAccessPreset,
|
||||
toApiGenealogyAccess,
|
||||
} from "@/utils/genealogy-contracts.js";
|
||||
} from "@/utils/genealogy/access-policy.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const pageState = ref("loading");
|
||||
@@ -260,38 +269,44 @@ const isUploading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const coverOssId = ref(null);
|
||||
const coverFileName = ref("");
|
||||
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
|
||||
const canArchive = ref(false);
|
||||
const canRestore = ref(false);
|
||||
const lifecycleDialogVisible = ref(false);
|
||||
const lifecycleSubmitting = ref(false);
|
||||
const currentRegionCode = ref("");
|
||||
const currentRegionDisplay = ref("");
|
||||
const selectedRegion = ref(null);
|
||||
const regionPickerTrail = ref([]);
|
||||
const regionPickerColumns = ref([]);
|
||||
const regionPickerIndexes = ref([0]);
|
||||
const regionPickerDialog = ref(null);
|
||||
const regionPickerError = ref("");
|
||||
const regionLoading = ref(false);
|
||||
const regionPickerOpen = ref(false);
|
||||
const regionPickerIndicatorStyle =
|
||||
"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 controller = createRequestController();
|
||||
const settingsReadRequestController = createRequestController();
|
||||
const genealogyLifecycleRequestController = createRequestController();
|
||||
const coverUploadRequestController = createRequestController();
|
||||
const settingsSaveRequestController = createRequestController();
|
||||
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
|
||||
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
|
||||
let pageActive = true;
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const regionDisplay = computed(() =>
|
||||
regionPickerTrail.value.length
|
||||
? regionPickerTrail.value.map((item) => item.label).join(" / ")
|
||||
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
|
||||
: currentRegionDisplay.value,
|
||||
);
|
||||
const stateCopy = computed(() => {
|
||||
if (pageState.value === "success") {
|
||||
return {
|
||||
title: "家谱设置已保存",
|
||||
copy: "服务端已返回成功结果。",
|
||||
copy: "家谱设置已保存。",
|
||||
action: "返回家谱总览",
|
||||
};
|
||||
}
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "家谱入口无效",
|
||||
copy: "没有取得有效家谱标识。",
|
||||
title: "暂时无法打开家谱设置",
|
||||
copy: "未找到家谱信息,请返回后重新进入。",
|
||||
action: "返回上一页",
|
||||
};
|
||||
}
|
||||
@@ -320,89 +335,16 @@ const validate = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const fetchRegionChildren = async (parentCode) => {
|
||||
regionLoading.value = true;
|
||||
regionPickerError.value = "";
|
||||
try {
|
||||
return await appApi.getRegionChildren(parentCode, {
|
||||
requestController: controller,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
regionPickerError.value = error?.message || "地区列表加载失败,请重试";
|
||||
return [];
|
||||
} finally {
|
||||
regionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadPickerColumns = async (provinceIndex = 0, cityIndex = 0) => {
|
||||
const provinces =
|
||||
regionPickerColumns.value[0] || (await fetchRegionChildren("0"));
|
||||
const province = provinces[provinceIndex];
|
||||
if (!province) return;
|
||||
const cities = await fetchRegionChildren(province.regionCode);
|
||||
const city = cities[cityIndex];
|
||||
if (!city) {
|
||||
regionPickerColumns.value = [provinces];
|
||||
regionPickerIndexes.value = [provinceIndex];
|
||||
return;
|
||||
}
|
||||
const districts = await fetchRegionChildren(city.regionCode);
|
||||
if (!districts.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [provinces, cities, districts];
|
||||
regionPickerIndexes.value = [provinceIndex, cityIndex, 0];
|
||||
};
|
||||
|
||||
const loadRegionRoot = async () => {
|
||||
if (regionLoading.value) return;
|
||||
const roots = await fetchRegionChildren("0");
|
||||
if (!roots.length || regionPickerError.value) return;
|
||||
regionPickerColumns.value = [roots];
|
||||
regionPickerIndexes.value = [0];
|
||||
await loadPickerColumns();
|
||||
};
|
||||
|
||||
const handleRegionPickerChange = async (event) => {
|
||||
if (regionLoading.value) return;
|
||||
const nextIndexes = (event?.detail?.value || []).map(
|
||||
(index) => Number(index) || 0,
|
||||
);
|
||||
const indexes = regionPickerIndexes.value;
|
||||
const provinceIndex = nextIndexes[0] || 0;
|
||||
const cityIndex = nextIndexes[1] || 0;
|
||||
const districtIndex = nextIndexes[2] || 0;
|
||||
if (provinceIndex !== (indexes[0] || 0)) {
|
||||
await loadPickerColumns(provinceIndex, 0);
|
||||
return;
|
||||
}
|
||||
if (cityIndex !== (indexes[1] || 0)) {
|
||||
await loadPickerColumns(indexes[0] || 0, cityIndex);
|
||||
return;
|
||||
}
|
||||
regionPickerIndexes.value = [indexes[0] || 0, indexes[1] || 0, districtIndex];
|
||||
};
|
||||
|
||||
const openRegionPicker = async () => {
|
||||
if (isSubmitting.value || isUploading.value || regionLoading.value) return;
|
||||
if (!regionPickerColumns.value.length) await loadRegionRoot();
|
||||
if (regionPickerColumns.value.length) regionPickerOpen.value = true;
|
||||
await regionPickerDialog.value?.open(currentRegionCode.value);
|
||||
};
|
||||
const closeRegionPicker = () => {
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
const confirmRegionSelection = () => {
|
||||
const trail = regionPickerColumns.value
|
||||
.map((column, index) => column[Number(regionPickerIndexes.value[index])])
|
||||
.filter(Boolean);
|
||||
const region = trail[trail.length - 1];
|
||||
if (!region) return;
|
||||
const selectRegion = ({ region, trail }) => {
|
||||
selectedRegion.value = region;
|
||||
currentRegionCode.value = region.regionCode;
|
||||
regionPickerTrail.value = trail;
|
||||
fieldErrors.regionCode = "";
|
||||
regionPickerError.value = "";
|
||||
regionPickerOpen.value = false;
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
@@ -412,10 +354,14 @@ const loadSettings = async () => {
|
||||
}
|
||||
pageState.value = "loading";
|
||||
try {
|
||||
const settings = await appApi.getGenealogySettings(genealogyId.value, {
|
||||
requestController: controller,
|
||||
const settings = await genealogyApi.getGenealogySettings(genealogyId.value, {
|
||||
requestController: settingsReadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
if (!isGenealogyAccessPreset(settings.accessPreset)) {
|
||||
pageState.value = "error";
|
||||
return;
|
||||
}
|
||||
Object.assign(form, {
|
||||
surname: settings.surname,
|
||||
genealogyName: settings.genealogyName,
|
||||
@@ -423,35 +369,69 @@ const loadSettings = async () => {
|
||||
originPlace: settings.originPlace,
|
||||
addressDetail: settings.addressDetail,
|
||||
intro: settings.intro,
|
||||
accessPreset:
|
||||
settings.accessPreset || GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
accessPreset: settings.accessPreset,
|
||||
});
|
||||
currentRegionCode.value = settings.regionCode;
|
||||
currentRegionDisplay.value = settings.regionFullName || settings.regionName;
|
||||
coverOssId.value = settings.coverOssId;
|
||||
coverOssId.value = settings.coverFile?.ossId ?? null;
|
||||
coverFileName.value = settings.coverFile?.fileName || "";
|
||||
lifecycleStatus.value = settings.lifecycleStatus;
|
||||
canArchive.value = settings.canArchive;
|
||||
canRestore.value = settings.canRestore;
|
||||
pageState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
pageState.value = "error";
|
||||
}
|
||||
};
|
||||
const requestLifecycleChange = () => {
|
||||
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
|
||||
submitError.value = "";
|
||||
lifecycleDialogVisible.value = true;
|
||||
};
|
||||
const confirmLifecycleChange = async () => {
|
||||
if ((!canArchive.value && !canRestore.value) || lifecycleSubmitting.value) return;
|
||||
lifecycleSubmitting.value = true;
|
||||
try {
|
||||
const lifecycleResult = canRestore.value
|
||||
? await genealogyApi.restoreGenealogy(genealogyId.value, {
|
||||
requestController: genealogyLifecycleRequestController,
|
||||
})
|
||||
: await genealogyApi.archiveGenealogy(genealogyId.value, {
|
||||
requestController: genealogyLifecycleRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
|
||||
canArchive.value = lifecycleResult.canArchive;
|
||||
canRestore.value = lifecycleResult.canRestore;
|
||||
lifecycleDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = getRequestErrorMessage(error, canRestore.value ? "家谱恢复失败,请稍后重试。" : "家谱归档失败,请稍后重试。");
|
||||
lifecycleDialogVisible.value = false;
|
||||
} finally {
|
||||
if (pageActive) lifecycleSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
if (isUploading.value || isSubmitting.value) return;
|
||||
isUploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({ requestController: controller });
|
||||
const coverUpload = await pickAndUploadImage({
|
||||
requestController: coverUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
coverOssId.value = receipt.ossId;
|
||||
coverFileName.value = receipt.fileName || "封面图片";
|
||||
coverOssId.value = coverUpload.ossId;
|
||||
coverFileName.value = coverUpload.fileName || "封面图片";
|
||||
} catch (error) {
|
||||
if (
|
||||
pageActive &&
|
||||
!isImagePickCancelled(error) &&
|
||||
!isRequestCancelled(error)
|
||||
) {
|
||||
uploadError.value = error?.message || "封面图片上传失败,请稍后重试";
|
||||
uploadError.value = getRequestErrorMessage(error, "封面图片上传失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) isUploading.value = false;
|
||||
@@ -462,13 +442,13 @@ const submitUpdate = async () => {
|
||||
if (isSubmitting.value || isUploading.value || !validate()) return;
|
||||
const access = toApiGenealogyAccess(form.accessPreset);
|
||||
if (!access) {
|
||||
submitError.value = "访问规则无效,请重新选择";
|
||||
submitError.value = "请选择家谱开放方式";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.updateGenealogy(
|
||||
await genealogyApi.updateGenealogy(
|
||||
genealogyId.value,
|
||||
{
|
||||
surname: form.surname,
|
||||
@@ -481,13 +461,13 @@ const submitUpdate = async () => {
|
||||
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
|
||||
...access,
|
||||
},
|
||||
{ requestController: controller },
|
||||
{ requestController: settingsSaveRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
pageState.value = "success";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
submitError.value = error?.message || "家谱设置保存失败,请稍后重试";
|
||||
submitError.value = getRequestErrorMessage(error, "家谱设置保存失败,请稍后重试");
|
||||
} finally {
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
@@ -508,7 +488,10 @@ onMounted(() => {
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
controller.abort();
|
||||
settingsReadRequestController.abort();
|
||||
genealogyLifecycleRequestController.abort();
|
||||
coverUploadRequestController.abort();
|
||||
settingsSaveRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -600,6 +583,25 @@ onUnload(() => {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.lifecycle-note,
|
||||
.lifecycle-actions {
|
||||
margin-top: 20rpx;
|
||||
padding: 20rpx 22rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.26);
|
||||
border-radius: 10rpx;
|
||||
background: rgba(159, 23, 15, 0.05);
|
||||
}
|
||||
.lifecycle-note text,
|
||||
.lifecycle-actions text { display: block; color: $ink-muted; font-size: clamp(14px, 22rpx, 17px); line-height: 1.55; }
|
||||
.lifecycle-note text:first-child { color: $brand-red; font-weight: 700; }
|
||||
.lifecycle-actions .app-button { margin-top: 18rpx; }
|
||||
.region-selector-chevron {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 12rpx;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.region-selector-value--placeholder,
|
||||
.placeholder {
|
||||
color: #ab9a86;
|
||||
@@ -642,7 +644,7 @@ onUnload(() => {
|
||||
}
|
||||
.upload-button {
|
||||
justify-self: start;
|
||||
min-height: 60rpx;
|
||||
min-height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0 20rpx;
|
||||
border: 1rpx solid rgba(159, 23, 15, 0.42);
|
||||
@@ -679,6 +681,11 @@ onUnload(() => {
|
||||
}
|
||||
.access-rule__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20rpx 12rpx;
|
||||
border: 1rpx solid rgba(128, 89, 49, 0.34);
|
||||
border-radius: 12rpx;
|
||||
@@ -718,103 +725,4 @@ onUnload(() => {
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.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 {
|
||||
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 {
|
||||
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;
|
||||
padding: 0 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(16px, 26rpx, 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 {
|
||||
flex: 1;
|
||||
height: 82rpx;
|
||||
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: 82rpx;
|
||||
}
|
||||
.region-sheet__confirm::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user