feat(auth): 注册服务协议 - 名称与条款改为后台可配置并同步注册页展示

后端:
- RegistrationSettingRequest 协议字段改为可选指针,注册开关单独保存不再覆盖协议正文
- 新增 RegistrationAgreement / AgreementTitleForMessage:标题未配置时跟随品牌名生成,读取失败返回空标题而非猜一个不存在的协议名
- PublicAuthSettings 下发 agreementTitle / agreementContent,首用户分支同样携带
- 新增 registration_agreement_test.go 覆盖品牌名兜底、存储合并与失败降级

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

文档:http-api.mdx 更新协议字段语义与失败降级说明;pending-test.mdx 增加手工验证清单
This commit is contained in:
✨await
2026-09-24 16:56:44 +08:00
parent abf593c6c9
commit 672bb0be73
16 changed files with 977 additions and 74 deletions

View File

@@ -2,6 +2,46 @@ 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;
@@ -34,12 +74,32 @@ export function VerificationFields({ purpose, method, value, onChange, disabled
} 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="text-sm">手机号</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="text-sm">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
<Button block size="large" loading={sending} disabled={disabled || remaining > 0} onClick={() => void send()}>{remaining > 0 ? `${remaining} 秒后可重新获取` : method === "sms_email" ? "获取短信及邮件验证码" : "获取验证码"}</Button>
{sms && <label className="block space-y-2"><span className="text-sm">短信验证码</span><Input size="large" value={value.smsCode} onChange={(e) => onChange({ ...value, smsCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位短信验证码" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮件验证码</span><Input size="large" value={value.emailCode} onChange={(e) => onChange({ ...value, emailCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位邮件验证码" required disabled={disabled || sending} /></label>}
{method === "sms_email" && <p className="text-xs opacity-65">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
{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>;
}