Skip to content

说明

  • 不再把"剩余秒数"写入 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>

使用提示

  • 以后如果后端支持返回 nextAllowedAtcooldownSeconds,只需在 handleSendCode 成功分支里替换为读取后端返回值即可,无需改整体结构。
  • 多端/多标签页需要完全一致时,也可以把 instanceId 设为"用户 ID+业务键"的稳定值(而不是随机 uuid)。

本站总访问