refine Semi UI identity workflow
This commit is contained in:
@@ -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 { Banner, Button, Card, Input, Spin, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
|
||||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
|
import { PasswordInput } from '../shared/PasswordInput';
|
||||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@@ -176,8 +177,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
<Tabs.TabPane itemKey="account" tab="账号登录" icon={<IconUser aria-hidden="true" />}>
|
<Tabs.TabPane itemKey="account" tab="账号登录" icon={<IconUser aria-hidden="true" />}>
|
||||||
<div className="v2-auth-fields">
|
<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 autoFocus required={!legacyMode} autoComplete="username" prefix={<IconUser aria-hidden="true" />} 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><PasswordInput aria-label="登录密码" required={!legacyMode} autoComplete="current-password" prefix={<IconLock aria-hidden="true" />} visibilityLabel="登录密码" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane itemKey="token" tab="运维令牌" icon={<IconKey aria-hidden="true" />}>
|
<Tabs.TabPane itemKey="token" tab="运维令牌" icon={<IconKey aria-hidden="true" />}>
|
||||||
@@ -190,7 +191,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
title="仅限平台运维"
|
title="仅限平台运维"
|
||||||
description="令牌只保存在当前浏览器会话,关闭或退出后失效。"
|
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>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconExit
|
IconExit
|
||||||
} from '@douyinfe/semi-icons';
|
} 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 { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
@@ -24,6 +24,7 @@ import { hasMenu } from '../auth/session';
|
|||||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||||
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
||||||
|
import { PasswordInput } from '../shared/PasswordInput';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
@@ -226,7 +227,7 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
|
|||||||
return <Modal
|
return <Modal
|
||||||
className="v2-password-modal"
|
className="v2-password-modal"
|
||||||
visible
|
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="修改登录密码"
|
aria-label="修改登录密码"
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
closeOnEsc={!pending}
|
closeOnEsc={!pending}
|
||||||
@@ -234,14 +235,14 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
|
|||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
<form className="v2-password-dialog" onSubmit={submit}>
|
<form className="v2-password-dialog" onSubmit={submit}>
|
||||||
<Banner type="warning" bordered title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
|
<Banner type="warning" bordered closeIcon={null} title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
|
||||||
<label><span>当前密码</span><Input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
|
<label><span>当前密码</span><PasswordInput aria-label="当前密码" autoFocus required autoComplete="current-password" visibilityLabel="当前密码" 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>
|
<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="新密码安全规则">
|
<div className="v2-password-rules" aria-label="新密码安全规则">
|
||||||
<span className={lengthValid ? 'is-valid' : ''}><IconTickCircle />10–128 位字符</span>
|
<span className={lengthValid ? 'is-valid' : ''}><IconTickCircle />10–128 位字符</span>
|
||||||
<span className={categoriesValid ? 'is-valid' : ''}><IconTickCircle />大小写字母、数字、特殊字符至少三类</span>
|
<span className={categoriesValid ? 'is-valid' : ''}><IconTickCircle />大小写字母、数字、特殊字符至少三类</span>
|
||||||
</div>
|
</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}
|
{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}
|
{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>
|
<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>
|
||||||
|
|||||||
@@ -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 profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
|
||||||
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.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 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 workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8');
|
||||||
const workspaceMetricRailSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceMetricRail.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');
|
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 recoveryActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/RecoveryActions.tsx'), 'utf8');
|
||||||
const detailTriggerRowSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/detailTriggerRow.ts'), '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 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 routeBoundarySource = readFileSync(resolve(process.cwd(), 'src/v2/routing/RouteBoundary.tsx'), 'utf8');
|
||||||
const fleetMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/FleetMap.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');
|
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('<Nav className="v2-navigation"');
|
||||||
expect(appShellSource).toContain('<SideSheet className="v2-mobile-more-sidesheet"');
|
expect(appShellSource).toContain('<SideSheet className="v2-mobile-more-sidesheet"');
|
||||||
expect(appShellSource).toContain('className="v2-password-modal"');
|
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-backdrop');
|
||||||
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
|
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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)}
|
||||||
|
/>}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@@ -4901,8 +4901,9 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-password-modal.semi-modal {
|
.v2-password-modal .semi-modal {
|
||||||
width: min(500px, calc(100vw - 32px)) !important;
|
width: min(500px, calc(100vw - 32px)) !important;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-password-modal .semi-modal-content {
|
.v2-password-modal .semi-modal-content {
|
||||||
@@ -4942,10 +4943,11 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-password-title h4.semi-typography {
|
.v2-password-title strong {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #293b51;
|
color: #293b51;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
|
font-weight: 720;
|
||||||
letter-spacing: -.02em;
|
letter-spacing: -.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4995,6 +4997,24 @@
|
|||||||
border-radius: 8px;
|
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 {
|
.v2-password-rules {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, .72fr) minmax(0, 1.6fr);
|
grid-template-columns: minmax(0, .72fr) minmax(0, 1.6fr);
|
||||||
@@ -5187,9 +5207,10 @@
|
|||||||
min-width: min(272px, calc(100vw - 20px));
|
min-width: min(272px, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-password-modal.semi-modal {
|
.v2-password-modal .semi-modal {
|
||||||
width: calc(100vw - 16px) !important;
|
width: calc(100vw - 24px) !important;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-password-modal .semi-modal-content {
|
.v2-password-modal .semi-modal-content {
|
||||||
|
|||||||
Reference in New Issue
Block a user