refine Semi UI platform shell and states
This commit is contained in:
@@ -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 />10–128 位字符</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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user