收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -98,6 +98,23 @@ function buildSession(
|
||||
};
|
||||
}
|
||||
|
||||
function findNearestClassName(
|
||||
element: HTMLElement,
|
||||
classNamePart: string,
|
||||
): HTMLElement | null {
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current) {
|
||||
if (current.className.includes(classNamePart)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
@@ -119,6 +136,27 @@ test('settings header uses a generic title instead of the phone number', () => {
|
||||
expect(screen.getByRole('button', { name: /账号与安全/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /主题外观/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /账号信息/u })).toBeNull();
|
||||
|
||||
const themeSettingsButton = screen.getByRole('button', { name: /主题设置/u });
|
||||
expect(themeSettingsButton.getAttribute('type')).toBe('button');
|
||||
expect(themeSettingsButton.className).toContain('platform-subpanel');
|
||||
expect(themeSettingsButton.className).toContain('rounded-[1.5rem]');
|
||||
expect(themeSettingsButton.className).toContain('hover:bg-white');
|
||||
});
|
||||
|
||||
test('appearance panel uses PlatformPillBadge for current theme status', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /主题设置/u }));
|
||||
|
||||
const appearanceDialog = screen.getByRole('dialog', { name: '主题设置' });
|
||||
const themeStatusBadge = within(appearanceDialog).getByText('平台设置已同步');
|
||||
|
||||
expect(within(appearanceDialog).getByText('当前主题')).toBeTruthy();
|
||||
expect(themeStatusBadge.className).toContain('rounded-full');
|
||||
expect(themeStatusBadge.className).toContain('bg-white/72');
|
||||
});
|
||||
|
||||
test('direct account entry does not render the settings shell as another dialog', () => {
|
||||
@@ -159,6 +197,9 @@ test('account panel uses compact binding cards and keeps logout actions at the b
|
||||
'[data-account-binding-card]',
|
||||
);
|
||||
expect(compactCards).toHaveLength(2);
|
||||
expect(compactCards[0]?.className).toContain('platform-subpanel');
|
||||
expect(compactCards[0]?.className).toContain('rounded-[1rem]');
|
||||
expect(compactCards[0]?.className).toContain('px-3.5 py-3');
|
||||
expect(
|
||||
within(compactCards[0] as HTMLElement).getByRole('button', {
|
||||
name: '更换手机号',
|
||||
@@ -357,6 +398,18 @@ test('account panel includes merged security devices and audit sections', async
|
||||
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
|
||||
const deviceRow = findNearestClassName(
|
||||
within(accountDialog).getByText('iPhone 15 Pro'),
|
||||
'bg-white/72',
|
||||
);
|
||||
const auditRow = findNearestClassName(
|
||||
within(accountDialog).getByText('登录成功'),
|
||||
'bg-white/72',
|
||||
);
|
||||
expect(deviceRow?.className).toContain('rounded-[1rem]');
|
||||
expect(deviceRow?.className).toContain('px-4 py-3');
|
||||
expect(auditRow?.className).toContain('rounded-[1rem]');
|
||||
expect(auditRow?.className).toContain('px-4 py-3');
|
||||
expect(
|
||||
within(accountDialog).getByRole('button', { name: '退出登录' }),
|
||||
).toBeTruthy();
|
||||
@@ -392,7 +445,14 @@ test('current merged session group hides kick action and shows count', async ()
|
||||
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const sessionCountBadge = within(accountDialog).getByText('2 个会话');
|
||||
const currentDeviceBadge = within(accountDialog).getByText('当前设备');
|
||||
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(sessionCountBadge.className).toContain('rounded-full');
|
||||
expect(sessionCountBadge.className).toContain('bg-white/72');
|
||||
expect(currentDeviceBadge.className).toContain('rounded-full');
|
||||
expect(currentDeviceBadge.className).toContain('border-emerald-200');
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '踢下线' }),
|
||||
).toBeNull();
|
||||
@@ -419,8 +479,12 @@ test('remote merged session group can be revoked with loading state', async () =
|
||||
const revokeButton = within(accountDialog).getByRole('button', {
|
||||
name: '处理中...',
|
||||
}) as HTMLButtonElement;
|
||||
const loggedInBadge = within(accountDialog).getByText('已登录');
|
||||
|
||||
expect(revokeButton.disabled).toBe(true);
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(loggedInBadge.className).toContain('rounded-full');
|
||||
expect(loggedInBadge.className).toContain('border-emerald-200');
|
||||
expect(onRevokeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import type { PlatformSettingsSection } from './AuthUiContext';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
@@ -130,10 +134,13 @@ function SettingsEntryCard({
|
||||
onClick: (trigger: HTMLButtonElement) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformSubpanel
|
||||
as="button"
|
||||
interactive
|
||||
radius="xl"
|
||||
padding="md"
|
||||
onClick={(event) => onClick(event.currentTarget)}
|
||||
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
|
||||
className="w-full hover:border-[var(--platform-surface-hover-border)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -151,7 +158,7 @@ function SettingsEntryCard({
|
||||
<div className="mt-3 text-sm text-[var(--platform-text-base)]">
|
||||
{summary}
|
||||
</div>
|
||||
</button>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,23 +211,27 @@ function OverlayPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
autoFocus
|
||||
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 gap-1.5 px-3 py-1.5"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 px-3 py-1.5"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,10 +270,13 @@ function ThemeOptionCard({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformSubpanel
|
||||
as="button"
|
||||
interactive
|
||||
radius="xl"
|
||||
padding="md"
|
||||
onClick={onClick}
|
||||
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${
|
||||
className={`w-full ${
|
||||
active
|
||||
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]'
|
||||
: 'hover:border-[var(--platform-surface-hover-border)]'
|
||||
@@ -275,7 +289,7 @@ function ThemeOptionCard({
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
{detail}
|
||||
</div>
|
||||
</button>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -494,13 +508,15 @@ export function AccountModal({
|
||||
设置与账号安全
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 px-3 py-1.5"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -552,7 +568,12 @@ export function AccountModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
@@ -562,11 +583,15 @@ export function AccountModal({
|
||||
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
|
||||
<PlatformPillBadge
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
className="px-3 py-1"
|
||||
>
|
||||
{themeStatusText}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
@@ -580,23 +605,28 @@ export function AccountModal({
|
||||
>
|
||||
<div data-account-content className="flex min-h-0 flex-col gap-3">
|
||||
{accountNotice ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
<PlatformStatusMessage tone="success" surface="profile">
|
||||
{accountNotice}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
data-account-binding-card
|
||||
className="platform-subpanel rounded-2xl px-3.5 py-3"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
绑定手机号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||||
onClick={(event) => {
|
||||
changePhoneTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
@@ -605,47 +635,59 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{boundPhoneNumber}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
data-account-binding-card
|
||||
className="platform-subpanel rounded-2xl px-3.5 py-3"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
绑定微信
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||||
onClick={() => {
|
||||
setAccountNotice('更换微信号功能暂未接入。');
|
||||
}}
|
||||
>
|
||||
更换微信号
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{boundWechatDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
登录密码
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 shrink-0 px-0 py-0 text-[11px] text-[var(--platform-cool-text)]"
|
||||
onClick={(event) => {
|
||||
passwordTriggerRef.current = event.currentTarget;
|
||||
setAccountNotice('');
|
||||
@@ -654,38 +696,52 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
安全状态
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||||
onClick={() => {
|
||||
void onRefreshRiskBlocks();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingRiskBlocks ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
正在读取安全状态...
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
) : riskBlocks.length > 0 ? (
|
||||
riskBlocks.map((block) => (
|
||||
<div
|
||||
<PlatformStatusMessage
|
||||
key={`${block.scopeType}:${block.expiresAt}`}
|
||||
className="platform-banner platform-banner--warning text-sm"
|
||||
tone="warning"
|
||||
surface="profile"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{block.title}</span>
|
||||
@@ -701,48 +757,68 @@ export function AccountModal({
|
||||
<div className="mt-2 text-xs leading-5">
|
||||
{block.detail}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
className="mt-3 h-9 min-h-0 px-3"
|
||||
onClick={() => {
|
||||
void onLiftRiskBlock(block.scopeType);
|
||||
}}
|
||||
>
|
||||
解除保护
|
||||
</button>
|
||||
</div>
|
||||
</PlatformActionButton>
|
||||
</PlatformStatusMessage>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
当前没有生效中的安全限制。
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
登录设备
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||||
onClick={() => {
|
||||
void onRefreshSessions();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingSessions ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
const isRevoking = revokingSessionIds.includes(
|
||||
@@ -750,21 +826,33 @@ export function AccountModal({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{session.sessionCount > 1 ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
<PlatformPillBadge
|
||||
tone="neutral"
|
||||
size="xs"
|
||||
className="px-2.5 py-1 text-[10px]"
|
||||
>
|
||||
{session.sessionCount} 个会话
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
<PlatformPillBadge
|
||||
tone="success"
|
||||
size="xs"
|
||||
className="px-2.5 py-1 text-[10px]"
|
||||
>
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
@@ -779,56 +867,80 @@ export function AccountModal({
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
size="xs"
|
||||
className="mt-3 h-9 min-h-0 px-3"
|
||||
disabled={isRevoking}
|
||||
onClick={() => {
|
||||
void onRevokeSession(session);
|
||||
}}
|
||||
>
|
||||
{isRevoking ? '处理中...' : '踢下线'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
暂无可展示的登录设备。
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
操作记录
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="min-h-0 px-3 py-1.5 text-[11px]"
|
||||
onClick={() => {
|
||||
void onRefreshAuditLogs();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2.5">
|
||||
{loadingAuditLogs ? (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
正在读取账号操作记录...
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
) : auditLogs.length > 0 ? (
|
||||
auditLogs.map((log) => (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={log.id}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{log.title}</span>
|
||||
@@ -844,38 +956,48 @@ export function AccountModal({
|
||||
IP:{log.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-3 text-sm text-[var(--platform-text-soft)]"
|
||||
>
|
||||
暂无账号操作记录。
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<div
|
||||
data-account-actions
|
||||
className="grid gap-2.5 pt-1 sm:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-10 w-full text-sm"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
className="h-10"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger h-10 w-full text-sm"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
size="sm"
|
||||
fullWidth
|
||||
className="h-10"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -909,12 +1031,13 @@ export function AccountModal({
|
||||
placeholder="输入验证码"
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={
|
||||
sendingCode || cooldownSeconds > 0 || !phone.trim()
|
||||
}
|
||||
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
tone="secondary"
|
||||
size="md"
|
||||
className="h-11 shrink-0"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSendingCode(true);
|
||||
@@ -951,14 +1074,14 @@ export function AccountModal({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{changePhoneHint ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
<PlatformStatusMessage tone="success" surface="profile">
|
||||
{changePhoneHint}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
@@ -968,15 +1091,15 @@ export function AccountModal({
|
||||
/>
|
||||
|
||||
{changePhoneError ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{changePhoneError}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-11 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="md"
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPhone(true);
|
||||
@@ -998,7 +1121,7 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
@@ -1038,15 +1161,16 @@ export function AccountModal({
|
||||
</label>
|
||||
|
||||
{passwordError ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{passwordError}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={changingPassword || !newPassword.trim()}
|
||||
className="platform-button platform-button--primary h-11 w-full text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="md"
|
||||
fullWidth
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPassword(true);
|
||||
@@ -1068,7 +1192,7 @@ export function AccountModal({
|
||||
}}
|
||||
>
|
||||
{changingPassword ? '提交中...' : '确认修改密码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
) : null}
|
||||
|
||||
@@ -54,12 +54,14 @@ vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
|
||||
isWechatMiniProgramWebViewRuntime:
|
||||
authMocks.isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
|
||||
requestWechatMiniProgramPhoneLogin:
|
||||
authMocks.requestWechatMiniProgramPhoneLogin,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
@@ -440,7 +442,9 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
|
||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
@@ -476,9 +480,7 @@ test('login modal requires first-time legal consent before sms login', async ()
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '《用户协议》' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '用户协议' }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole('dialog', { name: '用户协议' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '我知道了' }));
|
||||
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
@@ -849,6 +851,14 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('tab', { name: '短信登录' })
|
||||
.className.includes('h-12'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
within(dialog).getByRole('tablist', { name: '登录方式' }).className,
|
||||
).toContain('bg-transparent');
|
||||
expect(within(dialog).queryByLabelText('密码')).toBeNull();
|
||||
|
||||
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
|
||||
@@ -903,7 +913,9 @@ test('auth gate revokes merged session group and refreshes sessions', async () =
|
||||
const accountDialog = await screen.findByRole('dialog', {
|
||||
name: '账号信息',
|
||||
});
|
||||
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '踢下线' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
|
||||
@@ -945,7 +957,10 @@ test('auth gate clears account state after password change', async () => {
|
||||
const passwordDialog = await screen.findByRole('dialog', {
|
||||
name: '修改登录密码',
|
||||
});
|
||||
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
|
||||
await user.type(
|
||||
within(passwordDialog).getByLabelText('当前密码'),
|
||||
'oldpass1',
|
||||
);
|
||||
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
|
||||
await user.click(
|
||||
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
@@ -757,15 +758,14 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--primary mt-5"
|
||||
<PlatformActionButton
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
重新尝试
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
@@ -108,10 +110,11 @@ export function BindPhoneScreen({
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
className="shrink-0 text-sm"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -135,14 +138,14 @@ export function BindPhoneScreen({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
<PlatformStatusMessage tone="success" surface="profile">
|
||||
{hint}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
@@ -152,28 +155,29 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="lg"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--ghost h-11 px-4 text-sm"
|
||||
<PlatformActionButton
|
||||
tone="ghost"
|
||||
size="md"
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
返回其他登录方式
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
53
src/components/auth/CaptchaChallengeField.test.tsx
Normal file
53
src/components/auth/CaptchaChallengeField.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
const CAPTCHA_CHALLENGE = {
|
||||
challengeId: 'captcha-1',
|
||||
promptText: '请输入图中字符。',
|
||||
imageDataUrl: 'data:image/png;base64,ZmFrZQ==',
|
||||
expiresInSeconds: 120,
|
||||
};
|
||||
|
||||
test('does not render without a captcha challenge', () => {
|
||||
const { container } = render(
|
||||
<CaptchaChallengeField
|
||||
challenge={null}
|
||||
answer=""
|
||||
onAnswerChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test('reuses platform media frame and text field chrome', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleAnswerChange = vi.fn();
|
||||
|
||||
render(
|
||||
<CaptchaChallengeField
|
||||
challenge={CAPTCHA_CHALLENGE}
|
||||
answer=""
|
||||
onAnswerChange={handleAnswerChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const image = screen.getByAltText('图形验证码');
|
||||
const imageFrame = image.closest('.platform-media-frame');
|
||||
const input = screen.getByLabelText('图形验证码答案');
|
||||
|
||||
expect(screen.getByText('请输入图中字符。')).toBeTruthy();
|
||||
expect(imageFrame?.className).toContain('platform-media-frame');
|
||||
expect(imageFrame?.className).toContain('bg-white/68');
|
||||
expect(input.className).toContain('border-[var(--platform-subpanel-border)]');
|
||||
expect(input.className).toContain('h-11');
|
||||
|
||||
await user.type(input, '7');
|
||||
|
||||
expect(handleAnswerChange).toHaveBeenLastCalledWith('7');
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
|
||||
type CaptchaChallengeFieldProps = {
|
||||
challenge: AuthCaptchaChallenge | null;
|
||||
@@ -16,19 +19,28 @@ export function CaptchaChallengeField({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-banner platform-banner--info grid gap-3">
|
||||
<PlatformStatusMessage
|
||||
tone="info"
|
||||
surface="profile"
|
||||
className="grid gap-3 rounded-2xl"
|
||||
>
|
||||
<div className="text-sm leading-6">{challenge.promptText}</div>
|
||||
<img
|
||||
<PlatformMediaFrame
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
|
||||
fallbackLabel="图形验证码"
|
||||
aspect="auto"
|
||||
surface="soft"
|
||||
className="h-14 w-40"
|
||||
/>
|
||||
<input
|
||||
className="platform-input h-11"
|
||||
<PlatformTextField
|
||||
value={answer}
|
||||
aria-label="图形验证码答案"
|
||||
placeholder="输入图形验证码"
|
||||
density="compact"
|
||||
className="h-11"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -7,9 +7,7 @@ import type {
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import {
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
} from '../../services/authService';
|
||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
@@ -17,11 +15,21 @@ import {
|
||||
persistLegalConsent,
|
||||
readStoredLegalConsent,
|
||||
} from '../common/legalDocuments';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
type LoginTab = 'phone' | 'password';
|
||||
|
||||
const LOGIN_TAB_ITEMS: Array<{ id: LoginTab; label: string }> = [
|
||||
{ id: 'phone', label: '短信登录' },
|
||||
{ id: 'password', label: '密码登录' },
|
||||
];
|
||||
|
||||
type LoginScreenProps = {
|
||||
isOpen: boolean;
|
||||
platformTheme: PlatformTheme;
|
||||
@@ -199,14 +207,12 @@ export function LoginScreen({
|
||||
>
|
||||
{isResetPanelOpen ? '重置密码' : '账号入口'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformModalCloseButton
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="关闭登录弹窗"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭登录弹窗"
|
||||
variant="platformIcon"
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isResetPanelOpen ? (
|
||||
@@ -233,147 +239,146 @@ export function LoginScreen({
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 px-5 py-5">
|
||||
{phoneLoginEnabled ? (
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
|
||||
}`}
|
||||
role="tablist"
|
||||
aria-label="登录方式"
|
||||
>
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'phone'}
|
||||
onClick={() => setActiveLoginTab('phone')}
|
||||
>
|
||||
短信登录
|
||||
</LoginTabButton>
|
||||
{passwordLoginEnabled ? (
|
||||
<LoginTabButton
|
||||
active={activeLoginTab === 'password'}
|
||||
onClick={() => setActiveLoginTab('password')}
|
||||
>
|
||||
密码登录
|
||||
</LoginTabButton>
|
||||
) : null}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
{passwordLoginEnabled && activeLoginTab === 'password' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
submitDisabled ||
|
||||
!phone.trim() ||
|
||||
!password.trim() ||
|
||||
!legalConsentChecked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
{legalConsentRow}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
{passwordLoginEnabled && activeLoginTab === 'password' ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
submitDisabled ||
|
||||
!phone.trim() ||
|
||||
!password.trim() ||
|
||||
!legalConsentChecked
|
||||
) {
|
||||
return;
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{wechatLoginEnabled && !miniProgramRuntime ? (
|
||||
<WechatButton
|
||||
loading={wechatLoading}
|
||||
disabled={submitDisabled}
|
||||
onClick={onStartWechatLogin}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
{legalConsentRow}
|
||||
|
||||
{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}
|
||||
<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>
|
||||
|
||||
{!passwordLoginEnabled &&
|
||||
!phoneLoginEnabled &&
|
||||
!wechatLoginEnabled &&
|
||||
!miniProgramRuntime ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
当前登录入口暂不可用。
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="none"
|
||||
className="px-4 py-4 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
当前登录入口暂不可用。
|
||||
</PlatformSubpanel>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -439,13 +444,7 @@ function LegalConsentRow({
|
||||
);
|
||||
}
|
||||
|
||||
function LegalLink({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
function LegalLink({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -457,35 +456,6 @@ function LegalLink({
|
||||
);
|
||||
}
|
||||
|
||||
function LoginTabButton({
|
||||
active,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
|
||||
active
|
||||
? 'text-[var(--platform-text-strong)]'
|
||||
: 'text-[var(--platform-text-muted)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? (
|
||||
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
@@ -569,10 +539,11 @@ function PhoneCodeForm({
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
className="shrink-0 text-sm"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
@@ -580,7 +551,7 @@ function PhoneCodeForm({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -594,13 +565,9 @@ function PhoneCodeForm({
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
{legalConsentNode}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitBlocked}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<PlatformActionButton type="submit" disabled={submitBlocked} size="lg">
|
||||
{loggingIn ? '处理中' : submitLabel}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -663,10 +630,11 @@ function PasswordResetPanel({
|
||||
onChange={(event) => onCodeChange(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
className="shrink-0 text-sm"
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sendingCode
|
||||
@@ -674,7 +642,7 @@ function PasswordResetPanel({
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
@@ -692,22 +660,18 @@ function PasswordResetPanel({
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base"
|
||||
onClick={onBack}
|
||||
>
|
||||
<PlatformActionButton tone="secondary" size="lg" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={
|
||||
loggingIn || !phone.trim() || !code.trim() || !password.trim()
|
||||
}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="lg"
|
||||
>
|
||||
{loggingIn ? '处理中' : '重置密码'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
@@ -723,29 +687,29 @@ function WechatButton({
|
||||
onClick: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={loading || disabled}
|
||||
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
onClick={() => void onClick()}
|
||||
>
|
||||
{loading ? '跳转中' : '微信登录'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{message}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="platform-banner platform-banner--success text-sm">
|
||||
<PlatformStatusMessage tone="success" surface="profile">
|
||||
{message}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
|
||||
type RegistrationInviteModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -63,14 +65,12 @@ export function RegistrationInviteModal({
|
||||
>
|
||||
请填写邀请码
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformModalCloseButton
|
||||
onClick={onClose}
|
||||
className="platform-icon-button p-2"
|
||||
aria-label="取消填写邀请码"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="取消填写邀请码"
|
||||
variant="platformIcon"
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col gap-4 px-5 py-5"
|
||||
@@ -96,18 +96,18 @@ export function RegistrationInviteModal({
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm">
|
||||
<PlatformStatusMessage tone="error" surface="profile">
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
size="lg"
|
||||
>
|
||||
{submitting ? '提交中' : normalizedInviteCode ? '提交' : '跳过'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user