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:
@@ -1,6 +1,6 @@
|
||||
import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { App, Button, Checkbox, Divider, Input, Modal, Segmented } from "antd";
|
||||
import { ArrowRight, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
|
||||
import { ArrowRight, FileText, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register } from "@/services/api/auth";
|
||||
@@ -8,16 +8,15 @@ import { LinuxDOIcon } from "./auth-scene";
|
||||
import { ApiError } from "@/services/api/request";
|
||||
import { VerificationFields } from "@/components/auth/verification-fields";
|
||||
import { emptyVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
|
||||
|
||||
// TODO: 由维护者填写正式的影策服务协议条款后再发布。
|
||||
const SERVICE_AGREEMENT: { title: string; content: string }[] = [];
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName) || "平台";
|
||||
const [settings, setSettings] = useState<AuthSettings | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -28,26 +27,43 @@ export default function RegisterPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [agreementAccepted, setAgreementAccepted] = useState(false);
|
||||
const [agreementOpen, setAgreementOpen] = useState(false);
|
||||
const [settingsFailed, setSettingsFailed] = useState(false);
|
||||
const [settingsReloadKey, setSettingsReloadKey] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [registerCountdown, setRegisterCountdown] = useState(0);
|
||||
const registering = useRef(false);
|
||||
const next = safeNext(params.get("next"));
|
||||
|
||||
// 协议标题与条款由后台「登录与注册」配置下发。标题未配置时才回退到当前品牌名,
|
||||
// 且只在确认拿到设置(settings 非空)后回退;接口失败时不猜标题,避免展示
|
||||
// 与后台真实配置不符的协议名。
|
||||
// 标题统一去掉书号后由页面补《》,避免后台已填《》时出现双书名号。
|
||||
const agreementTitle = ((settings?.agreementTitle || "").trim() || `${brandName}服务协议`).replace(/^《|》$/g, "");
|
||||
const agreementContent = (settings?.agreementContent || "").trim();
|
||||
const agreementParagraphs = agreementContent ? agreementContent.split(/\n\s*\n/) : [];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSettingsFailed(false);
|
||||
void getAuthSettings()
|
||||
.then((value) => { if (!cancelled) { setSettings(value); setMethod(verificationMethods(value, "register")[0] ?? "email"); } })
|
||||
.catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
// 这里必须留下失败态:协议标题只能来自后台配置,读不到时不能用品牌名
|
||||
// 顶替,否则用户看到的是一个后台并不存在的协议名称。
|
||||
setSettingsFailed(true);
|
||||
message.error(error instanceof Error ? error.message : "读取注册设置失败");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [message]);
|
||||
}, [message, settingsReloadKey]);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (registering.current || registerCountdown > 0) return;
|
||||
if (!agreementAccepted) {
|
||||
message.warning("请先同意影策服务协议");
|
||||
message.warning(`请先同意${agreementTitle}`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
@@ -154,14 +170,27 @@ export default function RegisterPage() {
|
||||
</AuthField>
|
||||
</div>
|
||||
|
||||
<div className="auth-scene-muted flex items-start gap-2 text-xs leading-5">
|
||||
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
|
||||
<span className="auth-scene-muted text-xs leading-5">我已阅读并同意</span>
|
||||
</Checkbox>
|
||||
<button type="button" className="auth-scene-link -ml-1 text-xs leading-5 transition" onClick={() => setAgreementOpen(true)}>
|
||||
《影策服务协议》
|
||||
</button>
|
||||
</div>
|
||||
{settingsFailed ? (
|
||||
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
|
||||
<span>
|
||||
读取注册设置失败,暂时无法确认服务协议名称与条款。
|
||||
<button type="button" className="auth-scene-link ml-1 transition" onClick={() => setSettingsReloadKey((value) => value + 1)}>
|
||||
重新读取
|
||||
</button>
|
||||
</span>
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{settings ? (
|
||||
<div className="auth-agreement-row" data-accepted={agreementAccepted ? "true" : "false"}>
|
||||
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
|
||||
<span className="auth-agreement-label">我已阅读并同意</span>
|
||||
</Checkbox>
|
||||
<button type="button" className="auth-agreement-link" onClick={() => setAgreementOpen(true)}>
|
||||
《{agreementTitle}》
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0 || !agreementAccepted} icon={<ArrowRight className="size-4" />} iconPlacement="end">
|
||||
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
|
||||
@@ -176,16 +205,38 @@ export default function RegisterPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Modal className="workspace-modal workspace-modal-compact" title="影策服务协议" open={agreementOpen} onCancel={() => setAgreementOpen(false)} footer={null} destroyOnHidden>
|
||||
<div className="max-h-96 space-y-4 overflow-y-auto pr-1 text-sm leading-6 text-foreground/68">
|
||||
{SERVICE_AGREEMENT.length === 0 ? <p className="m-0">服务协议内容待补充。</p> : null}
|
||||
{SERVICE_AGREEMENT.map((item) => (
|
||||
<section key={item.title}>
|
||||
<h3 className="m-0 text-sm font-semibold text-foreground">{item.title}</h3>
|
||||
<p className="mt-1.5 mb-0">{item.content}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-compact auth-agreement-modal"
|
||||
title={agreementTitle}
|
||||
open={agreementOpen}
|
||||
onCancel={() => setAgreementOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
{agreementParagraphs.length === 0 ? (
|
||||
<div className="auth-agreement-empty">
|
||||
<FileText className="size-3.5 shrink-0" aria-hidden />
|
||||
服务协议内容待补充,请联系管理员在后台「登录与注册」中完善。
|
||||
</div>
|
||||
) : (
|
||||
<div className="auth-agreement-body">
|
||||
{agreementParagraphs.map((paragraph, index) => {
|
||||
// 后台用纯文本框维护条款,「一、二、三…」条目通常和正文写在同一个
|
||||
// 段落里(只用一个换行分隔)。这里把条目标题切出来单独成行并加重,
|
||||
// 否则标题和正文会挤在同一行,长条款失去可扫描的层级。
|
||||
const blocks = splitAgreementParagraph(paragraph);
|
||||
return blocks.map((block, blockIndex) => (
|
||||
<p key={`agreement-${index}-${blockIndex}`} data-role={block.heading ? "heading" : undefined}>
|
||||
{block.text}
|
||||
</p>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="auth-agreement-meta">
|
||||
<LockKeyhole className="size-3 shrink-0" aria-hidden />
|
||||
继续注册即表示你已阅读并接受本协议全部条款。
|
||||
</p>
|
||||
</Modal>
|
||||
</form>
|
||||
);
|
||||
@@ -213,3 +264,20 @@ function safeNext(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
return value;
|
||||
}
|
||||
|
||||
// 中文条款里「一、」「二、」这类条目标题常与正文写在同一个段落内(只隔一个换行)。
|
||||
// 这里按「条目标题 + 紧随其后的正文」拆成独立段落,让标题能单独成行并加重;
|
||||
// 没有条目标题的普通段落原样返回,不改变后台已经整理好的排版。
|
||||
const AGREEMENT_HEADING = /^\s*([一二三四五六七八九十百]+、[^\n]{0,40})\n+([\s\S]+)$/;
|
||||
|
||||
function splitAgreementParagraph(paragraph: string): { text: string; heading: boolean }[] {
|
||||
const match = paragraph.match(AGREEMENT_HEADING);
|
||||
if (!match) {
|
||||
const headingOnly = /^\s*[一二三四五六七八九十百]+、[^\n]{0,40}\s*$/.test(paragraph);
|
||||
return [{ text: paragraph.trim(), heading: headingOnly }];
|
||||
}
|
||||
return [
|
||||
{ text: match[1].trim(), heading: true },
|
||||
{ text: match[2].trim(), heading: false },
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user