| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- /**
- * App.tsx – Root Application Component
- *
- * Assembles the entire application:
- * - Wraps everything in ErrorBoundary to catch unhandled render errors
- * - Uses useOnlineStatus for network detection (Req 11.3)
- * - Dual-panel desktop layout via ResizableLayout with 40/60 split (Req 12.1)
- * - Left panel: ChatPanel (fixed)
- * - Right panel: Dynamically switches between ExportRecordList (default) and EditorPanel (when document is opened)
- * - Uses React.lazy for code splitting of non-critical panels (Req 13.2, 13.8)
- *
- * Layout Structure:
- * ┌─────────────────────────────────────────────────────────────┐
- * │ Toolbar │
- * ├──────────────────────┬──────────────────────────────────────┤
- * │ Chat Panel │ Export Records List (default) │
- * │ (Left 40%) │ OR │
- * │ │ Editor Panel (when document opened) │
- * │ │ (Right 60%) │
- * └──────────────────────┴──────────────────────────────────────┘
- *
- * Requirements: 4.1, 4.6, 11.3, 12.1, 13.2, 13.8
- *
- * @module App
- */
- import React, { useCallback, useState, memo, lazy, Suspense, useMemo } from 'react';
- import { ConfigProvider, Alert, Button, Drawer, Spin } from 'antd';
- import { MessageOutlined } from '@ant-design/icons';
- import zhCN from 'antd/locale/zh_CN';
- import { ErrorBoundary } from './components/common';
- import ResizableLayout from './components/Layout/ResizableLayout';
- import { useUIStore } from './stores/uiStore';
- // ── Lazy-loaded panel components (Req 13.2, 13.8) ────────────────────────────
- // These non-first-screen panels are split into separate chunks by Vite,
- // reducing the initial JS bundle size.
- const ChatPanel = lazy(() => import('./components/ChatPanel/ChatPanel'));
- const EditorPanel = lazy(() => import('./components/EditorPanel/EditorPanel'));
- const ExportRecordList = lazy(() =>
- import('./components/ExportRecordList').then((m) => ({ default: m.ExportRecordList }))
- );
- const SessionList = lazy(() =>
- import('./components/SessionList').then((m) => ({ default: m.SessionList }))
- );
- import { useOnlineStatus } from './hooks/useOnlineStatus';
- // ── Panel loading fallback ────────────────────────────────────────────────────
- /**
- * Lightweight fallback rendered by <Suspense> while a lazy panel chunk is
- * being fetched. Keeps the layout stable by filling the full available area.
- */
- const PanelFallback: React.FC = memo(() => (
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- height: '100%',
- width: '100%',
- backgroundColor: '#ffffff',
- }}
- aria-busy="true"
- aria-label="加载中"
- >
- <Spin size="default" tip="加载中…">
- <div style={{ minHeight: 50 }} />
- </Spin>
- </div>
- ));
- PanelFallback.displayName = 'PanelFallback';
- // ── Styles ────────────────────────────────────────────────────────────────────
- const appStyle: React.CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- height: '100vh',
- width: '100vw',
- overflow: 'hidden',
- backgroundColor: '#f5f5f5',
- };
- const offlineBannerStyle: React.CSSProperties = {
- flexShrink: 0,
- };
- const mainContentStyle: React.CSSProperties = {
- flex: 1,
- overflow: 'hidden',
- display: 'flex',
- flexDirection: 'column',
- };
- // ── Subcomponents ─────────────────────────────────────────────────────────────
- /**
- * Offline banner shown at the top when network is unavailable.
- */
- const OfflineBanner: React.FC = memo(() => (
- <div style={offlineBannerStyle}>
- <Alert
- banner
- type="warning"
- message="您当前处于离线状态,部分功能可能不可用"
- showIcon
- data-testid="offline-banner"
- />
- </div>
- ));
- OfflineBanner.displayName = 'OfflineBanner';
- // ── App Component ─────────────────────────────────────────────────────────────
- /**
- * App
- *
- * The top-level React component. Handles layout orchestration.
- *
- * Uses a fixed dual-panel desktop layout with ResizableLayout (40% left / 60% right).
- * The left panel shows ChatPanel (fixed), and the right panel dynamically switches:
- * - Default: ExportRecordList (shows export history)
- * - When document opened: EditorPanel (shows document for preview and editing)
- */
- const App: React.FC = () => {
- // ── Network status (Req 11.3) ────────────────────────────────────────────
- const isOnline = useOnlineStatus();
- // ── Editor state ──────────────────────────────────────────────────────────
- const previewDocumentId = useUIStore((state) => state.previewDocumentId);
- const previewDocumentName = useUIStore((state) => state.previewDocumentName);
- const closeDocumentPreview = useUIStore((state) => state.closeDocumentPreview);
-
- // ── Session history drawer ────────────────────────────────────────────────
- const [sessionListOpen, setSessionListOpen] = useState(false);
-
- // ── User ID (in a real app, this would come from auth context) ───────────
- const currentUserId = 'default-user';
-
- /** Stable callback to close the session drawer */
- const handleCloseSessionList = useCallback(() => setSessionListOpen(false), []);
- /** Stable callback to open the session drawer */
- const handleOpenSessionList = useCallback(() => setSessionListOpen(true), []);
- /**
- * Handle close editor button click
- */
- const handleCloseEditor = useCallback(() => {
- closeDocumentPreview();
- }, [closeDocumentPreview]);
- // ── Panels ────────────────────────────────────────────────────────────────
- // Each panel is wrapped in <Suspense> so the lazy chunk loads independently.
- // useMemo ensures the JSX elements are stable references and don't cause
- // Suspense to remount unnecessarily on parent re-renders (Req 13.7).
- const leftPanel = useMemo(() => {
- // Always show chat panel on the left
- return (
- <Suspense fallback={<PanelFallback />}>
- <ChatPanel />
- </Suspense>
- );
- }, []);
-
- const rightPanel = useMemo(() => {
- // If a document is being previewed, show EditorPanel
- // Otherwise show ExportRecordList (default)
- if (previewDocumentId) {
- return (
- <Suspense fallback={<PanelFallback />}>
- <EditorPanel
- documentId={previewDocumentId}
- initialDocumentName={previewDocumentName}
- onClose={handleCloseEditor}
- />
- </Suspense>
- );
- }
-
- // Default: show export records list
- return (
- <Suspense fallback={<PanelFallback />}>
- <ExportRecordList userId={currentUserId} />
- </Suspense>
- );
- }, [previewDocumentId, previewDocumentName, handleCloseEditor, currentUserId]);
- // ── Session history drawer ────────────────────────────────────────────────
- const sessionListDrawer = sessionListOpen ? (
- <Drawer
- title="会话历史"
- placement="left"
- width={360}
- open
- onClose={handleCloseSessionList}
- styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
- data-testid="session-list-drawer"
- >
- <Suspense fallback={<PanelFallback />}>
- <SessionList onSessionLoad={handleCloseSessionList} />
- </Suspense>
- </Drawer>
- ) : null;
- // ── Session history toggle button ─────────────────────────────────────────
- const sessionListButton = (
- <Button
- type="text"
- icon={<MessageOutlined />}
- onClick={handleOpenSessionList}
- title="会话历史"
- aria-label="打开会话历史"
- data-testid="open-session-list-button"
- />
- );
- // ── Desktop dual-panel layout (Req 12.1) ──────────────────────────────────
- return (
- <ConfigProvider locale={zhCN}>
- <ErrorBoundary>
- <div style={appStyle} data-testid="app">
- {/* Offline banner (Req 11.3) */}
- {!isOnline && <OfflineBanner />}
- {/* Session history drawer */}
- {sessionListDrawer}
- {/* Main two-panel layout */}
- <div style={mainContentStyle}>
- {/* Toolbar row with session history toggle */}
- <div
- style={{
- display: 'flex',
- alignItems: 'center',
- padding: '2px 8px',
- borderBottom: '1px solid #f0f0f0',
- backgroundColor: '#ffffff',
- flexShrink: 0,
- height: 40,
- gap: 8,
- }}
- data-testid="app-toolbar"
- >
- {sessionListButton}
- </div>
- {/* Resizable dual-panel layout (Req 4.1–4.5, 12.1) */}
- <div style={{ flex: 1, overflow: 'hidden' }}>
- <ResizableLayout
- leftPanel={leftPanel}
- rightPanel={rightPanel}
- defaultLeftWidth={40}
- minWidth={300}
- />
- </div>
- </div>
- </div>
- </ErrorBoundary>
- </ConfigProvider>
- );
- };
- export default App;
|