Files
ttstd-infinite-canvas/web/src/components/auth/verification-fields.tsx
✨await 672bb0be73 feat(auth): 注册服务协议 - 名称与条款改为后台可配置并同步注册页展示
后端:
- RegistrationSettingRequest 协议字段改为可选指针,注册开关单独保存不再覆盖协议正文
- 新增 RegistrationAgreement / AgreementTitleForMessage:标题未配置时跟随品牌名生成,读取失败返回空标题而非猜一个不存在的协议名
- PublicAuthSettings 下发 agreementTitle / agreementContent,首用户分支同样携带
- 新增 registration_agreement_test.go 覆盖品牌名兜底、存储合并与失败降级

前端:
- 注册页协议标题与条款改为消费后台配置;读取失败时隐藏协议入口并支持重新读取
- 验证码获取按钮内联到输入框右侧,联系方式为空即禁用;sms_email 双通道合并为单输入框
- 协议弹窗重排:条目标题独立成行加重、空态虚线提示、底部补充同意说明;深浅主题适配
- 协议勾选行改为状态化区域,复选框适配 AntD 6 结构(方框在 .ant-checkbox 本体,修正深色主题白底白勾)
- 后台「服务协议名称与条款」区块补齐表单内边距并对齐区块头/页脚,协议名称限宽

文档:http-api.mdx 更新协议字段语义与失败降级说明;pending-test.mdx 增加手工验证清单
2026-09-24 16:56:44 +08:00

106 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from "react";
import { App, Button, Input } from "antd";
import { startVerification, type VerificationDraft, type VerificationMethod, type VerificationPurpose } from "@/services/api/verification";
// 验证码输入框与「获取验证码」按钮同排显示:按钮贴右侧、竖线分隔。
// 倒计时文案长度变化会撑动按钮宽度,因此按钮用 tabular-nums + nowrap 固定视觉节奏,
// 输入框通过 flex: 1 吸收剩余宽度,避免每次倒计时跳一下布局。
function CodeField({ label, value, onChange, onSend, sending, remaining, targetReady, disabled, placeholder, autoComplete, inputMode, sendLabel }: {
label: string; value: string; onChange: (next: string) => void;
onSend: () => void; sending: boolean; remaining: number; targetReady: boolean;
disabled: boolean; placeholder: string; autoComplete: string; inputMode: "text" | "email" | "tel" | "numeric"; sendLabel: string;
}) {
const counting = remaining > 0;
const blocked = disabled || sending || counting || !targetReady;
return (
<label className="block space-y-2">
<span className="auth-scene-label text-xs font-medium">{label}</span>
<span className={`auth-code-field${sending ? " is-busy" : ""}`}>
<Input
size="large"
variant="borderless"
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete={autoComplete}
inputMode={inputMode}
placeholder={placeholder}
disabled={disabled || sending}
/>
<Button
className="auth-code-send"
size="large"
type="text"
data-counting={counting ? "true" : "false"}
loading={sending}
disabled={blocked}
onClick={onSend}
>
{counting ? `${remaining} 秒后重发` : sendLabel}
</Button>
</span>
</label>
);
}
export function VerificationFields({ purpose, method, value, onChange, disabled = false, password }: {
purpose: VerificationPurpose; method: VerificationMethod; value: VerificationDraft;
onChange: (value: VerificationDraft) => void; disabled?: boolean; password?: string;
}) {
const { message } = App.useApp();
const [sending, setSending] = useState(false);
const [remaining, setRemaining] = useState(0);
const inFlight = useRef(false);
const sms = method !== "email", email = method !== "sms";
useEffect(() => {
if (!remaining) return;
const timer = window.setTimeout(() => setRemaining((n) => Math.max(0, n - 1)), 1000);
return () => window.clearTimeout(timer);
}, [remaining]);
const changeTarget = (key: "email" | "phone", target: string) => onChange({ ...value, [key]: target, ticket: "", emailCode: "", smsCode: "" });
const send = async () => {
if (inFlight.current || remaining > 0) return;
if ((sms && !value.phone.trim()) || (email && !value.email.trim()) || (purpose === "bind" && !password)) {
message.warning("请先填写联系方式及必要的当前密码"); return;
}
inFlight.current = true; setSending(true);
// Start cooldown even on ambiguous failures; never encourage instant resends.
setRemaining(60);
onChange({ ...value, ticket: "", emailCode: "", smsCode: "" });
try {
const result = await startVerification({ purpose, method, email: email ? value.email.trim() : undefined, phone: sms ? value.phone.trim() : undefined, password });
onChange({ ...value, email: email ? value.email.trim().toLowerCase() : "", phone: sms ? value.phone.trim() : "", emailCode: "", smsCode: "", ticket: result.ticket });
setRemaining(result.retryAfter);
message.success(purpose === "login" ? "如该联系方式已验证且账号可用,验证码将发送至该联系方式" : "验证码已发送,10 分钟内有效");
} catch (error) { message.error(error instanceof Error ? error.message : "发送失败,请稍后重试"); }
finally { setSending(false); inFlight.current = false; }
};
const sendLabel = method === "sms_email" ? "获取验证码" : "获取验证码";
return <div className="space-y-4">
{sms && <label className="block space-y-2"><span className="auth-scene-label text-xs font-medium">手机号</span><Input size="large" value={value.phone} onChange={(e) => changeTarget("phone", e.target.value)} autoComplete="tel" inputMode="tel" placeholder="中国大陆手机号,支持 +86" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="auth-scene-label text-xs font-medium">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
{/* 双通道(sms_email)一次请求同时下发两种验证码,此时按钮文案不区分通道。 */}
{(sms || email) && (
<CodeField
label={method === "sms_email" ? "验证码" : sms ? "短信验证码" : "邮件验证码"}
value={method === "sms_email" ? value.smsCode : sms ? value.smsCode : value.emailCode}
onChange={(next) => {
const code = next.replace(/\D/g, "").slice(0, 6);
if (method === "sms_email" || sms) onChange({ ...value, smsCode: code, ...(method === "sms_email" ? { emailCode: code } : {}) });
else onChange({ ...value, emailCode: code });
}}
onSend={() => void send()}
sending={sending}
remaining={remaining}
// 按钮在联系方式为空时保持禁用,避免用户点了才发现没填邮箱/手机号。
targetReady={(sms ? Boolean(value.phone.trim()) : true) && (email ? Boolean(value.email.trim()) : true) && (purpose !== "bind" || Boolean(password))}
disabled={disabled}
placeholder="6 位数字验证码"
autoComplete="one-time-code"
inputMode="numeric"
sendLabel={sendLabel}
/>
)}
{method === "sms_email" && <p className="auth-scene-muted m-0 text-xs leading-5">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
</div>;
}