feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+283 -5
View File
@@ -2,7 +2,7 @@
<view class="settings-page" :class="`settings-state--${pageState}`">
<GenealogyPageBackground />
<view class="page-header"
><PageHeader title="家谱设置" custom-back @back="returnToOverview"
><PageHeader title="家谱设置" custom-back @back="requestBack"
/></view>
<view class="page-content">
@@ -142,6 +142,7 @@
<text v-if="coverFileName" class="upload-receipt"
>已上传{{ coverFileName }}</text
>
<button v-if="coverOssId" class="remove-cover-button" :disabled="isUploading || isSubmitting" @click="clearCover">移除封面</button>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
@@ -186,6 +187,24 @@
@click="requestLifecycleChange"
/>
</view>
<view
v-if="canDeletePermanently || permanentDeletionDisabledReason"
class="permanent-deletion"
>
<text>永久删除家谱</text>
<text>{{
canDeletePermanently
? "该操作会永久删除家谱及其业务内容,无法从回收站恢复。"
: permanentDeletionDisabledReason
}}</text>
<AppButton
block
type="secondary"
:disabled="!canDeletePermanently || permanentDeletionSubmitting"
label="永久删除家谱"
@click="openPermanentDeletion"
/>
</view>
</view>
<view v-else class="state-card">
@@ -206,6 +225,54 @@
@confirm="confirmLifecycleChange"
@cancel="lifecycleDialogVisible = false"
/>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃家谱设置修改?"
message="当前修改还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="permanentDeletionVisible"
eyebrow="高风险操作"
title="永久删除这部家谱?"
:message="`请输入完整谱名“${form.genealogyName}”,并通过账号短信验证。删除后无法恢复。`"
:confirm-text="permanentDeletionSubmitting ? '正在删除' : '确认永久删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmPermanentDeletion"
@cancel="closePermanentDeletion"
>
<input
v-model="permanentDeletionConfirmationName"
class="deletion-dialog-input"
maxlength="24"
:placeholder="`输入谱名:${form.genealogyName}`"
/>
<view class="deletion-code-row">
<input
v-model.trim="permanentDeletionSmsCode"
class="deletion-dialog-input"
type="number"
maxlength="4"
:placeholder="`验证码将发送至 ${deletionVerificationPhoneMasked}`"
/>
<AppButton
compact
type="secondary"
:disabled="permanentDeletionSubmitting || deletionCodeSending || deletionCodeCooldown > 0"
:label="deletionCodeButtonLabel"
@click="sendPermanentDeletionCode"
/>
</view>
<text v-if="permanentDeletionError" class="submit-error">{{ permanentDeletionError }}</text>
</AppDialog>
<RegionPickerDialog
ref="regionPickerDialog"
@@ -213,13 +280,14 @@
@select="selectRegion"
@loading-change="regionLoading = $event"
@error-change="regionPickerError = $event"
@transient-change="regionPickerVisible = $event"
/>
</view>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
@@ -245,7 +313,10 @@ import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const pageState = ref("loading");
@@ -272,8 +343,18 @@ const coverFileName = ref("");
const lifecycleStatus = ref(GENEALOGY_LIFECYCLE_STATUS.NORMAL);
const canArchive = ref(false);
const canRestore = ref(false);
const canDeletePermanently = ref(false);
const permanentDeletionDisabledReason = ref("");
const deletionVerificationPhoneMasked = ref("");
const lifecycleDialogVisible = ref(false);
const lifecycleSubmitting = ref(false);
const permanentDeletionVisible = ref(false);
const permanentDeletionConfirmationName = ref("");
const permanentDeletionSmsCode = ref("");
const permanentDeletionError = ref("");
const permanentDeletionSubmitting = ref(false);
const deletionCodeSending = ref(false);
const deletionCodeCooldown = ref(0);
const currentRegionCode = ref("");
const currentRegionDisplay = ref("");
const selectedRegion = ref(null);
@@ -281,13 +362,28 @@ const regionPickerTrail = ref([]);
const regionPickerDialog = ref(null);
const regionPickerError = ref("");
const regionLoading = ref(false);
const regionPickerVisible = ref(false);
const settingsBaseline = ref("");
const discardVisible = ref(false);
const settingsReadRequestController = createRequestController();
const deletionCapabilityReadRequestController = createRequestController();
const genealogyLifecycleRequestController = createRequestController();
const deletionCodeRequestController = createRequestController();
const permanentDeletionRequestController = createRequestController();
const coverUploadRequestController = createRequestController();
const settingsSaveRequestController = createRequestController();
// 封面上传、归档和设置保存属于独立操作。分别持有取消槽,避免后发操作
// 取消前一条仍在收敛的请求,留下无法解释的加载状态。
let pageActive = true;
let deletionCodeTimer = null;
const permanentDeletionGuard = createNonIdempotentWriteGuard();
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const regionDisplay = computed(() =>
@@ -295,6 +391,23 @@ const regionDisplay = computed(() =>
? regionPickerTrail.value.map((regionNode) => regionNode.label).join(" / ")
: currentRegionDisplay.value,
);
const settingsSnapshot = computed(() => JSON.stringify({
...form,
regionCode: selectedRegion.value?.regionCode || currentRegionCode.value,
coverOssId: coverOssId.value || null,
}));
const isDirty = computed(() =>
pageState.value === "form" &&
Boolean(settingsBaseline.value) &&
settingsSnapshot.value !== settingsBaseline.value,
);
const deletionCodeButtonLabel = computed(() =>
deletionCodeSending.value
? "正在发送"
: deletionCodeCooldown.value > 0
? `${deletionCodeCooldown.value}s 后重发`
: "发送验证码",
);
const stateCopy = computed(() => {
if (pageState.value === "success") {
return {
@@ -379,6 +492,22 @@ const loadSettings = async () => {
canArchive.value = settings.canArchive;
canRestore.value = settings.canRestore;
pageState.value = "form";
settingsBaseline.value = settingsSnapshot.value;
try {
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: deletionCapabilityReadRequestController },
);
if (!pageActive) return;
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
canDeletePermanently.value = false;
permanentDeletionDisabledReason.value = "永久注销资格暂时无法读取,请稍后重试。";
deletionVerificationPhoneMasked.value = "";
}
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
pageState.value = "error";
@@ -404,6 +533,13 @@ const confirmLifecycleChange = async () => {
lifecycleStatus.value = lifecycleResult.lifecycleStatus;
canArchive.value = lifecycleResult.canArchive;
canRestore.value = lifecycleResult.canRestore;
const deletionCapability = await genealogyApi.getPermanentDeletionCapability(
genealogyId.value,
{ requestController: genealogyLifecycleRequestController },
);
canDeletePermanently.value = deletionCapability.canDeletePermanently;
permanentDeletionDisabledReason.value = deletionCapability.disabledReasons.join("");
deletionVerificationPhoneMasked.value = deletionCapability.phoneMasked;
lifecycleDialogVisible.value = false;
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -414,6 +550,92 @@ const confirmLifecycleChange = async () => {
}
};
const stopDeletionCodeCooldown = () => {
if (deletionCodeTimer) clearInterval(deletionCodeTimer);
deletionCodeTimer = null;
};
const startDeletionCodeCooldown = (seconds) => {
stopDeletionCodeCooldown();
deletionCodeCooldown.value = seconds;
deletionCodeTimer = setInterval(() => {
deletionCodeCooldown.value = Math.max(0, deletionCodeCooldown.value - 1);
if (deletionCodeCooldown.value === 0) stopDeletionCodeCooldown();
}, 1000);
};
const openPermanentDeletion = () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
permanentDeletionConfirmationName.value = "";
permanentDeletionSmsCode.value = "";
permanentDeletionError.value = "";
permanentDeletionVisible.value = true;
};
const closePermanentDeletion = () => {
if (permanentDeletionSubmitting.value || deletionCodeSending.value) return;
permanentDeletionVisible.value = false;
permanentDeletionError.value = "";
};
const sendPermanentDeletionCode = async () => {
if (!canDeletePermanently.value || deletionCodeSending.value || permanentDeletionSubmitting.value || deletionCodeCooldown.value > 0) return;
deletionCodeSending.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.sendPermanentDeletionCode(
genealogyId.value,
{ requestController: deletionCodeRequestController },
);
if (!pageActive || !permanentDeletionVisible.value) return;
startDeletionCodeCooldown(60);
} catch (error) {
if (pageActive && permanentDeletionVisible.value && !isRequestCancelled(error)) {
permanentDeletionError.value = getRequestErrorMessage(error, "删除验证码发送失败,请稍后重试。");
}
} finally {
if (pageActive) deletionCodeSending.value = false;
}
};
const confirmPermanentDeletion = async () => {
if (!canDeletePermanently.value || permanentDeletionSubmitting.value) return;
if (permanentDeletionConfirmationName.value.trim() !== form.genealogyName.trim()) {
permanentDeletionError.value = "输入的谱名与当前家谱不一致。";
return;
}
if (!/^\d{4}$/.test(permanentDeletionSmsCode.value)) {
permanentDeletionError.value = "请输入4位短信验证码。";
return;
}
const payload = {
confirmationName: permanentDeletionConfirmationName.value,
smsCode: permanentDeletionSmsCode.value,
};
const deletionAttempt = permanentDeletionGuard.begin(payload);
if (deletionAttempt === null) {
permanentDeletionError.value = "上次删除结果待确认,请先返回家谱列表刷新,不要重复提交。";
return;
}
permanentDeletionSubmitting.value = true;
permanentDeletionError.value = "";
try {
await genealogyApi.deleteGenealogyPermanently(
genealogyId.value,
payload,
{ requestController: permanentDeletionRequestController },
);
if (!pageActive) return;
genealogyContext.invalidateCurrentGenealogyId();
permanentDeletionVisible.value = false;
await returnTo("G01");
} catch (error) {
const isOutcomeUnknown = permanentDeletionGuard.recordFailure(deletionAttempt, error);
if (!pageActive) return;
permanentDeletionError.value = isOutcomeUnknown
? "删除结果待确认,请返回家谱列表刷新,不要重复提交。"
: getRequestErrorMessage(error, "永久删除失败,请核对验证码后重试。");
if (!isOutcomeUnknown && isRequestCancelled(error)) permanentDeletionError.value = "";
} finally {
if (pageActive) permanentDeletionSubmitting.value = false;
}
};
const uploadCover = async () => {
if (isUploading.value || isSubmitting.value) return;
isUploading.value = true;
@@ -438,6 +660,13 @@ const uploadCover = async () => {
}
};
const clearCover = () => {
if (isUploading.value || isSubmitting.value) return;
coverOssId.value = null;
coverFileName.value = "";
uploadError.value = "";
};
const submitUpdate = async () => {
if (isSubmitting.value || isUploading.value || !validate()) return;
const access = toApiGenealogyAccess(form.accessPreset);
@@ -458,7 +687,7 @@ const submitUpdate = async () => {
originPlace: form.originPlace,
addressDetail: form.addressDetail,
intro: form.intro,
...(coverOssId.value ? { coverOssId: coverOssId.value } : {}),
coverOssId: coverOssId.value,
...access,
},
{ requestController: settingsSaveRequestController },
@@ -477,6 +706,27 @@ const returnToOverview = () =>
hasValidContext.value
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const requestBack = async () => {
if (regionPickerVisible.value) {
regionPickerDialog.value?.close();
return true;
}
if (lifecycleDialogVisible.value) {
lifecycleDialogVisible.value = false;
return true;
}
if (permanentDeletionVisible.value) {
closePermanentDeletion();
return true;
}
if (discardVisible.value) {
cancelDiscard();
return true;
}
if (isSubmitting.value || isUploading.value || lifecycleSubmitting.value || permanentDeletionSubmitting.value || deletionCodeSending.value) return true;
if (isDirty.value && !(await requestDiscardConfirmation())) return false;
return returnToOverview();
};
const handleStateAction = () =>
pageState.value === "success" ? returnToOverview() : loadSettings();
@@ -486,12 +736,18 @@ onLoad((query) => {
onMounted(() => {
void loadSettings();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
settingsReadRequestController.abort();
deletionCapabilityReadRequestController.abort();
genealogyLifecycleRequestController.abort();
deletionCodeRequestController.abort();
permanentDeletionRequestController.abort();
coverUploadRequestController.abort();
settingsSaveRequestController.abort();
stopDeletionCodeCooldown();
discardConfirmation.dispose();
});
</script>
@@ -510,7 +766,7 @@ onUnload(() => {
}
.page-content {
flex: 1;
padding: 24rpx 32rpx 72rpx;
padding: 24rpx 32rpx calc(72rpx + env(safe-area-inset-bottom));
}
.settings-card,
.state-card {
@@ -567,6 +823,7 @@ onUnload(() => {
}
.field-row input {
min-width: 0;
min-height: var(--app-touch-min);
flex: 1;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
@@ -583,6 +840,15 @@ onUnload(() => {
text-align: right;
overflow-wrap: anywhere;
}
.permanent-deletion { margin-top: 30rpx; padding: 24rpx; border: 1rpx solid rgba(158, 37, 27, .34); border-radius: 10rpx; background: rgba(158, 37, 27, .05); }
.permanent-deletion > text { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.permanent-deletion > text:first-child { color: $brand-red; font-size: clamp(16px, 27rpx, 20px); font-weight: 700; }
.permanent-deletion > text + text { margin-top: 8rpx; }
.permanent-deletion .app-button { margin-top: 18rpx; }
.deletion-dialog-input { width: 100%; min-height: var(--app-touch-min); margin-top: 14rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(158, 37, 27, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .7); }
.deletion-code-row { display: flex; align-items: center; gap: 12rpx; }
.deletion-code-row .deletion-dialog-input { min-width: 0; flex: 1; }
.deletion-code-row .app-button { width: auto; flex: 0 0 auto; margin-top: 14rpx; }
.lifecycle-note,
.lifecycle-actions {
margin-top: 20rpx;
@@ -658,6 +924,18 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 1rpx solid rgba($brand-red, 0.38);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.field-error,
.submit-error {
margin-top: 10rpx;