| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- /**
- * MainToolbar.tsx - 主工具栏组件
- *
- * 编辑器顶部工具栏,包含保存、导出、关闭等操作
- *
- * @module components/Editor/toolbar
- */
- import React, { useState } from 'react';
- import { Button, Dropdown, Divider, message, Modal } from 'antd';
- import type { MenuProps } from 'antd';
- import { useShallow } from 'zustand/react/shallow';
- import {
- SaveOutlined,
- CloseOutlined,
- DownloadOutlined,
- FileWordOutlined,
- FilePdfOutlined,
- FileMarkdownOutlined,
- DownOutlined,
- LoadingOutlined,
- ExclamationCircleOutlined,
- UndoOutlined,
- RedoOutlined,
- } from '@ant-design/icons';
- import { useEditorStore } from '../../../stores/editorStore';
- import { exportToWord } from '../../../services/exportService';
- import { downloadExportRecord } from '../../../services/exportRecordService';
- import { exportBlocksToMarkdown, exportEditorToPdf } from '../../../services/clientExportService';
- import './MainToolbar.css';
- // ══════════════════════════════════════════════════════════════════════════════
- // Component Props
- // ══════════════════════════════════════════════════════════════════════════════
- export interface MainToolbarProps {
- /** 文档标题 */
- documentTitle: string;
- /** 文档ID */
- documentId: string;
- /** 是否只读 */
- readOnly?: boolean;
- /** 保存回调 */
- onSave?: () => void;
- /** 关闭回调 */
- onClose?: () => void;
- }
- // ══════════════════════════════════════════════════════════════════════════════
- // Component
- // ══════════════════════════════════════════════════════════════════════════════
- /**
- * MainToolbar - 主工具栏
- */
- export const MainToolbar: React.FC<MainToolbarProps> = ({
- documentTitle,
- documentId,
- readOnly = false,
- onSave,
- onClose,
- }) => {
- // ── State ──────────────────────────────────────────────────────────────────
- const [isExporting, setIsExporting] = useState(false);
- // ── Editor Store ───────────────────────────────────────────────────────────
- // 获取文档修改状态和保存状态
- const {
- hasModified,
- failedBlocks,
- isSaving,
- autoSaveEnabled,
- setAutoSaveEnabled,
- lastSaveTime,
- savingProgress,
- pastLength,
- futureLength,
- isHistoryApplying,
- pendingStructuralOperations,
- undo,
- redo,
- } = useEditorStore(
- useShallow((state) => ({
- hasModified: state.hasModified,
- failedBlocks: state.failedBlocks,
- isSaving: state.isSaving,
- autoSaveEnabled: state.autoSaveEnabled,
- setAutoSaveEnabled: state.setAutoSaveEnabled,
- lastSaveTime: state.lastSaveTime,
- savingProgress: state.savingProgress,
- pastLength: state.past.length,
- futureLength: state.future.length,
- isHistoryApplying: state.isHistoryApplying,
- pendingStructuralOperations: state.pendingStructuralOperations,
- undo: state.undo,
- redo: state.redo,
- }))
- );
- // ── 格式化上次保存时间 ────────────────────────────────────────────────────
- const formatLastSaveTime = () => {
- if (!lastSaveTime) return '';
- const time = new Date(lastSaveTime);
- return `${time.getHours()}:${time.getMinutes().toString().padStart(2, '0')} 保存`;
- };
- // ── Export handlers ────────────────────────────────────────────────────────
- /**
- * 处理关闭操作
- * 如果有未保存的修改,弹出确认对话框
- */
- const handleClose = () => {
- if (hasModified || isSaving || failedBlocks.length > 0) {
- Modal.confirm({
- title: isSaving ? '文档正在保存' : '未保存的修改',
- icon: <ExclamationCircleOutlined />,
- content: isSaving
- ? '保存请求尚未完成,关闭可能导致最新修改未写入服务器。确定要关闭吗?'
- : '您有未保存或保存失败的修改,确定要关闭吗?',
- okText: '关闭',
- okType: 'danger',
- cancelText: '取消',
- onOk: () => {
- onClose?.();
- },
- });
- } else {
- onClose?.();
- }
- };
- /**
- * 导出为Word文档
- */
- const handleExportWord = async () => {
- if (!documentId) {
- message.error('无法导出:文档ID不存在');
- return;
- }
- setIsExporting(true);
- const hideLoading = message.loading('正在导出Word文档...', 0);
- try {
- // 1. 调用导出API
- const response = await exportToWord({
- documentId,
- styleId: null, // 使用默认样式
- });
- hideLoading();
- // 2. 显示导出成功提示
- if (response.warning) {
- message.warning(response.warning);
- } else {
- message.success('导出成功!');
- }
- // 3. 触发下载
- // 使用downloadExportRecord来触发浏览器下载
- const userId = 'default-user'; // TODO: 从认证上下文获取
- await downloadExportRecord(response.recordId, userId);
- } catch (error: unknown) {
- hideLoading();
- message.error(error instanceof Error ? error.message : '导出Word文档失败');
- } finally {
- setIsExporting(false);
- }
- };
- const handleExportPDF = async () => {
- setIsExporting(true);
- const hideLoading = message.loading('正在生成PDF文档...', 0);
- try {
- await exportEditorToPdf(documentTitle);
- message.success('PDF导出成功');
- } catch (error) {
- message.error(error instanceof Error ? error.message : 'PDF导出失败');
- } finally {
- hideLoading();
- setIsExporting(false);
- }
- };
- const handleExportMarkdown = () => {
- exportBlocksToMarkdown(useEditorStore.getState().blocks, documentTitle);
- message.success('Markdown导出成功');
- };
- // 导出菜单
- const exportMenuItems: MenuProps['items'] = [
- {
- key: 'word',
- icon: <FileWordOutlined />,
- label: 'Word',
- disabled: isExporting,
- onClick: handleExportWord,
- },
- {
- key: 'pdf',
- icon: <FilePdfOutlined />,
- label: 'PDF',
- disabled: isExporting,
- onClick: handleExportPDF,
- },
- {
- key: 'markdown',
- icon: <FileMarkdownOutlined />,
- label: 'Markdown',
- disabled: isExporting,
- onClick: handleExportMarkdown,
- },
- ];
- return (
- <div className="main-toolbar" data-testid="main-toolbar">
- {/* 左侧 - 标题 */}
- <div className="toolbar-left">
- <span className="document-title" title={documentTitle}>
- {documentTitle || '未命名文档'}
- </span>
- {/* 修改状态指示 */}
- {hasModified && (
- <span className="modified-indicator">
- <span className="dot"></span>
- <span>未保存</span>
- </span>
- )}
- {/* 保存状态显示 */}
- {isSaving && savingProgress && (
- <span className="saving-indicator">
- <LoadingOutlined />
- <span>
- 保存中 ({savingProgress.current}/{savingProgress.total})
- </span>
- </span>
- )}
- {/* 自动保存时间显示 */}
- {!hasModified && !isSaving && lastSaveTime && (
- <span className="saved-time" title={new Date(lastSaveTime).toLocaleString()}>
- {formatLastSaveTime()}
- </span>
- )}
- </div>
- {/* 右侧 - 操作按钮 */}
- <div className="toolbar-right">
- {!readOnly && (
- <>
- <Button
- type="text"
- size="small"
- icon={<UndoOutlined />}
- onClick={undo}
- disabled={
- pastLength === 0 || isSaving || isHistoryApplying || pendingStructuralOperations > 0
- }
- title="撤销 (Ctrl/Cmd+Z)"
- aria-label="撤销"
- />
- <Button
- type="text"
- size="small"
- icon={<RedoOutlined />}
- onClick={redo}
- disabled={
- futureLength === 0 ||
- isSaving ||
- isHistoryApplying ||
- pendingStructuralOperations > 0
- }
- title="重做 (Ctrl/Cmd+Shift+Z)"
- aria-label="重做"
- />
- {/* 自动保存切换 */}
- <Button
- type={autoSaveEnabled ? 'default' : 'text'}
- size="small"
- onClick={() => setAutoSaveEnabled(!autoSaveEnabled)}
- title={autoSaveEnabled ? '关闭自动保存' : '开启自动保存'}
- >
- {autoSaveEnabled ? '自动保存' : '手动保存'}
- </Button>
- {/* 保存 */}
- <Button
- type="primary"
- size="small"
- icon={isSaving ? <LoadingOutlined /> : <SaveOutlined />}
- onClick={onSave}
- disabled={!hasModified || isSaving}
- loading={isSaving}
- title={
- hasModified
- ? '文档已修改,点击保存'
- : autoSaveEnabled
- ? '文档未修改(已启用自动保存)'
- : '文档未修改'
- }
- >
- 保存
- </Button>
- </>
- )}
- {/* 导出 */}
- <Dropdown menu={{ items: exportMenuItems }} placement="bottomRight" disabled={isExporting}>
- <Button
- type="default"
- size="small"
- icon={isExporting ? <LoadingOutlined /> : <DownloadOutlined />}
- loading={isExporting}
- >
- 导出 <DownOutlined />
- </Button>
- </Dropdown>
- <Divider type="vertical" style={{ margin: '0 8px' }} />
- {/* 关闭 */}
- <Button
- type="text"
- size="small"
- icon={<CloseOutlined />}
- onClick={handleClose}
- title="关闭编辑器"
- />
- </div>
- </div>
- );
- };
- export default MainToolbar;
|