MainToolbar.tsx 11 KB

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