refine Semi UI platform shell and states

This commit is contained in:
lingniu
2026-07-18 01:12:48 +08:00
parent 1cd92715a5
commit 5c0a61f7bd
9 changed files with 1298 additions and 54 deletions

View File

@@ -12,11 +12,15 @@ const auth = vi.hoisted(() => ({
session: { name: 'test-user', username: 'test-user', role: 'viewer' as const } as any,
logout: vi.fn()
}));
const apiMocks = vi.hoisted(() => ({
changePassword: vi.fn()
}));
vi.mock('../routing/routeModules', () => routing);
vi.mock('../auth/AuthGate', () => ({
usePlatformSession: () => ({ session: auth.session, logout: auth.logout })
}));
vi.mock('../../api/client', () => ({ api: { changePassword: apiMocks.changePassword } }));
import { AppShell } from './AppShell';
@@ -25,6 +29,7 @@ afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
auth.session = { name: 'test-user', username: 'test-user', role: 'viewer' };
apiMocks.changePassword.mockReset();
});
test('renders only explicitly assigned customer menus', () => {
@@ -91,6 +96,8 @@ test('opens contextual help for the active module and closes it without navigati
fireEvent.click(help);
expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效');
expect(document.querySelector('.v2-help-overview.semi-card')).toBeInTheDocument();
expect(screen.getByText('建议操作')).toBeInTheDocument();
expect(help).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByLabelText('关闭帮助'));
expect(help).toHaveAttribute('aria-expanded', 'false');
@@ -108,6 +115,7 @@ test('uses one Semi account menu for identity, password and logout actions', asy
fireEvent.click(trigger);
expect(await screen.findByText('@test-user')).toBeInTheDocument();
expect(screen.getByText('当前登录账号')).toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: '账号菜单test-user' })).toHaveAttribute('aria-expanded', 'true'));
fireEvent.click(screen.getByText('修改登录密码'));
@@ -119,6 +127,35 @@ test('uses one Semi account menu for identity, password and logout actions', asy
expect(auth.logout).toHaveBeenCalledTimes(1);
});
test('validates password policy while typing and submits only a complete secure change', async () => {
apiMocks.changePassword.mockResolvedValue({ changed: true });
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
<Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes>
</MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: '账号菜单test-user' }));
fireEvent.click(await screen.findByText('修改登录密码'));
const submit = screen.getByRole('button', { name: '确认修改' });
expect(submit).toBeDisabled();
expect(document.querySelector('.v2-password-dialog > .semi-banner-warning')).toHaveTextContent('当前设备和其他设备上的旧会话都会失效');
fireEvent.change(screen.getByLabelText('当前密码'), { target: { value: 'CurrentPass!1' } });
fireEvent.change(screen.getByLabelText('新密码'), { target: { value: 'StrongPass!1' } });
expect(screen.getByLabelText('新密码安全规则').querySelectorAll('.is-valid')).toHaveLength(2);
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'different' } });
expect(screen.getByRole('status')).toHaveTextContent('两次输入的新密码不一致');
expect(submit).toBeDisabled();
fireEvent.change(screen.getByLabelText('确认新密码'), { target: { value: 'StrongPass!1' } });
expect(screen.getByRole('status')).toHaveTextContent('两次输入一致');
expect(submit).toBeEnabled();
fireEvent.click(submit);
await waitFor(() => expect(apiMocks.changePassword).toHaveBeenCalledWith({ currentPassword: 'CurrentPass!1', newPassword: 'StrongPass!1' }));
expect(auth.logout).toHaveBeenCalledTimes(1);
});
test('uses the compact mobile navigation and opens secondary modules in a Semi SideSheet', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({
matches: true,

View File

@@ -6,14 +6,16 @@ import {
IconChevronLeft,
IconHelpCircle,
IconHome,
IconLock,
IconMapPin,
IconMore,
IconSearch,
IconSetting,
IconTickCircle,
IconUser,
IconExit
} from '@douyinfe/semi-icons';
import { Avatar, Button, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui';
import { Avatar, Banner, Button, Card, Dropdown, Input, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../api/client';
@@ -65,15 +67,51 @@ function ContextHelp({ section, visible, onClose }: { section: string; visible:
const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] };
const title = pageNames[section] ?? '车辆数据中台';
useSideSheetA11y(visible, '.v2-help-sheet', 'v2-context-help', title, '关闭帮助');
return <SideSheet className="v2-help-sheet" visible={visible} aria-label={title} width={410} title={<div><Text type="tertiary" size="small"></Text><Title heading={4}>{title}</Title></div>} onCancel={onClose} footer={<Button block onClick={onClose}></Button>}>
return <SideSheet
className="v2-help-sheet"
visible={visible}
aria-label={title}
width={410}
title={<div className="v2-help-sheet-title"><span><IconHelpCircle /></span><div><Text type="tertiary" size="small"></Text><Title heading={4}>{title}</Title></div></div>}
onCancel={onClose}
footer={<Button block theme="light" type="primary" onClick={onClose}></Button>}
>
<div className="v2-help-panel">
<Text type="secondary">{content.summary}</Text>
<Card className="v2-help-overview" bodyStyle={{ padding: 0 }}>
<Tag color="blue"></Tag>
<Text type="secondary">{content.summary}</Text>
</Card>
<div className="v2-help-section-label"><Text strong></Text><Text type="tertiary" size="small">{content.tips.length} </Text></div>
<ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol>
<footer><kbd>Esc</kbd><span></span></footer>
</div>
</SideSheet>;
}
function AccountMenu({
session,
roleLabel,
onPassword,
onLogout
}: {
session: ReturnType<typeof usePlatformSession>['session'];
roleLabel?: string;
onPassword: () => void;
onLogout: () => void;
}) {
const accountLabel = session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号';
return <Dropdown.Menu>
<Dropdown.Title className="v2-account-dropdown-profile">
<Avatar size="small" color="blue">{session.name.slice(0, 1)}</Avatar>
<span><small></small><strong>{session.name}</strong><em>{accountLabel}</em></span>
<Tag color="blue" size="small">{roleLabel}</Tag>
</Dropdown.Title>
<Dropdown.Divider />
<Dropdown.Item icon={<IconLock />} onClick={onPassword}></Dropdown.Item>
<Dropdown.Item type="danger" icon={<IconExit />} onClick={onLogout}>退</Dropdown.Item>
</Dropdown.Menu>;
}
export function AppShell() {
const location = useLocation();
const section = location.pathname.split('/')[1] || 'monitor';
@@ -114,23 +152,19 @@ export function AppShell() {
<Header className="v2-topbar">
<div className="v2-topbar-title"><Text type="tertiary" size="small"></Text><Title heading={4}>{pageNames[section] ?? '车辆数据中台'}</Title></div>
<div className="v2-topbar-actions">
<Button theme="borderless" icon={<IconHelpCircle />} aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)} />
<Button className="v2-help-trigger" theme="light" type="tertiary" icon={<IconHelpCircle />} aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><span className="v2-help-trigger-label"></span></Button>
<Dropdown
trigger="click"
position="bottomRight"
visible={accountOpen}
onVisibleChange={setAccountOpen}
contentClassName="v2-account-dropdown"
render={<Dropdown.Menu>
<Dropdown.Title className="v2-account-dropdown-profile">
<Avatar size="small" color="blue">{session.name.slice(0, 1)}</Avatar>
<span><strong>{session.name}</strong><small>{session.username ? `@${session.username}` : session.authProvider === 'legacy-token' ? '运维令牌账号' : '平台账号'}</small></span>
<Tag color="blue" size="small">{roleLabel}</Tag>
</Dropdown.Title>
<Dropdown.Divider />
<Dropdown.Item icon={<IconUser />} onClick={() => { setAccountOpen(false); setPasswordOpen(true); }}></Dropdown.Item>
<Dropdown.Item type="danger" icon={<IconExit />} onClick={() => { setAccountOpen(false); logout(); }}>退</Dropdown.Item>
</Dropdown.Menu>}
render={<AccountMenu
session={session}
roleLabel={roleLabel}
onPassword={() => { setAccountOpen(false); setPasswordOpen(true); }}
onLogout={() => { setAccountOpen(false); logout(); }}
/>}
>
<Button
theme="borderless"
@@ -161,9 +195,21 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
const [confirmPassword, setConfirmPassword] = useState('');
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const categoryCount = [
/[a-z]/.test(newPassword),
/[A-Z]/.test(newPassword),
/\d/.test(newPassword),
/[^A-Za-z0-9]/.test(newPassword)
].filter(Boolean).length;
const lengthValid = newPassword.length >= 10 && newPassword.length <= 128;
const categoriesValid = categoryCount >= 3;
const confirmationStarted = confirmPassword.length > 0;
const passwordsMatch = confirmationStarted && newPassword === confirmPassword;
const passwordValid = lengthValid && categoriesValid;
const submit = async (event: FormEvent) => {
event.preventDefault();
if (newPassword !== confirmPassword) { setError('两次输入的新密码不一致'); return; }
if (!passwordValid) { setError('新密码不符合安全规则'); return; }
if (!passwordsMatch) { setError('两次输入的新密码不一致'); return; }
setPending(true); setError('');
try {
await api.changePassword({ currentPassword, newPassword });
@@ -172,14 +218,28 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
setError(reason instanceof Error ? reason.message : '密码修改失败');
} finally { setPending(false); }
};
return <Modal className="v2-password-modal" visible title="修改登录密码" aria-label="修改登录密码" onCancel={onClose} closeOnEsc={!pending} maskClosable={!pending} footer={null}>
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>}
aria-label="修改登录密码"
onCancel={onClose}
closeOnEsc={!pending}
maskClosable={!pending}
footer={null}
>
<form className="v2-password-dialog" onSubmit={submit}>
<Text type="secondary"></Text>
<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>
<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>
{error ? <Text type="danger" role="alert">{error}</Text> : null}
<footer><Button type="tertiary" onClick={onClose} disabled={pending}></Button><Button htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</Button></footer>
{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>
</form>
</Modal>;
}