Appearance
说明
- 不再把"剩余秒数"写入
localStorage并自减;改为存一个时间点nextAllowedAt(毫秒),每秒重新计算剩余时间。 - 后端当前不返回剩余秒数时,前端在发送成功后用
Date.now() + resendTime*1000作为本地兜底。 - 刷新/多标签页可恢复,且不会出现"重置为错误剩余时间"的问题。
覆盖式完整代码
vue
<template>
<div @click="handleSendCode">
<slot
:remainingTime="remainingTime"
:isLoading="isLoading"
:disabled="disabledResend"
:buttonText="buttonText"
>
<GtcButton
v-bind="attrs"
:disabled="disabled || disabledResend"
:loading="isLoading"
>
{{ buttonText }}
</GtcButton>
</slot>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, useAttrs } from "vue";
import { message } from "ant-design-vue";
import { http } from "@/libs";
import type { Method, RequestParams } from "@/types";
import { v4 as uuidv4 } from "uuid";
import i18n from "@/locales";
import { GtcButton } from "@/components";
const t = (i18n.global as any).t;
const props = withDefaults(
defineProps<{
disabled?: boolean;
resendTime?: number;
apiUrl?: string;
method?: Method;
apiParams?: RequestParams;
instanceId?: string;
sendFn?: (ctx: {
apiUrl: string;
method: Method;
apiParams: RequestParams;
}) => Promise<unknown>;
}>(),
{
disabled: false,
resendTime: 60,
method: "POST",
apiUrl: "/api/pub/get_code",
apiParams: () => ({}),
}
);
const emit = defineEmits<{
(e: "send-success"): void;
(e: "send-failed", error: any): void;
}>();
const attrs = useAttrs();
const instanceId = props.instanceId || uuidv4();
// 复杂逻辑:改用"下一次可发送的时间点"作为冷却状态的唯一来源(多标签页/刷新都可还原)
const storageKey = `email_verify_nextAllowedAt_${instanceId}`;
// 复杂逻辑:存储下一次可发送的时间戳(毫秒);null 表示无需冷却
const nextAllowedAt = ref<number | null>(null);
const isLoading = ref(false);
const timer = ref<ReturnType<typeof setInterval> | null>(null);
// 复杂逻辑:由时间点实时计算剩余秒数,而非自减,避免漂移与刷新误差
const remainingTime = computed(() => {
if (!nextAllowedAt.value) return 0;
const ms = nextAllowedAt.value - Date.now();
return ms > 0 ? Math.ceil(ms / 1000) : 0;
});
const disabledResend = computed(
() => remainingTime.value > 0 || isLoading.value
);
const buttonText = computed(() =>
remainingTime.value === 0
? t("components.sendCodeButton.send-code")
: t("components.sendCodeButton.resend-code", { time: remainingTime.value })
);
// 复杂逻辑:定时"心跳"检查是否到点;到点后清理本地存储与状态
const startTimer = () => {
if (timer.value) clearInterval(timer.value);
timer.value = setInterval(() => {
if (remainingTime.value <= 0) {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
nextAllowedAt.value = null;
try {
localStorage.removeItem(storageKey);
} catch {}
}
}, 1000);
};
// 复杂逻辑:清理无效/过期的键,避免长期堆积
function cleanupStaleKeys() {
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key.startsWith("email_verify_nextAllowedAt_")) {
const raw = localStorage.getItem(key);
const ts = raw ? Number(raw) : NaN;
if (!Number.isFinite(ts) || ts <= Date.now()) {
localStorage.removeItem(key);
}
}
}
} catch {}
}
onMounted(() => {
cleanupStaleKeys();
// 复杂逻辑:从 localStorage 恢复"下一次可发送时间点"
try {
const raw = localStorage.getItem(storageKey);
const ts = raw ? Number(raw) : NaN;
nextAllowedAt.value = Number.isFinite(ts) ? ts : null;
} catch {
nextAllowedAt.value = null;
}
// 复杂逻辑:仍在冷却则启动心跳;否则清理残留键
if (remainingTime.value > 0) {
startTimer();
} else {
try {
localStorage.removeItem(storageKey);
} catch {}
}
});
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
// 复杂逻辑:离开时若已到点则顺手清理;未到点则保留用于刷新恢复
if (remainingTime.value <= 0) {
try {
localStorage.removeItem(storageKey);
} catch {}
}
});
const handleSendCode = async () => {
if (props.disabled) return;
if (remainingTime.value > 0) {
// 复杂逻辑:冷却中直接提示剩余秒数
return message.warn(
t("components.sendCodeButton.wait-retry", {
timeLeft: remainingTime.value,
})
);
}
if (isLoading.value) {
return message.warn(t("components.sendCodeButton.request-in-progress"));
}
const { apiUrl, method, apiParams, sendFn } = props;
if (!apiUrl && !sendFn) {
return message.error(t("components.sendCodeButton.no-api-url"));
}
try {
isLoading.value = true;
// 复杂逻辑:调用可选的自定义发送函数;否则走默认 http
const resp = sendFn
? await sendFn({
apiUrl: apiUrl!,
method: method!,
apiParams: apiParams!,
})
: await sendRequest(method!, apiUrl!, { ...(apiParams as any) });
message.success(t("components.sendCodeButton.send-success"));
// 复杂逻辑:后端当前不返回剩余秒数——前端以 resendTime 生成"下一次可发送的时间点"
nextAllowedAt.value = Date.now() + (props.resendTime || 60) * 1000;
try {
localStorage.setItem(storageKey, String(nextAllowedAt.value));
} catch {}
startTimer();
emit("send-success");
} catch (error) {
console.error("发送验证码请求出错:", error);
// 复杂逻辑:失败即取消冷却,限流交给后端兜底
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
nextAllowedAt.value = null;
try {
localStorage.removeItem(storageKey);
} catch {}
message.error(
error instanceof Error
? error.message
: t("components.sendCodeButton.send-failed")
);
emit("send-failed", error);
} finally {
isLoading.value = false;
}
};
async function sendRequest(
method: string,
apiUrl: string,
params: Record<string, unknown>
) {
if (method.toUpperCase() === "GET") {
return http.get(apiUrl, { params });
}
return http.post(apiUrl, params);
}
</script>使用提示
- 以后如果后端支持返回
nextAllowedAt或cooldownSeconds,只需在handleSendCode成功分支里替换为读取后端返回值即可,无需改整体结构。 - 多端/多标签页需要完全一致时,也可以把
instanceId设为"用户 ID+业务键"的稳定值(而不是随机uuid)。