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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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