MainToolbar.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * MainToolbar.tsx - 主工具栏组件
  3. *
  4. * 编辑器顶部工具栏,包含保存、导出、关闭等操作
  5. *
  6. * @module components/Editor/toolbar
  7. */
  8. import React, { useState } from 'react';
  9. import { Button, Dropdown, Divider, message, Modal } from 'antd';
  10. import type { MenuProps } from 'antd';
  11. import { useShallow } from 'zustand/react/shallow';
  12. import {
  13. SaveOutlined,
  14. CloseOutlined,
  15. DownloadOutlined,
  16. FileWordOutlined,
  17. FilePdfOutlined,
  18. FileMarkdownOutlined,
  19. DownOutlined,
  20. LoadingOutlined,
  21. ExclamationCircleOutlined,
  22. UndoOutlined,
  23. RedoOutlined,
  24. } from '@ant-design/icons';
  25. import { useEditorStore } from '../../../stores/editorStore';
  26. import { exportToWord } from '../../../services/exportService';
  27. import { downloadExportRecord } from '../../../services/exportRecordService';
  28. import {
  29. exportBlocksToMarkdown,
  30. exportEditorToPdf,
  31. } from '../../../services/clientExportService';
  32. import './MainToolbar.css';
  33. // ══════════════════════════════════════════════════════════════════════════════
  34. // Component Props
  35. // ══════════════════════════════════════════════════════════════════════════════
  36. export interface MainToolbarProps {
  37. /** 文档标题 */
  38. documentTitle: string;
  39. /** 文档ID */
  40. documentId: string;
  41. /** 是否只读 */
  42. readOnly?: boolean;
  43. /** 保存回调 */
  44. onSave?: () => void;
  45. /** 关闭回调 */
  46. onClose?: () => void;
  47. }
  48. // ══════════════════════════════════════════════════════════════════════════════
  49. // Component
  50. // ══════════════════════════════════════════════════════════════════════════════
  51. /**
  52. * MainToolbar - 主工具栏
  53. */
  54. export const MainToolbar: React.FC<MainToolbarProps> = ({
  55. documentTitle,
  56. documentId,
  57. readOnly = false,
  58. onSave,
  59. onClose,
  60. }) => {
  61. // ── State ──────────────────────────────────────────────────────────────────
  62. const [isExporting, setIsExporting] = useState(false);
  63. // ── Editor Store ───────────────────────────────────────────────────────────
  64. // 获取文档修改状态和保存状态
  65. const {
  66. hasModified,
  67. isSaving,
  68. autoSaveEnabled,
  69. setAutoSaveEnabled,
  70. lastSaveTime,
  71. savingProgress,
  72. pastLength,
  73. futureLength,
  74. isHistoryApplying,
  75. pendingStructuralOperations,
  76. undo,
  77. redo,
  78. } = useEditorStore(useShallow((state) => ({
  79. hasModified: state.hasModified,
  80. isSaving: state.isSaving,
  81. autoSaveEnabled: state.autoSaveEnabled,
  82. setAutoSaveEnabled: state.setAutoSaveEnabled,
  83. lastSaveTime: state.lastSaveTime,
  84. savingProgress: state.savingProgress,
  85. pastLength: state.past.length,
  86. futureLength: state.future.length,
  87. isHistoryApplying: state.isHistoryApplying,
  88. pendingStructuralOperations: state.pendingStructuralOperations,
  89. undo: state.undo,
  90. redo: state.redo,
  91. })));
  92. // ── 格式化上次保存时间 ────────────────────────────────────────────────────
  93. const formatLastSaveTime = () => {
  94. if (!lastSaveTime) return '';
  95. const time = new Date(lastSaveTime);
  96. return `${time.getHours()}:${time.getMinutes().toString().padStart(2, '0')} 保存`;
  97. };
  98. // ── Export handlers ────────────────────────────────────────────────────────
  99. /**
  100. * 处理关闭操作
  101. * 如果有未保存的修改,弹出确认对话框
  102. */
  103. const handleClose = () => {
  104. if (hasModified) {
  105. Modal.confirm({
  106. title: '未保存的修改',
  107. icon: <ExclamationCircleOutlined />,
  108. content: '您有未保存的修改,确定要关闭吗?',
  109. okText: '关闭',
  110. okType: 'danger',
  111. cancelText: '取消',
  112. onOk: () => {
  113. onClose?.();
  114. },
  115. });
  116. } else {
  117. onClose?.();
  118. }
  119. };
  120. /**
  121. * 导出为Word文档
  122. */
  123. const handleExportWord = async () => {
  124. if (!documentId) {
  125. message.error('无法导出:文档ID不存在');
  126. return;
  127. }
  128. setIsExporting(true);
  129. const hideLoading = message.loading('正在导出Word文档...', 0);
  130. try {
  131. // 1. 调用导出API
  132. const response = await exportToWord({
  133. documentId,
  134. styleId: null, // 使用默认样式
  135. });
  136. hideLoading();
  137. // 2. 显示导出成功提示
  138. if (response.warning) {
  139. message.warning(response.warning);
  140. } else {
  141. message.success('导出成功!');
  142. }
  143. // 3. 触发下载
  144. // 使用downloadExportRecord来触发浏览器下载
  145. const userId = 'default-user'; // TODO: 从认证上下文获取
  146. await downloadExportRecord(response.recordId, userId);
  147. } catch (error: unknown) {
  148. hideLoading();
  149. message.error(error instanceof Error ? error.message : '导出Word文档失败');
  150. } finally {
  151. setIsExporting(false);
  152. }
  153. };
  154. const handleExportPDF = async () => {
  155. setIsExporting(true);
  156. const hideLoading = message.loading('正在生成PDF文档...', 0);
  157. try {
  158. await exportEditorToPdf(documentTitle);
  159. message.success('PDF导出成功');
  160. } catch (error) {
  161. message.error(error instanceof Error ? error.message : 'PDF导出失败');
  162. } finally {
  163. hideLoading();
  164. setIsExporting(false);
  165. }
  166. };
  167. const handleExportMarkdown = () => {
  168. exportBlocksToMarkdown(useEditorStore.getState().blocks, documentTitle);
  169. message.success('Markdown导出成功');
  170. };
  171. // 导出菜单
  172. const exportMenuItems: MenuProps['items'] = [
  173. {
  174. key: 'word',
  175. icon: <FileWordOutlined />,
  176. label: 'Word',
  177. disabled: isExporting,
  178. onClick: handleExportWord,
  179. },
  180. {
  181. key: 'pdf',
  182. icon: <FilePdfOutlined />,
  183. label: 'PDF',
  184. disabled: isExporting,
  185. onClick: handleExportPDF,
  186. },
  187. {
  188. key: 'markdown',
  189. icon: <FileMarkdownOutlined />,
  190. label: 'Markdown',
  191. disabled: isExporting,
  192. onClick: handleExportMarkdown,
  193. },
  194. ];
  195. return (
  196. <div className="main-toolbar" data-testid="main-toolbar">
  197. {/* 左侧 - 标题 */}
  198. <div className="toolbar-left">
  199. <span className="document-title" title={documentTitle}>
  200. {documentTitle || '未命名文档'}
  201. </span>
  202. {/* 修改状态指示 */}
  203. {hasModified && (
  204. <span className="modified-indicator">
  205. <span className="dot"></span>
  206. <span>未保存</span>
  207. </span>
  208. )}
  209. {/* 保存状态显示 */}
  210. {isSaving && savingProgress && (
  211. <span className="saving-indicator">
  212. <LoadingOutlined />
  213. <span>保存中 ({savingProgress.current}/{savingProgress.total})</span>
  214. </span>
  215. )}
  216. {/* 自动保存时间显示 */}
  217. {!hasModified && !isSaving && lastSaveTime && (
  218. <span className="saved-time" title={new Date(lastSaveTime).toLocaleString()}>
  219. {formatLastSaveTime()}
  220. </span>
  221. )}
  222. </div>
  223. {/* 右侧 - 操作按钮 */}
  224. <div className="toolbar-right">
  225. {!readOnly && (
  226. <>
  227. <Button
  228. type="text"
  229. size="small"
  230. icon={<UndoOutlined />}
  231. onClick={undo}
  232. disabled={pastLength === 0 || isSaving || isHistoryApplying || pendingStructuralOperations > 0}
  233. title="撤销 (Ctrl/Cmd+Z)"
  234. aria-label="撤销"
  235. />
  236. <Button
  237. type="text"
  238. size="small"
  239. icon={<RedoOutlined />}
  240. onClick={redo}
  241. disabled={futureLength === 0 || isSaving || isHistoryApplying || pendingStructuralOperations > 0}
  242. title="重做 (Ctrl/Cmd+Shift+Z)"
  243. aria-label="重做"
  244. />
  245. {/* 自动保存切换 */}
  246. <Button
  247. type={autoSaveEnabled ? 'default' : 'text'}
  248. size="small"
  249. onClick={() => setAutoSaveEnabled(!autoSaveEnabled)}
  250. title={autoSaveEnabled ? '关闭自动保存' : '开启自动保存'}
  251. >
  252. {autoSaveEnabled ? '自动保存' : '手动保存'}
  253. </Button>
  254. {/* 保存 */}
  255. <Button
  256. type="primary"
  257. size="small"
  258. icon={isSaving ? <LoadingOutlined /> : <SaveOutlined />}
  259. onClick={onSave}
  260. disabled={!hasModified || isSaving}
  261. loading={isSaving}
  262. title={
  263. hasModified
  264. ? '文档已修改,点击保存'
  265. : autoSaveEnabled
  266. ? '文档未修改(已启用自动保存)'
  267. : '文档未修改'
  268. }
  269. >
  270. 保存
  271. </Button>
  272. </>
  273. )}
  274. {/* 导出 */}
  275. <Dropdown menu={{ items: exportMenuItems }} placement="bottomRight" disabled={isExporting}>
  276. <Button
  277. type="default"
  278. size="small"
  279. icon={isExporting ? <LoadingOutlined /> : <DownloadOutlined />}
  280. loading={isExporting}
  281. >
  282. 导出 <DownOutlined />
  283. </Button>
  284. </Dropdown>
  285. <Divider type="vertical" style={{ margin: '0 8px' }} />
  286. {/* 关闭 */}
  287. <Button
  288. type="text"
  289. size="small"
  290. icon={<CloseOutlined />}
  291. onClick={handleClose}
  292. title="关闭编辑器"
  293. />
  294. </div>
  295. </div>
  296. );
  297. };
  298. export default MainToolbar;