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

@@ -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>;
}