feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+258
View File
@@ -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>
-232
View File
@@ -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>
-116
View File
@@ -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>
-128
View File
@@ -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;
+728
View File
@@ -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>
+265
View File
@@ -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;
+433
View File
@@ -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>