继续收口认证入口弹窗壳层

新增 PlatformAuthModalShell 统一认证白底弹窗壳层

登录入口和邀请码弹窗复用共享认证壳层

补充认证壳层和 AuthGate 接入测试

同步 PlatformUiKit 文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 18:09:54 +08:00
parent 291ab06a5b
commit 59facaf14b
7 changed files with 324 additions and 245 deletions

View File

@@ -592,6 +592,8 @@ test('auth gate hides register entry and opens invite modal for new sms account'
await user.click(screen.getByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
expect(dialog.className).toContain('platform-auth-card');
expect(dialog.className).toContain('platform-modal-shell');
expect(within(dialog).queryByRole('tab', { name: '注册' })).toBeNull();
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
@@ -609,6 +611,8 @@ test('auth gate hides register entry and opens invite modal for new sms account'
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(inviteDialog.className).toContain('platform-auth-card');
expect(inviteDialog.className).toContain('platform-modal-shell');
const inviteCodeInput = within(inviteDialog).getByLabelText(
'邀请码',
) as HTMLInputElement;
@@ -799,6 +803,8 @@ test('login modal resets draft state every time it is reopened', async () => {
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const firstDialog = screen.getByRole('dialog', { name: '账号入口' });
expect(firstDialog.className).toContain('platform-auth-card');
expect(firstDialog.className).toContain('platform-modal-shell');
await user.type(within(firstDialog).getByLabelText('手机号'), '13800000000');
await user.click(
within(firstDialog).getByRole('button', { name: '获取验证码' }),

View File

@@ -18,11 +18,11 @@ import {
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { CaptchaChallengeField } from './CaptchaChallengeField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
@@ -191,201 +191,181 @@ export function LoginScreen({
return (
<>
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
<PlatformAuthModalShell
title={isResetPanelOpen ? '重置密码' : '账号入口'}
platformTheme={platformTheme}
onClose={onClose}
closeLabel="关闭登录弹窗"
panelClassName="!max-w-md"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-login-dialog-title"
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="auth-login-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<PlatformModalCloseButton
onClick={onClose}
label="关闭登录弹窗"
variant="platformIcon"
className="p-2"
/>
</div>
{isResetPanelOpen ? (
<PasswordResetPanel
phone={resetPhone}
code={resetCode}
password={resetPasswordValue}
sendingCode={sendingCode}
loggingIn={loggingIn}
cooldownSeconds={resetCooldownSeconds}
error={error}
onPhoneChange={setResetPhone}
onCodeChange={setResetCode}
onPasswordChange={setResetPasswordValue}
onBack={() => setIsResetPanelOpen(false)}
onSendCode={async () => {
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<PlatformSegmentedTabs
items={
passwordLoginEnabled
? LOGIN_TAB_ITEMS
: LOGIN_TAB_ITEMS.slice(0, 1)
}
activeId={activeLoginTab}
onChange={setActiveLoginTab}
columns={passwordLoginEnabled ? 'two' : 'one'}
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>
) : null}
{isResetPanelOpen ? (
<PasswordResetPanel
phone={resetPhone}
code={resetCode}
password={resetPasswordValue}
sendingCode={sendingCode}
loggingIn={loggingIn}
cooldownSeconds={resetCooldownSeconds}
error={error}
onPhoneChange={setResetPhone}
onCodeChange={setResetCode}
onPasswordChange={setResetPasswordValue}
onBack={() => setIsResetPanelOpen(false)}
onSendCode={async () => {
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<PlatformSegmentedTabs
items={
passwordLoginEnabled
? LOGIN_TAB_ITEMS
: LOGIN_TAB_ITEMS.slice(0, 1)
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
activeId={activeLoginTab}
onChange={setActiveLoginTab}
columns={passwordLoginEnabled ? 'two' : 'one'}
frame="bare"
surface="transparent"
tone="underline"
size="tab"
semantics="tabs"
ariaLabel="登录方式"
/>
) : null}
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<PlatformActionButton
type="submit"
disabled={
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
size="lg"
>
{loggingIn ? '登录中' : '登录'}
</PlatformActionButton>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
<div className="flex flex-col gap-2">
<PlatformActionButton
type="submit"
disabled={
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
size="lg"
>
{loggingIn ? '登录中' : '登录'}
</PlatformActionButton>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
onClick={onStartWechatLogin}
/>
) : null}
</form>
) : null}
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
) : null}
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="px-4 py-4"
>
</PlatformEmptyState>
) : null}
</div>
)}
</div>
</div>
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="px-4 py-4"
>
</PlatformEmptyState>
) : null}
</div>
)}
</PlatformAuthModalShell>
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
test('renders auth modal shell with platform theme and auth card chrome', () => {
const onClose = vi.fn();
render(
<PlatformAuthModalShell
title="账号入口"
platformTheme="light"
onClose={onClose}
closeLabel="关闭登录弹窗"
panelClassName="!max-w-md"
>
<div></div>
</PlatformAuthModalShell>,
);
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('platform-auth-card');
expect(dialog.className).toContain('!max-w-md');
expect(within(dialog).getByText('登录表单')).toBeTruthy();
fireEvent.click(dialog.parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
test('keeps escape disabled for auth flows', () => {
const onClose = vi.fn();
render(
<PlatformAuthModalShell
title="请填写邀请码"
platformTheme="dark"
onClose={onClose}
closeLabel="取消填写邀请码"
zIndexClassName="z-[130]"
>
<div></div>
</PlatformAuthModalShell>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '取消填写邀请码' })).toBeTruthy();
});

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import { UnifiedModal } from '../common/UnifiedModal';
type PlatformAuthModalShellProps = {
title: string;
platformTheme: PlatformTheme;
onClose: () => void;
children: ReactNode;
closeLabel: string;
zIndexClassName?: string;
panelClassName?: string;
bodyClassName?: string;
};
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(' ');
}
/**
* 认证入口弹窗共享壳层。
* 这里只统一主题遮罩、auth card、标题栏和关闭按钮登录 / 邀请码表单状态仍留在各自业务组件。
*/
export function PlatformAuthModalShell({
title,
platformTheme,
onClose,
children,
closeLabel,
zIndexClassName = 'z-[120]',
panelClassName,
bodyClassName = '!p-0',
}: PlatformAuthModalShellProps) {
return (
<UnifiedModal
open
title={title}
onClose={onClose}
closeLabel={closeLabel}
closeVariant="platformIcon"
closeOnBackdrop
closeOnEscape={false}
portal={false}
size="sm"
zIndexClassName={zIndexClassName}
overlayClassName={`platform-theme platform-theme--${platformTheme} !px-3 !py-4 text-[var(--platform-text-strong)] sm:!p-4`}
panelClassName={joinClassNames(
'platform-auth-card !rounded-[2rem] sm:!rounded-[2rem]',
panelClassName,
)}
headerClassName="!items-center !px-5 !py-4"
titleClassName="text-lg font-semibold text-[var(--platform-text-strong)]"
bodyClassName={bodyClassName}
>
{children}
</UnifiedModal>
);
}

View File

@@ -3,9 +3,9 @@ import { useEffect, useMemo, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformAuthModalShell } from './PlatformAuthModalShell';
type RegistrationInviteModalProps = {
isOpen: boolean;
@@ -49,70 +49,48 @@ export function RegistrationInviteModal({
}
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[130] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
<PlatformAuthModalShell
title="请填写邀请码"
platformTheme={platformTheme}
onClose={onClose}
closeLabel="取消填写邀请码"
zIndexClassName="z-[130]"
panelClassName="!max-w-sm"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="registration-invite-dialog-title"
className="platform-auth-card w-full max-w-sm overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!normalizedInviteCode) {
onClose();
return;
}
void onSubmit(normalizedInviteCode);
}}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="registration-invite-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
</div>
<PlatformModalCloseButton
onClick={onClose}
label="取消填写邀请码"
variant="platformIcon"
className="p-2"
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="邀请码"
/>
</div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!normalizedInviteCode) {
onClose();
return;
}
</label>
void onSubmit(normalizedInviteCode);
}}
>
<label className="grid gap-2">
<PlatformFieldLabel variant="form" className="mb-0">
</PlatformFieldLabel>
<PlatformTextField
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
placeholder="邀请码"
/>
</label>
{error ? (
<PlatformStatusMessage tone="error" surface="profile">
{error}
</PlatformStatusMessage>
) : null}
{error ? (
<PlatformStatusMessage tone="error" surface="profile">
{error}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
disabled={submitting}
size="lg"
>
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</PlatformActionButton>
</form>
</div>
</div>
<PlatformActionButton type="submit" disabled={submitting} size="lg">
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
</PlatformActionButton>
</form>
</PlatformAuthModalShell>
);
}