refine Semi UI platform shell and states
This commit is contained in:
@@ -27,6 +27,35 @@ afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
test('uses a branded Semi UI session state while restoring authentication', () => {
|
||||
mocks.session.mockReturnValue(new Promise(() => undefined));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><AuthGate><div>受保护工作台</div></AuthGate></QueryClientProvider>);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在验证登录状态');
|
||||
expect(screen.getByRole('status')).toHaveTextContent('账号、菜单与车辆权限');
|
||||
expect(screen.getByAltText('羚牛智能')).toHaveAttribute('src', '/brand-logo.svg');
|
||||
expect(view.container.querySelector('.v2-auth-loading.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelectorAll('.v2-auth-loading-skeleton > i')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('presents failed credentials as a clear Semi UI alert without losing the login form', async () => {
|
||||
mocks.session.mockRejectedValue(new Error('未登录'));
|
||||
mocks.login.mockRejectedValue(new Error('账号或密码错误'));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><AuthGate><div>受保护工作台</div></AuthGate></QueryClientProvider>);
|
||||
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'customer' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'wrong-password' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('登录未完成');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('账号或密码错误');
|
||||
expect(view.container.querySelector('.v2-auth-error .semi-banner')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('treats login and logout as complete query and mutation cache boundaries', async () => {
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconAlertTriangle, IconLock, IconSafe } from '@douyinfe/semi-icons';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { Banner, Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||
@@ -13,6 +14,40 @@ type AuthContextValue = {
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function AuthLoadingState() {
|
||||
return <main className="v2-auth-screen is-session-loading">
|
||||
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="正在恢复平台会话">
|
||||
<div className="v2-auth-loading-inner">
|
||||
<header className="v2-auth-loading-header">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Tag color="blue" prefixIcon={<IconSafe />}>安全会话</Tag>
|
||||
</header>
|
||||
<div className="v2-auth-loading-status" role="status" aria-live="polite">
|
||||
<span><Spin size="large" /></span>
|
||||
<div>
|
||||
<Title heading={4}>正在验证登录状态</Title>
|
||||
<Text type="tertiary">正在恢复账号、菜单与车辆权限,完成后会自动进入工作台。</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="v2-auth-loading-skeleton" aria-hidden="true"><i /><i /><i /></div>
|
||||
<footer><IconLock /><Text type="tertiary" size="small">会话仅保存在当前浏览器,切换账号时会清理用户数据缓存。</Text></footer>
|
||||
</div>
|
||||
</Card>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function AuthErrorMessage({ message }: { message: string }) {
|
||||
return <div className="v2-auth-error">
|
||||
<Banner
|
||||
type="danger"
|
||||
bordered
|
||||
icon={<IconAlertTriangle />}
|
||||
title="登录未完成"
|
||||
description={message}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function usePlatformSession() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) throw new Error('usePlatformSession must be used inside AuthGate');
|
||||
@@ -79,7 +114,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><Card className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" /><Spin size="large" /><Text strong>正在验证登录状态…</Text></Card></div>;
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
if (!session.data) {
|
||||
const error = loginError || (attempted && session.error ? session.error.message : '');
|
||||
@@ -92,19 +127,21 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
</section>
|
||||
<Card className="v2-auth-card" bodyStyle={{ padding: 0 }}>
|
||||
<form className="v2-auth-form" onSubmit={login}>
|
||||
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Title heading={3}>登录车辆数据中台</Title>
|
||||
<Text type="secondary">{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</Text>
|
||||
<header className="v2-auth-form-heading">
|
||||
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Title heading={3}>登录车辆数据中台</Title>
|
||||
<Text type="secondary">{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</Text>
|
||||
</header>
|
||||
{legacyMode ? <label><span>运维访问令牌</span><Input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={setDraftToken} placeholder="Bearer token" size="large" /></label> : <>
|
||||
<label><span>用户名</span><Input autoFocus required autoComplete="username" value={username} onChange={setUsername} placeholder="请输入用户名" size="large" /></label>
|
||||
<label><span>密码</span><Input required type="password" autoComplete="current-password" value={password} onChange={setPassword} placeholder="请输入密码" size="large" /></label>
|
||||
</>}
|
||||
{error ? <Text type="danger" role="alert">{error}</Text> : null}
|
||||
{error ? <AuthErrorMessage message={error} /> : null}
|
||||
<Button block htmlType="submit" loading={loginPending} disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)} theme="solid" type="primary" size="large">{loginPending ? '正在登录…' : '登录'}</Button>
|
||||
<Button block className="v2-auth-mode-switch" type="tertiary" theme="borderless" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
|
||||
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
|
||||
</Button>
|
||||
<Text type="tertiary" size="small">登录即表示你同意遵守平台数据安全规范</Text>
|
||||
<div className="v2-auth-trust-note"><IconLock /><Text type="tertiary" size="small">登录后按账号隔离菜单与车辆数据</Text></div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('V2 production entry', () => {
|
||||
expect(appShellSource).toContain('<Layout className="v2-shell">');
|
||||
expect(appShellSource).toContain('<Nav className="v2-navigation"');
|
||||
expect(appShellSource).toContain('<SideSheet className="v2-mobile-more-sidesheet"');
|
||||
expect(appShellSource).toContain('<Modal className="v2-password-modal"');
|
||||
expect(appShellSource).toContain('className="v2-password-modal"');
|
||||
expect(appShellSource).not.toContain('v2-mobile-more-backdrop');
|
||||
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ describe('route recovery boundary', () => {
|
||||
render(<PlatformErrorBoundary><BrokenShell /></PlatformErrorBoundary>);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('平台外壳运行异常');
|
||||
expect(document.querySelector('.v2-route-error-card.semi-card')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看技术信息')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /重新加载平台/ })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
||||
});
|
||||
@@ -35,6 +37,7 @@ describe('route recovery boundary', () => {
|
||||
const BrokenPage = () => { throw new Error('render exploded'); };
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
|
||||
expect(document.querySelector('.v2-route-error.is-runtime .v2-route-error-card.semi-card')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toHaveClass('semi-button');
|
||||
expect(screen.getByRole('button', { name: /返回全局监控/ })).toHaveClass('semi-button');
|
||||
});
|
||||
@@ -83,7 +86,7 @@ describe('route recovery boundary', () => {
|
||||
const PendingPage = lazy(() => new Promise<never>(() => undefined));
|
||||
const { container } = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><RoutePage page={PendingPage} label="接入管理" /></MemoryRouter>);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在加载接入管理');
|
||||
expect(screen.getByText('导航与筛选状态已保留')).toBeInTheDocument();
|
||||
expect(screen.getByText(/导航与筛选状态已保留/)).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.v2-page-skeleton > i')).toHaveLength(4);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import { Card, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { PageLoading } from '../shared/AsyncState';
|
||||
import { RecoveryActions } from '../shared/RecoveryActions';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
|
||||
const CHUNK_RELOAD_COOLDOWN_MS = 2 * 60_000;
|
||||
type RecoveryTone = 'timeout' | 'update' | 'runtime' | 'shell';
|
||||
|
||||
function RouteRecoveryView({
|
||||
tone,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
children,
|
||||
root = false
|
||||
}: {
|
||||
tone: RecoveryTone;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
error: Error;
|
||||
children: ReactNode;
|
||||
root?: boolean;
|
||||
}) {
|
||||
const tagColor = tone === 'update' ? 'blue' : tone === 'timeout' ? 'orange' : 'red';
|
||||
return <section className={`v2-route-error is-${tone}${root ? ' v2-root-error' : ''}`} role="alert">
|
||||
<Card className="v2-route-error-card" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-route-error-content">
|
||||
<header>
|
||||
<span className="v2-route-error-icon"><IconAlertTriangle /></span>
|
||||
<Tag color={tagColor}>{eyebrow}</Tag>
|
||||
</header>
|
||||
<div className="v2-route-error-copy">
|
||||
<Title heading={2}>{title}</Title>
|
||||
<Text type="secondary">{description}</Text>
|
||||
</div>
|
||||
<details className="v2-route-error-detail">
|
||||
<summary>查看技术信息</summary>
|
||||
<code>{error.message || error.name}</code>
|
||||
</details>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function isRouteChunkError(error: unknown) {
|
||||
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||
@@ -58,21 +100,21 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
|
||||
if (!error) return this.props.children;
|
||||
const chunkError = isRouteChunkError(error);
|
||||
const chunkTimeout = isRouteChunkTimeout(error);
|
||||
return <section className="v2-route-error" role="alert">
|
||||
<span><IconAlertTriangle /></span>
|
||||
<div>
|
||||
<small>{chunkTimeout ? '页面资源加载超时' : chunkError ? '检测到页面版本更新' : '页面运行异常'}</small>
|
||||
<h2>{chunkTimeout ? '当前模块没有在预期时间内加载完成' : chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}</h2>
|
||||
<p>{chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}</p>
|
||||
<code>{error.message || error.name}</code>
|
||||
<RecoveryActions
|
||||
primaryLabel="重试加载模块"
|
||||
onPrimary={this.props.onRetry}
|
||||
secondaryLabel="刷新当前页面"
|
||||
onSecondary={() => window.location.reload()}
|
||||
/>
|
||||
</div>
|
||||
</section>;
|
||||
const tone: RecoveryTone = chunkTimeout ? 'timeout' : chunkError ? 'update' : 'runtime';
|
||||
return <RouteRecoveryView
|
||||
tone={tone}
|
||||
eyebrow={chunkTimeout ? '页面资源加载超时' : chunkError ? '检测到页面版本更新' : '页面运行异常'}
|
||||
title={chunkTimeout ? '当前模块没有在预期时间内加载完成' : chunkError ? '正在等待加载最新页面资源' : '当前模块暂时无法显示'}
|
||||
description={chunkTimeout ? '系统已等待并尝试恢复,可能是网络暂时不稳定。刷新后会重新加载当前模块,不会丢失服务端数据。' : chunkError ? '通常是发布后旧标签页仍引用上一版本资源。刷新后会恢复,不会丢失服务端数据。' : '页面已被安全隔离,侧栏和其他模块仍可使用。更换筛选条件会自动重试,也可刷新当前页面。'}
|
||||
error={error}
|
||||
>
|
||||
<RecoveryActions
|
||||
primaryLabel="重试加载模块"
|
||||
onPrimary={this.props.onRetry}
|
||||
secondaryLabel="刷新当前页面"
|
||||
onSecondary={() => window.location.reload()}
|
||||
/>
|
||||
</RouteRecoveryView>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,16 +128,16 @@ export class PlatformErrorBoundary extends Component<{ children: ReactNode }, {
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return <section className="v2-route-error v2-root-error" role="alert">
|
||||
<span><IconAlertTriangle /></span>
|
||||
<div>
|
||||
<small>平台外壳运行异常</small>
|
||||
<h2>工作台暂时无法继续显示</h2>
|
||||
<p>系统已阻止异常扩散。请重新加载最新版本;服务端数据不会因页面恢复操作而改变。</p>
|
||||
<code>{error.message || error.name}</code>
|
||||
<RecoveryActions primaryLabel="重新加载平台" onPrimary={() => window.location.reload()} />
|
||||
</div>
|
||||
</section>;
|
||||
return <RouteRecoveryView
|
||||
tone="shell"
|
||||
eyebrow="平台外壳运行异常"
|
||||
title="工作台暂时无法继续显示"
|
||||
description="系统已阻止异常扩散。请重新加载最新版本;服务端数据不会因页面恢复操作而改变。"
|
||||
error={error}
|
||||
root
|
||||
>
|
||||
<RecoveryActions primaryLabel="重新加载平台" onPrimary={() => window.location.reload()} />
|
||||
</RouteRecoveryView>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { ReactNode } from 'react';
|
||||
type StateScope = 'page' | 'panel' | 'inline';
|
||||
type StateKind = 'loading' | 'empty' | 'error';
|
||||
|
||||
const PAGE_LOADING_SKELETON = <div className="v2-state-skeleton v2-page-skeleton" aria-hidden="true"><i /><i /><i /><i /></div>;
|
||||
const PANEL_LOADING_SKELETON = <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div>;
|
||||
|
||||
function stateRole(kind: StateKind) {
|
||||
return kind === 'error' ? 'alert' : 'status';
|
||||
}
|
||||
@@ -36,7 +39,7 @@ function StateSurface({
|
||||
<div className="v2-state-copy"><Typography.Text strong>{title}</Typography.Text>{description ? <Typography.Text type={kind === 'error' ? 'danger' : 'tertiary'} size="small">{description}</Typography.Text> : null}</div>
|
||||
{action ? <div className="v2-state-action">{action}</div> : null}
|
||||
</div>}
|
||||
{kind === 'loading' ? <div className="v2-state-skeleton" aria-hidden="true"><i /><i /><i /></div> : null}
|
||||
{kind === 'loading' ? (scope === 'page' ? PAGE_LOADING_SKELETON : PANEL_LOADING_SKELETON) : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user