Files
im_webapp/web/src/features/auth/components/VerificationCodeButton.tsx
T
Your NameandClaude Sonnet 5 c9f4615c40 fix(web): code review round 2 -- messaging UI bugs and design fidelity
- DetailColumn: remark-edit draft and the history-search modal's open
  state weren't reset on contact switch (no key={topicId}, same long-
  lived instance) -- could silently save a typed remark against the
  WRONG contact. History-search state lifted to MessagesLayout so
  ConversationView's header search icon (previously wired to nothing)
  can open the same modal
- ConversationView: loadOlder's in-flight "restore scroll position"
  callback and its loading flag weren't guarded against a conversation
  switch happening mid-request, and the scroll-to-bottom effect keyed
  only on messages.length so two conversations that happened to load
  the same page size never got scrolled down on open
- ChatListColumn: pinned 系统消息/文件助手 rows used solid-color-square
  avatars like regular contacts; the actual design-code source
  (ChatList.dc.html) and reference screenshots use a light tinted
  background with a colored icon instead
- MessageBubble: own ("我") messages hash-colored their avatar like any
  contact instead of the design's fixed accent blue
- Every send/recall/favorite/forward action swallowed its own failure
  silently (empty .catch or, for forward, no catch at all) -- moved
  Toast from features/auth into app/ (shared) and wired real error
  feedback through it
- MessageContextMenu: added the recall countdown label, Escape-to-close,
  and viewport-edge clamping (a right-click near the window edge could
  render partially off-screen)
- EmojiPanel now defaults to 最近 per the reference (was defaulting to
  表情)
- HistorySearchModal: pagination (searchTopicMessages/getTopicMedia both
  supported cursor/next_cursor already, nothing used it -- silently
  truncated at one page), Escape-to-close, and a brief "没有找到" flash
  on the first keystroke before the debounce caught up
- A handful of design-token corrections (--shadow-btn, NavRail's border/
  spacing, LoginPage's logo radius/brand-panel padding, a placeholder
  icon color) verified against screens/*.dc.html's exact pixel values

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:53 +08:00

77 lines
2.9 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { requestVerificationCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
const PHONE_RE = /^\d{11}$/;
interface VerificationCodeButtonProps {
phone: string;
scene: 'register' | 'reset_password';
onChallenge: (challengeId: string) => void;
onError: (message: string) => void;
}
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) {
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
// Without this, navigating away mid-countdown (e.g. switching from register back to login)
// leaves the interval running against an unmounted component indefinitely until it naturally
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
useEffect(() => () => window.clearInterval(timerRef.current), []);
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
// counting down for the old number) until the old timer happens to run out on its own.
useEffect(() => {
window.clearInterval(timerRef.current);
setSeconds(0);
}, [phone]);
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
const click = async () => {
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
setBusy(true);
try {
const data = await requestVerificationCode(scene, phone);
onChallenge(data.challenge_id);
tick(data.resend_after_seconds || 60);
} catch (err) {
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds);
if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={click}
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
className="w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
</button>
);
}