feat: 完成前端业务闭环与后端联调
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user