282 lines
9.7 KiB
Vue
282 lines
9.7 KiB
Vue
<template>
|
||
<AppDialog
|
||
:visible="visible"
|
||
eyebrow="内容密码"
|
||
title="找回内容密码"
|
||
confirm-text="关闭"
|
||
:close-on-mask="!isBusy"
|
||
@confirm="requestClose"
|
||
@cancel="requestClose"
|
||
>
|
||
<view class="recovery-dialog">
|
||
<AppLoading v-if="capabilityState === 'loading'" text="正在核对找回方式" />
|
||
<template v-else-if="capabilityState === 'ready' && capability.enabled">
|
||
<text class="recovery-dialog__copy"
|
||
>验证码将发送至当前账号绑定手机号 {{ capability.maskedPhone }}。验证通过后可设置新的内容密码。</text
|
||
>
|
||
<view class="recovery-dialog__code-row">
|
||
<input
|
||
v-model.trim="smsCode"
|
||
type="number"
|
||
maxlength="4"
|
||
placeholder="4位短信验证码"
|
||
/>
|
||
<AppButton
|
||
compact
|
||
type="secondary"
|
||
:disabled="isBusy || cooldownSeconds > 0"
|
||
:label="codeButtonLabel"
|
||
@click="sendRecoveryCode"
|
||
/>
|
||
</view>
|
||
<input
|
||
v-model="newPassword"
|
||
password
|
||
maxlength="128"
|
||
placeholder="新的内容密码(8至128位)"
|
||
/>
|
||
<input
|
||
v-model="confirmedPassword"
|
||
password
|
||
maxlength="128"
|
||
placeholder="再次输入新的内容密码"
|
||
/>
|
||
<AppButton
|
||
block
|
||
:disabled="isBusy"
|
||
:label="resetting ? '正在重置' : '验证并重置密码'"
|
||
@click="resetContentPassword"
|
||
/>
|
||
</template>
|
||
<view v-else class="recovery-dialog__state">
|
||
<text>{{ capabilityError || capability.disabledReason || "当前内容暂不支持密码找回。" }}</text>
|
||
<AppButton
|
||
v-if="capabilityState === 'error'"
|
||
compact
|
||
type="secondary"
|
||
label="重试"
|
||
@click="loadCapability"
|
||
/>
|
||
</view>
|
||
<text v-if="formError" class="recovery-dialog__error" role="alert">{{ formError }}</text>
|
||
<text v-if="resultNotice" class="recovery-dialog__notice" role="status">{{ resultNotice }}</text>
|
||
<text class="recovery-dialog__security"
|
||
>服务端必须校验当前账号、资源查看权限和短信票据,并对发送与重置操作限流、审计。</text
|
||
>
|
||
</view>
|
||
</AppDialog>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||
import AppButton from "@/components/AppButton.vue";
|
||
import AppDialog from "@/components/AppDialog.vue";
|
||
import AppLoading from "@/components/AppLoading.vue";
|
||
import { contentPasswordRecoveryApi } from "@/services/api/content-password-recovery-service.js";
|
||
import {
|
||
createRequestController,
|
||
isRequestCancelled,
|
||
} from "@/services/api/request-controller.js";
|
||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||
|
||
const props = defineProps({
|
||
visible: Boolean,
|
||
genealogyId: { type: String, required: true },
|
||
resourceType: { type: String, required: true },
|
||
resourceId: { type: String, required: true },
|
||
});
|
||
const emit = defineEmits(["close", "complete", "busy-change"]);
|
||
|
||
const capabilityState = ref("idle");
|
||
const capability = reactive({
|
||
enabled: false,
|
||
maskedPhone: "",
|
||
disabledReason: "",
|
||
});
|
||
const capabilityError = ref("");
|
||
const formError = ref("");
|
||
const resultNotice = ref("");
|
||
const smsCode = ref("");
|
||
const newPassword = ref("");
|
||
const confirmedPassword = ref("");
|
||
const cooldownSeconds = ref(0);
|
||
const sendingCode = ref(false);
|
||
const resetting = ref(false);
|
||
const capabilityController = createRequestController();
|
||
const codeController = createRequestController();
|
||
const resetController = createRequestController();
|
||
const resetGuard = createNonIdempotentWriteGuard();
|
||
let cooldownTimer = null;
|
||
let componentActive = true;
|
||
|
||
const isBusy = computed(() => sendingCode.value || resetting.value);
|
||
const codeButtonLabel = computed(() =>
|
||
sendingCode.value
|
||
? "正在发送"
|
||
: cooldownSeconds.value > 0
|
||
? `${cooldownSeconds.value}s 后重发`
|
||
: "发送验证码",
|
||
);
|
||
|
||
watch(isBusy, (busy) => emit("busy-change", busy), { immediate: true });
|
||
|
||
const stopCooldown = () => {
|
||
if (cooldownTimer) clearInterval(cooldownTimer);
|
||
cooldownTimer = null;
|
||
};
|
||
const startCooldown = (seconds) => {
|
||
stopCooldown();
|
||
cooldownSeconds.value = seconds;
|
||
if (seconds <= 0) return;
|
||
cooldownTimer = setInterval(() => {
|
||
cooldownSeconds.value = Math.max(0, cooldownSeconds.value - 1);
|
||
if (cooldownSeconds.value === 0) stopCooldown();
|
||
}, 1000);
|
||
};
|
||
const resetForm = () => {
|
||
smsCode.value = "";
|
||
newPassword.value = "";
|
||
confirmedPassword.value = "";
|
||
formError.value = "";
|
||
resultNotice.value = "";
|
||
};
|
||
const loadCapability = async () => {
|
||
capabilityController.abort();
|
||
capabilityState.value = "loading";
|
||
capabilityError.value = "";
|
||
try {
|
||
const result = await contentPasswordRecoveryApi.getCapability(
|
||
props.genealogyId,
|
||
props.resourceType,
|
||
props.resourceId,
|
||
{ requestController: capabilityController },
|
||
);
|
||
if (!componentActive || !props.visible) return;
|
||
Object.assign(capability, result);
|
||
startCooldown(result.cooldownSeconds);
|
||
capabilityState.value = "ready";
|
||
} catch (error) {
|
||
if (!componentActive || !props.visible || isRequestCancelled(error)) return;
|
||
capabilityError.value = getRequestErrorMessage(error, "找回方式暂时无法读取,请稍后重试。");
|
||
capabilityState.value = "error";
|
||
}
|
||
};
|
||
const sendRecoveryCode = async () => {
|
||
if (!capability.enabled || sendingCode.value || resetting.value || cooldownSeconds.value > 0) return;
|
||
sendingCode.value = true;
|
||
formError.value = "";
|
||
try {
|
||
const delivery = await contentPasswordRecoveryApi.sendCode(
|
||
props.genealogyId,
|
||
props.resourceType,
|
||
props.resourceId,
|
||
{ requestController: codeController },
|
||
);
|
||
if (!componentActive || !props.visible) return;
|
||
startCooldown(delivery.cooldownSeconds);
|
||
resultNotice.value = `验证码已发送至 ${capability.maskedPhone}`;
|
||
} catch (error) {
|
||
if (componentActive && props.visible && !isRequestCancelled(error)) {
|
||
formError.value = getRequestErrorMessage(error, "验证码发送失败,请稍后重试。");
|
||
}
|
||
} finally {
|
||
if (componentActive) sendingCode.value = false;
|
||
}
|
||
};
|
||
const resetContentPassword = async () => {
|
||
if (!capability.enabled || isBusy.value) return;
|
||
if (!/^\d{4}$/.test(smsCode.value)) {
|
||
formError.value = "请输入4位短信验证码。";
|
||
return;
|
||
}
|
||
if (newPassword.value.length < 8 || newPassword.value.length > 128) {
|
||
formError.value = "请输入8至128位新的内容密码。";
|
||
return;
|
||
}
|
||
if (newPassword.value !== confirmedPassword.value) {
|
||
formError.value = "两次输入的新密码不一致。";
|
||
return;
|
||
}
|
||
const payload = { smsCode: smsCode.value, newPassword: newPassword.value };
|
||
const resetAttempt = resetGuard.begin(payload);
|
||
if (resetAttempt === null) {
|
||
formError.value = "上次重置结果待确认,请先关闭窗口并尝试使用新密码解锁。";
|
||
return;
|
||
}
|
||
resetting.value = true;
|
||
formError.value = "";
|
||
try {
|
||
await contentPasswordRecoveryApi.resetPassword(
|
||
props.genealogyId,
|
||
props.resourceType,
|
||
props.resourceId,
|
||
payload,
|
||
{ requestController: resetController },
|
||
);
|
||
if (!componentActive || !props.visible) return;
|
||
emit("complete", payload.newPassword);
|
||
emit("close");
|
||
resetForm();
|
||
} catch (error) {
|
||
const isOutcomeUnknown = resetGuard.recordFailure(resetAttempt, error);
|
||
if (!componentActive || !props.visible) return;
|
||
if (isOutcomeUnknown) {
|
||
emit("complete", payload.newPassword);
|
||
formError.value = "重置结果待确认,请关闭窗口后尝试使用新密码解锁,不要重复提交。";
|
||
return;
|
||
}
|
||
if (isRequestCancelled(error)) return;
|
||
formError.value = getRequestErrorMessage(error, "内容密码重置失败,请检查验证码后重试。");
|
||
} finally {
|
||
if (componentActive) resetting.value = false;
|
||
}
|
||
};
|
||
const requestClose = () => {
|
||
if (!isBusy.value) emit("close");
|
||
};
|
||
|
||
watch(
|
||
() => props.visible,
|
||
(visible) => {
|
||
if (!visible) {
|
||
capabilityController.abort();
|
||
codeController.abort();
|
||
resetController.abort();
|
||
stopCooldown();
|
||
resetForm();
|
||
capabilityState.value = "idle";
|
||
return;
|
||
}
|
||
resetForm();
|
||
void loadCapability();
|
||
},
|
||
);
|
||
|
||
onUnmounted(() => {
|
||
componentActive = false;
|
||
stopCooldown();
|
||
capabilityController.abort();
|
||
codeController.abort();
|
||
resetController.abort();
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.recovery-dialog { width: 100%; margin-top: 16rpx; text-align: left; }
|
||
.recovery-dialog__copy,
|
||
.recovery-dialog__security,
|
||
.recovery-dialog__state text,
|
||
.recovery-dialog__error,
|
||
.recovery-dialog__notice { display: block; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
|
||
.recovery-dialog input { width: 100%; min-height: var(--app-touch-min); margin-top: 16rpx; padding: 0 18rpx; box-sizing: border-box; border: 1rpx solid rgba(142, 95, 41, .3); border-radius: 8rpx; background: rgba(255, 255, 255, .68); }
|
||
.recovery-dialog__code-row { display: flex; align-items: center; margin-top: 16rpx; gap: 12rpx; }
|
||
.recovery-dialog__code-row input { min-width: 0; flex: 1; margin-top: 0; }
|
||
.recovery-dialog__code-row .app-button { width: auto; flex: 0 0 auto; }
|
||
.recovery-dialog > .app-button { margin-top: 20rpx; }
|
||
.recovery-dialog__state .app-button { width: auto; margin-top: 16rpx; }
|
||
.recovery-dialog__error { margin-top: 14rpx; color: $brand-red; }
|
||
.recovery-dialog__notice { margin-top: 14rpx; color: #426b58; }
|
||
.recovery-dialog__security { margin-top: 18rpx; padding-top: 14rpx; border-top: 1rpx solid rgba(142, 95, 41, .18); font-size: clamp(12px, 19rpx, 14px); }
|
||
</style>
|