后端: - RegistrationSettingRequest 协议字段改为可选指针,注册开关单独保存不再覆盖协议正文 - 新增 RegistrationAgreement / AgreementTitleForMessage:标题未配置时跟随品牌名生成,读取失败返回空标题而非猜一个不存在的协议名 - PublicAuthSettings 下发 agreementTitle / agreementContent,首用户分支同样携带 - 新增 registration_agreement_test.go 覆盖品牌名兜底、存储合并与失败降级 前端: - 注册页协议标题与条款改为消费后台配置;读取失败时隐藏协议入口并支持重新读取 - 验证码获取按钮内联到输入框右侧,联系方式为空即禁用;sms_email 双通道合并为单输入框 - 协议弹窗重排:条目标题独立成行加重、空态虚线提示、底部补充同意说明;深浅主题适配 - 协议勾选行改为状态化区域,复选框适配 AntD 6 结构(方框在 .ant-checkbox 本体,修正深色主题白底白勾) - 后台「服务协议名称与条款」区块补齐表单内边距并对齐区块头/页脚,协议名称限宽 文档:http-api.mdx 更新协议字段语义与失败降级说明;pending-test.mdx 增加手工验证清单
106 lines
6.6 KiB
TypeScript
106 lines
6.6 KiB
TypeScript
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>;
|
||
}
|