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
@@ -0,0 +1,281 @@
<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>