App.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /**
  2. * App.tsx – Root Application Component
  3. *
  4. * Assembles the entire application:
  5. * - Wraps everything in ErrorBoundary to catch unhandled render errors
  6. * - Uses useOnlineStatus for network detection (Req 11.3)
  7. * - Dual-panel desktop layout via ResizableLayout with 40/60 split (Req 12.1)
  8. * - Left panel: ChatPanel (fixed)
  9. * - Right panel: Dynamically switches between ExportRecordList (default) and EditorPanel (when document is opened)
  10. * - Uses React.lazy for code splitting of non-critical panels (Req 13.2, 13.8)
  11. *
  12. * Layout Structure:
  13. * ┌─────────────────────────────────────────────────────────────┐
  14. * │ Toolbar │
  15. * ├──────────────────────┬──────────────────────────────────────┤
  16. * │ Chat Panel │ Export Records List (default) │
  17. * │ (Left 40%) │ OR │
  18. * │ │ Editor Panel (when document opened) │
  19. * │ │ (Right 60%) │
  20. * └──────────────────────┴──────────────────────────────────────┘
  21. *
  22. * Requirements: 4.1, 4.6, 11.3, 12.1, 13.2, 13.8
  23. *
  24. * @module App
  25. */
  26. import React, { useCallback, useState, memo, lazy, Suspense, useMemo } from 'react';
  27. import { ConfigProvider, Alert, Button, Drawer, Spin } from 'antd';
  28. import { MessageOutlined } from '@ant-design/icons';
  29. import zhCN from 'antd/locale/zh_CN';
  30. import { ErrorBoundary } from './components/common';
  31. import ResizableLayout from './components/Layout/ResizableLayout';
  32. import { useUIStore } from './stores/uiStore';
  33. // ── Lazy-loaded panel components (Req 13.2, 13.8) ────────────────────────────
  34. // These non-first-screen panels are split into separate chunks by Vite,
  35. // reducing the initial JS bundle size.
  36. const ChatPanel = lazy(() => import('./components/ChatPanel/ChatPanel'));
  37. const EditorPanel = lazy(() => import('./components/EditorPanel/EditorPanel'));
  38. const ExportRecordList = lazy(() =>
  39. import('./components/ExportRecordList').then((m) => ({ default: m.ExportRecordList }))
  40. );
  41. const SessionList = lazy(() =>
  42. import('./components/SessionList').then((m) => ({ default: m.SessionList }))
  43. );
  44. import { useOnlineStatus } from './hooks/useOnlineStatus';
  45. // ── Panel loading fallback ────────────────────────────────────────────────────
  46. /**
  47. * Lightweight fallback rendered by <Suspense> while a lazy panel chunk is
  48. * being fetched. Keeps the layout stable by filling the full available area.
  49. */
  50. const PanelFallback: React.FC = memo(() => (
  51. <div
  52. style={{
  53. display: 'flex',
  54. alignItems: 'center',
  55. justifyContent: 'center',
  56. height: '100%',
  57. width: '100%',
  58. backgroundColor: '#ffffff',
  59. }}
  60. aria-busy="true"
  61. aria-label="加载中"
  62. >
  63. <Spin size="default" tip="加载中…">
  64. <div style={{ minHeight: 50 }} />
  65. </Spin>
  66. </div>
  67. ));
  68. PanelFallback.displayName = 'PanelFallback';
  69. // ── Styles ────────────────────────────────────────────────────────────────────
  70. const appStyle: React.CSSProperties = {
  71. display: 'flex',
  72. flexDirection: 'column',
  73. height: '100vh',
  74. width: '100vw',
  75. overflow: 'hidden',
  76. backgroundColor: '#f5f5f5',
  77. };
  78. const offlineBannerStyle: React.CSSProperties = {
  79. flexShrink: 0,
  80. };
  81. const mainContentStyle: React.CSSProperties = {
  82. flex: 1,
  83. overflow: 'hidden',
  84. display: 'flex',
  85. flexDirection: 'column',
  86. };
  87. // ── Subcomponents ─────────────────────────────────────────────────────────────
  88. /**
  89. * Offline banner shown at the top when network is unavailable.
  90. */
  91. const OfflineBanner: React.FC = memo(() => (
  92. <div style={offlineBannerStyle}>
  93. <Alert
  94. banner
  95. type="warning"
  96. message="您当前处于离线状态,部分功能可能不可用"
  97. showIcon
  98. data-testid="offline-banner"
  99. />
  100. </div>
  101. ));
  102. OfflineBanner.displayName = 'OfflineBanner';
  103. // ── App Component ─────────────────────────────────────────────────────────────
  104. /**
  105. * App
  106. *
  107. * The top-level React component. Handles layout orchestration.
  108. *
  109. * Uses a fixed dual-panel desktop layout with ResizableLayout (40% left / 60% right).
  110. * The left panel shows ChatPanel (fixed), and the right panel dynamically switches:
  111. * - Default: ExportRecordList (shows export history)
  112. * - When document opened: EditorPanel (shows document for preview and editing)
  113. */
  114. const App: React.FC = () => {
  115. // ── Network status (Req 11.3) ────────────────────────────────────────────
  116. const isOnline = useOnlineStatus();
  117. // ── Editor state ──────────────────────────────────────────────────────────
  118. const previewDocumentId = useUIStore((state) => state.previewDocumentId);
  119. const previewDocumentName = useUIStore((state) => state.previewDocumentName);
  120. const closeDocumentPreview = useUIStore((state) => state.closeDocumentPreview);
  121. // ── Session history drawer ────────────────────────────────────────────────
  122. const [sessionListOpen, setSessionListOpen] = useState(false);
  123. // ── User ID (in a real app, this would come from auth context) ───────────
  124. const currentUserId = 'default-user';
  125. /** Stable callback to close the session drawer */
  126. const handleCloseSessionList = useCallback(() => setSessionListOpen(false), []);
  127. /** Stable callback to open the session drawer */
  128. const handleOpenSessionList = useCallback(() => setSessionListOpen(true), []);
  129. /**
  130. * Handle close editor button click
  131. */
  132. const handleCloseEditor = useCallback(() => {
  133. closeDocumentPreview();
  134. }, [closeDocumentPreview]);
  135. // ── Panels ────────────────────────────────────────────────────────────────
  136. // Each panel is wrapped in <Suspense> so the lazy chunk loads independently.
  137. // useMemo ensures the JSX elements are stable references and don't cause
  138. // Suspense to remount unnecessarily on parent re-renders (Req 13.7).
  139. const leftPanel = useMemo(() => {
  140. // Always show chat panel on the left
  141. return (
  142. <Suspense fallback={<PanelFallback />}>
  143. <ChatPanel />
  144. </Suspense>
  145. );
  146. }, []);
  147. const rightPanel = useMemo(() => {
  148. // If a document is being previewed, show EditorPanel
  149. // Otherwise show ExportRecordList (default)
  150. if (previewDocumentId) {
  151. return (
  152. <Suspense fallback={<PanelFallback />}>
  153. <EditorPanel
  154. documentId={previewDocumentId}
  155. initialDocumentName={previewDocumentName}
  156. onClose={handleCloseEditor}
  157. />
  158. </Suspense>
  159. );
  160. }
  161. // Default: show export records list
  162. return (
  163. <Suspense fallback={<PanelFallback />}>
  164. <ExportRecordList userId={currentUserId} />
  165. </Suspense>
  166. );
  167. }, [previewDocumentId, previewDocumentName, handleCloseEditor, currentUserId]);
  168. // ── Session history drawer ────────────────────────────────────────────────
  169. const sessionListDrawer = sessionListOpen ? (
  170. <Drawer
  171. title="会话历史"
  172. placement="left"
  173. width={360}
  174. open
  175. onClose={handleCloseSessionList}
  176. styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
  177. data-testid="session-list-drawer"
  178. >
  179. <Suspense fallback={<PanelFallback />}>
  180. <SessionList onSessionLoad={handleCloseSessionList} />
  181. </Suspense>
  182. </Drawer>
  183. ) : null;
  184. // ── Session history toggle button ─────────────────────────────────────────
  185. const sessionListButton = (
  186. <Button
  187. type="text"
  188. icon={<MessageOutlined />}
  189. onClick={handleOpenSessionList}
  190. title="会话历史"
  191. aria-label="打开会话历史"
  192. data-testid="open-session-list-button"
  193. />
  194. );
  195. // ── Desktop dual-panel layout (Req 12.1) ──────────────────────────────────
  196. return (
  197. <ConfigProvider locale={zhCN}>
  198. <ErrorBoundary>
  199. <div style={appStyle} data-testid="app">
  200. {/* Offline banner (Req 11.3) */}
  201. {!isOnline && <OfflineBanner />}
  202. {/* Session history drawer */}
  203. {sessionListDrawer}
  204. {/* Main two-panel layout */}
  205. <div style={mainContentStyle}>
  206. {/* Toolbar row with session history toggle */}
  207. <div
  208. style={{
  209. display: 'flex',
  210. alignItems: 'center',
  211. padding: '2px 8px',
  212. borderBottom: '1px solid #f0f0f0',
  213. backgroundColor: '#ffffff',
  214. flexShrink: 0,
  215. height: 40,
  216. gap: 8,
  217. }}
  218. data-testid="app-toolbar"
  219. >
  220. {sessionListButton}
  221. </div>
  222. {/* Resizable dual-panel layout (Req 4.1–4.5, 12.1) */}
  223. <div style={{ flex: 1, overflow: 'hidden' }}>
  224. <ResizableLayout
  225. leftPanel={leftPanel}
  226. rightPanel={rightPanel}
  227. defaultLeftWidth={40}
  228. minWidth={300}
  229. />
  230. </div>
  231. </div>
  232. </div>
  233. </ErrorBoundary>
  234. </ConfigProvider>
  235. );
  236. };
  237. export default App;