refine Semi UI identity workflow

This commit is contained in:
lingniu
2026-07-18 13:59:33 +08:00
parent 10a072280b
commit 32c8939eb5
6 changed files with 92 additions and 13 deletions

View File

@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Banner, Button, Card, Input, Spin, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
import { api } from '../../api/client';
import { PasswordInput } from '../shared/PasswordInput';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
const { Title, Text } = Typography;
@@ -176,8 +177,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
>
<Tabs.TabPane itemKey="account" tab="账号登录" icon={<IconUser aria-hidden="true" />}>
<div className="v2-auth-fields">
<label><span></span><Input autoFocus required={!legacyMode} autoComplete="username" value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
<label><span></span><Input required={!legacyMode} type="password" autoComplete="current-password" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
<label><span></span><Input autoFocus required={!legacyMode} autoComplete="username" prefix={<IconUser aria-hidden="true" />} value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
<label><span></span><PasswordInput aria-label="登录密码" required={!legacyMode} autoComplete="current-password" prefix={<IconLock aria-hidden="true" />} visibilityLabel="登录密码" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
</div>
</Tabs.TabPane>
<Tabs.TabPane itemKey="token" tab="运维令牌" icon={<IconKey aria-hidden="true" />}>
@@ -190,7 +191,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
title="仅限平台运维"
description="令牌只保存在当前浏览器会话,关闭或退出后失效。"
/>
<label><span>访</span><Input autoFocus required={legacyMode} type="password" autoComplete="off" value={draftToken} onChange={setDraftToken} placeholder="请输入服务器访问令牌" size="large" /></label>
<label><span>访</span><PasswordInput aria-label="运维访问令牌" autoFocus required={legacyMode} autoComplete="off" prefix={<IconKey aria-hidden="true" />} visibilityLabel="运维访问令牌" value={draftToken} onChange={setDraftToken} placeholder="请输入服务器访问令牌" size="large" /></label>
</div>
</Tabs.TabPane>
</Tabs>

View File

@@ -15,7 +15,7 @@ import {
IconUser,
IconExit
} from '@douyinfe/semi-icons';
import { Avatar, Banner, Button, Card, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui';
import { Avatar, Banner, Button, Card, Dropdown, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui';
import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
@@ -24,6 +24,7 @@ import { hasMenu } from '../auth/session';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
import { PasswordInput } from '../shared/PasswordInput';
const { Header, Sider, Content } = Layout;
const { Text, Title } = Typography;
@@ -226,7 +227,7 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
return <Modal
className="v2-password-modal"
visible
title={<div className="v2-password-title"><span aria-hidden="true"><IconLock /></span><div><Title heading={4}></Title><Text aria-hidden="true" type="tertiary" size="small"></Text></div></div>}
title={<div className="v2-password-title"><span aria-hidden="true"><IconLock /></span><div><strong></strong><Text aria-hidden="true" type="tertiary" size="small"></Text></div></div>}
aria-label="修改登录密码"
onCancel={onClose}
closeOnEsc={!pending}
@@ -234,14 +235,14 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
footer={null}
>
<form className="v2-password-dialog" onSubmit={submit}>
<Banner type="warning" bordered title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
<label><span></span><Input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
<label><span></span><Input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
<Banner type="warning" bordered closeIcon={null} title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
<label><span></span><PasswordInput aria-label="当前密码" autoFocus required autoComplete="current-password" visibilityLabel="当前密码" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
<label><span></span><PasswordInput aria-label="新密码" required minLength={10} autoComplete="new-password" visibilityLabel="新密码" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
<div className="v2-password-rules" aria-label="新密码安全规则">
<span className={lengthValid ? 'is-valid' : ''}><IconTickCircle />10128 </span>
<span className={categoriesValid ? 'is-valid' : ''}><IconTickCircle /></span>
</div>
<label><span></span><Input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={setConfirmPassword} size="large" /></label>
<label><span></span><PasswordInput aria-label="确认新密码" required minLength={10} autoComplete="new-password" visibilityLabel="确认新密码" value={confirmPassword} onChange={setConfirmPassword} size="large" /></label>
{confirmationStarted ? <Text className={`v2-password-match${passwordsMatch ? ' is-valid' : ' is-error'}`} role="status">{passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'}</Text> : null}
{error ? <Banner className="v2-password-error" type="danger" bordered title="无法修改密码" description={error} /> : null}
<footer><Button type="tertiary" onClick={onClose} disabled={pending}></Button><Button htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !passwordValid || !passwordsMatch}>{pending ? '正在修改…' : '确认修改'}</Button></footer>

View File

@@ -16,6 +16,7 @@ const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/R
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
const mobileFilterToggleSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterToggle.tsx'), 'utf8');
const passwordInputSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/PasswordInput.tsx'), 'utf8');
const workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8');
const workspaceMetricRailSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceMetricRail.tsx'), 'utf8');
const segmentedTabsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/SegmentedTabs.tsx'), 'utf8');
@@ -26,6 +27,7 @@ const vehicleCandidateListSource = readFileSync(resolve(process.cwd(), 'src/v2/s
const recoveryActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/RecoveryActions.tsx'), 'utf8');
const detailTriggerRowSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/detailTriggerRow.ts'), 'utf8');
const appShellSource = readFileSync(resolve(process.cwd(), 'src/v2/layout/AppShell.tsx'), 'utf8');
const authGateSource = readFileSync(resolve(process.cwd(), 'src/v2/auth/AuthGate.tsx'), 'utf8');
const routeBoundarySource = readFileSync(resolve(process.cwd(), 'src/v2/routing/RouteBoundary.tsx'), 'utf8');
const fleetMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/FleetMap.tsx'), 'utf8');
const trackMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/TrackMap.tsx'), 'utf8');
@@ -490,6 +492,13 @@ describe('V2 production entry', () => {
expect(appShellSource).toContain('<Nav className="v2-navigation"');
expect(appShellSource).toContain('<SideSheet className="v2-mobile-more-sidesheet"');
expect(appShellSource).toContain('className="v2-password-modal"');
expect(appShellSource.match(/<PasswordInput/g)).toHaveLength(3);
expect(authGateSource.match(/<PasswordInput/g)).toHaveLength(2);
expect(passwordInputSource).toContain('<Input');
expect(passwordInputSource).toContain('<Button');
expect(passwordInputSource).toContain("`${visible ? '隐藏' : '显示'}${visibilityLabel}`");
expect(workspaceStyles).toContain('.v2-password-modal .semi-modal {');
expect(workspaceStyles).not.toContain('.v2-password-modal.semi-modal {');
expect(appShellSource).not.toContain('v2-mobile-more-backdrop');
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
});

View File

@@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { PasswordInput } from './PasswordInput';
test('uses a localized Semi UI visibility control without losing the password value', () => {
render(<PasswordInput aria-label="登录密码" visibilityLabel="登录密码" defaultValue="Secret!123" />);
const input = screen.getByLabelText('登录密码');
expect(input).toHaveAttribute('type', 'password');
expect(input).toHaveValue('Secret!123');
fireEvent.click(screen.getByRole('button', { name: '显示登录密码' }));
expect(input).toHaveAttribute('type', 'text');
expect(input).toHaveValue('Secret!123');
expect(screen.getByRole('button', { name: '隐藏登录密码' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '隐藏登录密码' }));
expect(input).toHaveAttribute('type', 'password');
});

View File

@@ -0,0 +1,28 @@
import { IconEyeClosed, IconEyeOpened } from '@douyinfe/semi-icons';
import { Button, Input } from '@douyinfe/semi-ui';
import { type ComponentProps, useState } from 'react';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'mode' | 'suffix' | 'type'> & {
visibilityLabel?: string;
};
export function PasswordInput({ visibilityLabel = '密码', ...props }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
const actionLabel = `${visible ? '隐藏' : '显示'}${visibilityLabel}`;
return <Input
{...props}
type={visible ? 'text' : 'password'}
suffix={<Button
className="v2-password-visibility"
theme="borderless"
type="tertiary"
htmlType="button"
icon={visible ? <IconEyeOpened /> : <IconEyeClosed />}
aria-label={actionLabel}
aria-pressed={visible}
title={actionLabel}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setVisible((value) => !value)}
/>}
/>;
}

View File

@@ -4903,6 +4903,7 @@
.v2-password-modal .semi-modal {
width: min(500px, calc(100vw - 32px)) !important;
max-width: calc(100vw - 32px);
}
.v2-password-modal .semi-modal-content {
@@ -4942,10 +4943,11 @@
gap: 2px;
}
.v2-password-title h4.semi-typography {
.v2-password-title strong {
margin: 0;
color: #293b51;
font-size: 17px;
font-weight: 720;
letter-spacing: -.02em;
}
@@ -4995,6 +4997,24 @@
border-radius: 8px;
}
.v2-password-visibility.semi-button {
width: 30px;
min-width: 30px;
height: 30px;
border-radius: 8px;
color: #718197;
padding: 0;
}
.v2-password-visibility.semi-button:hover {
background: #eaf2fd;
color: var(--v2-blue);
}
.v2-password-visibility.semi-button:focus-visible {
box-shadow: 0 0 0 2px rgba(18, 104, 243, .18);
}
.v2-password-rules {
display: grid;
grid-template-columns: minmax(0, .72fr) minmax(0, 1.6fr);
@@ -5188,8 +5208,9 @@
}
.v2-password-modal .semi-modal {
width: calc(100vw - 16px) !important;
width: calc(100vw - 24px) !important;
max-width: 500px;
margin-inline: auto;
}
.v2-password-modal .semi-modal-content {