完成!我已经成功在ModalLeftPanel和ModalRightPanel之间添加了可拖动的分隔条功能。
实现的功能:
1.状态管理(第 197、200 行)
- 添加了
leftPanelWidthstate 来追踪左侧面板的宽度百分比(默认 50%) - 添加了
isDraggingRefref 来追踪是否正在拖动
2.拖动事件处理(第 247-280 行)
handleMouseDown: 鼠标按下时开始拖动,改变光标样式handleMouseMove: 鼠标移动时计算新的面板宽度- 限制宽度范围在 20% - 80% 之间
- 考虑了侧边栏宽度(75px)
handleMouseUp: 鼠标释放时结束拖动,恢复光标样式- 使用
useEffect注册和清理全局事件监听器
3.分隔条组件(第 1257-1278 行)
const Resizer = styled.div` width: 5px; background: var(--color-border); cursor: col-resize; position: relative; flex-shrink: 0; transition: background 0.2s; &:hover { background: var(--color-primary); // 悬停时高亮显示 }4.面板样式更新(第 1243-1255、738、753 行)
- 移除了
flex: 1,改为使用动态宽度 - 添加了
overflow: hidden防止内容溢出 - 在 JSX 中应用动态宽度:
- 左侧面板:
style={{ width: ${leftPanelWidth}% }} - 右侧面板:
style={{ width: ${100 - leftPanelWidth}% }}
- 左侧面板:
使用效果:
✅可拖动分隔条:用户可以拖动中间的分隔条
✅视觉反馈:鼠标悬停时分隔条会高亮显示为主题色
✅宽度限制:限制在 20%-80% 之间,确保两侧面板都有足够空间
✅流畅体验:拖动时光标变为col-resize,提供清晰的交互提示
✅响应式:自动适应窗口大小变化
用户现在可以根据自己的需求自由调整左右面板的大小,获得更好的阅读和对比体验!
export const PaperChat: React.FC<PaperChatProps> = ({ url, onClose, config }) => { const messageListRef = useRef<HTMLDivElement>(null) const isDraggingRef = useRef(false) // 是否正在拖动分隔条 // 处理分隔条拖动 const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault() isDraggingRef.current = true document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' } const handleMouseMove = React.useCallback((e: MouseEvent) => { if (!isDraggingRef.current) return const containerWidth = window.innerWidth - 75 // 减去侧边栏宽度 const newLeftWidth = ((e.clientX - 75) / containerWidth) * 100 // 限制最小和最大宽度(20% - 80%) if (newLeftWidth >= 20 && newLeftWidth <= 80) { setLeftPanelWidth(newLeftWidth) } }, []) const handleMouseUp = React.useCallback(() => { isDraggingRef.current = false document.body.style.cursor = '' document.body.style.userSelect = '' }, []) return ( <div style={{ background: 'var(--color-background)', display: 'flex', position: 'relative', flexDirection: 'column', height: '100%' }}> {/* 顶部模式切换按钮 */} <TopModeToggle> <ModeToggleButton type="default" onClick={() => setPolishMode(polishMode === 'paragraph' ? 'article' : 'paragraph')}> <TranslationOutlined /> {polishMode === 'paragraph' ? config.paragraphModeText : config.articleModeText} </ModeToggleButton> </TopModeToggle> <ComponentContainer> {polishMode === 'paragraph' ? renderParagraphMode() : renderArticleMode()} </ComponentContainer> {/* 文章模式结果弹窗 - 全屏双栏布局(露出左侧菜单栏) */} <Modal title={config.articleModalTitle} open={modalVisible} onCancel={() => setModalVisible(false)} centered={false} zIndex={1000} maskStyle={{ left: 'var(--sidebar-width, 75px)' }} footer={[ <Button key="close" onClick={() => setModalVisible(false)}> 关闭 </Button>, <Button key="copy" type="primary" onClick={() => { navigator.clipboard.writeText(modalContent) message.success('已复制到剪贴板') }} disabled={!modalContent}> 复制结果 </Button> ]} width="calc(100vw - var(--sidebar-width, 75px))" style={{ top: 0, paddingBottom: 0, maxWidth: 'calc(100vw - var(--sidebar-width, 75px))', margin: 0, position: 'absolute', right: 0 }} styles={{ body: { height: 'calc(100vh - 110px)', padding: 0, overflow: 'hidden' }, content: { height: '100vh', borderRadius: 0 }, wrapper: { left: 'var(--sidebar-width, 75px)', right: 0 } }}> <ModalSplitContainer> {/* 左侧:原文内容 */} <ModalLeftPanel style={{ width: `${leftPanelWidth}%` }}> <ModalPanelHeader>原文内容</ModalPanelHeader> <ModalPanelContent> {originalArticleContent ? ( <OriginalContentWrapper>{originalArticleContent}</OriginalContentWrapper> ) : ( <EmptyHint>正在读取文件内容...</EmptyHint> )} </ModalPanelContent> </ModalLeftPanel> {/* 可拖动的分隔条 */} <Resizer onMouseDown={handleMouseDown} /> {/* 右侧:处理结果 */} <ModalRightPanel style={{ width: `${100 - leftPanelWidth}%` }}> <ModalPanelHeader>处理结果</ModalPanelHeader> <ModalPanelContent ref={modalContentRef}> {articleLoading && !modalContent && !modalReasoningContent ? ( <ModalLoadingWrapper> <Spin size="large" /> <span>{config.articleLoadingText}</span> </ModalLoadingWrapper> ) : ( <ModalContentWrapper> {/* 思考链折叠组件 */} {modalReasoningContent && ( <ModalThinkingBlock reasoningContent={modalReasoningContent} isThinking={modalIsThinking} hasContent={!!modalContent} /> )} {typeof modalContent === 'string' && modalContent && ( <MarkdownContent> <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}> {modalContent} </ReactMarkdown> </MarkdownContent> )} {articleLoading && ( <StreamingIndicator> <Spin size="small" /> <span>正在生成中...</span> </StreamingIndicator> )} </ModalContentWrapper> )} </ModalPanelContent> </ModalRightPanel> </ModalSplitContainer> </Modal> </div> ) } const IconCircle = styled.div` width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(135deg, #5b7cfb 0%, #6a55f6 100%); color: #ffffff; display: flex; align-items: center; justify-content: center; font-size: 22px; box-shadow: 0 8px 16px rgba(106, 85, 246, 0.2); ` const HeaderTitle = styled.h1` margin: 0; font-size: 18px; line-height: 1.4; font-weight: 600; color: var(--color-text); ` const HeaderTitleRow = styled.div` display: flex; align-items: flex-start; gap: 12px; ` const Header = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-bottom: 16px; ` const Content = styled.div` flex: 1; display: flex; flex-direction: row; align-items: stretch; gap: 20px; box-sizing: border-box; min-height: 0; overflow: hidden; ` const LeftColumn = styled.div` flex: 2; display: flex; flex-direction: column; background: #fff; border-radius: 24px; padding: 20px 20px 18px; box-sizing: border-box; ` const RightColumn = styled.div` flex: 2; display: flex; flex-direction: column; background: var(--color-background-soft); border-radius: 24px; padding: 20px 20px 12px; box-sizing: border-box; min-height: 0; overflow: hidden; ` const MessagesContainer = styled.div` flex: 1; overflow-y: auto; padding: 12px 12px 8px; border-radius: 16px; background: rgba(255, 255, 255, 0.02); min-height: 0; ` const MessageRow = styled.div<{ $type: 'user' | 'assistant' }>` display: flex; flex-direction: column; align-items: ${(props) => (props.$type === 'user' ? 'flex-end' : 'flex-start')}; margin-bottom: 12px; ` const MessageBubble = styled.div<{ $type: 'user' | 'assistant'; $error?: boolean }>` max-width: 100%; padding: 10px 14px; border-radius: 8px; font-size: 14px; word-break: break-word; background-color: ${(props) => props.$error ? '#fff1f0' : props.$type === 'user' ? 'var(--color-primary)' : '#f5f5f5'}; color: ${(props) => (props.$type === 'user' ? '#ffffff' : 'var(--color-text-1)')}; ` const MessageTime = styled.div` margin-top: 4px; font-size: 11px; color: var(--color-text-3); ` const EmptyHint = styled.div` font-size: 13px; color: var(--color-text-2); text-align: left; ` const SearchCard = styled.div` flex: 1; border: 1px dashed rgba(148, 163, 184, 0.35); border-radius: 20px; padding: 18px 18px 14px; margin-top: 10px; display: flex; flex-direction: column; min-height: 0; ` const SearchActions = styled.div` display: flex; justify-content: space-between; align-items: center; border-radius: 10px; background: var(--color-background-soft); padding: 8px 12px; margin-bottom: 12px; ` const InputArea = styled.div` display: flex; flex-direction: column; flex: 1; min-height: 0; border: 1px silod var(--color-primary); ` const SendButton = styled(Button)` min-width: 32px; width: 32px; height: 32px; padding: 0; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; background-color: transparent; color: var(--color-text-2); box-shadow: none; &:hover { background-color: rgba(0, 0, 0, 0.06); color: var(--color-primary); } &:disabled { background-color: transparent; color: var(--color-text-4); } ` const HeaderIndex = styled.div` font-size: 12px; color: var(--color-text-3); margin-bottom: 4px; ` const HeaderSubtitle = styled.div` font-size: 12px; color: var(--color-text-2); margin-top: 4px; ` const EditorFooter = styled.div` display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid rgba(148, 163, 184, 0.35); ` const FooterLeft = styled.div` display: flex; gap: 8px; flex-wrap: wrap; align-items: center; ` const FooterRight = styled.div` display: flex; align-items: center; ` const LangToggleButton = styled(Button)` height: 30px; padding: 0 14px; border-radius: 999px; font-size: 12px; border: 1px solid #e2e8f0; background-color: transparent; color: var(--color-text-2); display: flex; align-items: center; &:hover { border-color: #cbd5e1; color: var(--color-text-2); background-color: transparent; } ` const ModelSelectButton = styled(Button)` height: 30px; padding: 0 12px; border-radius: 999px; font-size: 12px; color: var(--color-text); border: 1px solid #79a3f8; display: flex; align-items: center; background-color: transparent; &:hover { color: var(--color-primary); border-color: #1962f4; background-color: transparent; } ` const ModeToggleButton = styled(Button)` height: 32px; padding: 0 16px; border-radius: 999px; font-size: 13px; border: none; background: linear-gradient(90deg, #79a3f8 0%, #1962f4 100%); color: #fff; &:hover { color: #5571f1ff !important; } ` const FooterTag = styled.div` font-size: 12px; color: var(--color-text-2); padding: 0; border-radius: 0; ` const ResultHeader = styled.div` display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; ` const ResultTitle = styled.div` font-size: 20px; font-weight: 700; color: var(--color-text); ` const ResultFooter = styled.div` display: flex; justify-content: flex-end; align-items: center; margin-top: 8px; ` const ResultFooterLeft = styled.div` display: flex; align-items: center; gap: 6px; font-size: 12px; ` const ResultFooterText = styled.span` color: var(--color-text-2); ` // 顶部模式切换容器 const TopModeToggle = styled.div` display: flex; justify-content: flex-end; padding: 16px 24px 0; ` // 文章润色相关样式 const ArticleContent = styled.div` flex: 1; display: flex; flex-direction: column; gap: 20px; box-sizing: border-box; ` const ArticleHeader = styled.div` display: flex; flex-direction: column; align-items: flex-start; gap: 4px; ` const ArticleInputSection = styled.div` display: flex; flex-direction: column; gap: 8px; background: #fff; border-radius: 16px; padding: 16px; ` const ArticleInputLabel = styled.div` font-size: 14px; font-weight: 500; color: var(--color-text); ` const ArticleInputFooter = styled.div` display: flex; justify-content: flex-end; margin-top: 8px; ` const UploadSection = styled.div` flex: 1; display: flex; flex-direction: column; background: #fff; border-radius: 16px; padding: 20px; min-height: 200px; .ant-upload-wrapper, .ant-upload-drag { height: 100%; } .ant-upload-drag { background: #eef4fe; border-color: #b7d2fe; border-radius: 12px; &:hover { border-color: var(--color-primary); } } ` const UploadIcon = styled.div` font-size: 48px; color: var(--color-primary); margin-bottom: 16px; ` const UploadText = styled.div` font-size: 16px; color: var(--color-text); margin-bottom: 8px; ` const UploadHint = styled.div` font-size: 13px; color: var(--color-text-2); ` const UploadedFileName = styled.div` margin-top: 12px; font-size: 13px; color: var(--color-primary); padding: 6px 12px; background: rgba(59, 130, 246, 0.1); border-radius: 6px; display: inline-block; ` const ModalLoadingWrapper = styled.div` display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 40px; color: var(--color-text-2); ` const ModalContentWrapper = styled.div` word-break: break-word; font-size: 14px; line-height: 1.8; color: var(--color-text); ` const StreamingIndicator = styled.div` display: flex; align-items: center; gap: 8px; margin-top: 16px; padding: 8px 12px; background: rgba(59, 130, 246, 0.08); border-radius: 6px; font-size: 13px; color: var(--color-primary); ` // 全屏弹窗双栏布局样式 const ModalSplitContainer = styled.div` display: flex; height: 100%; gap: 0; ` const ModalLeftPanel = styled.div` display: flex; flex-direction: column; min-width: 0; overflow: hidden; ` const ModalRightPanel = styled.div` display: flex; flex-direction: column; min-width: 0; overflow: hidden; ` const Resizer = styled.div` width: 5px; background: var(--color-border); cursor: col-resize; position: relative; flex-shrink: 0; transition: background 0.2s; &:hover { background: var(--color-primary); } &::before { content: ''; position: absolute; top: 0; left: -2px; right: -2px; bottom: 0; /* 扩大可点击区域 */ } ` const ModalPanelHeader = styled.div` padding: 12px 20px; font-size: 14px; font-weight: 600; color: var(--color-text); background: var(--color-background-soft); border-bottom: 1px solid var(--color-border); flex-shrink: 0; ` const ModalPanelContent = styled.div` flex: 1; overflow-y: auto; padding: 20px; min-height: 0; ` const OriginalContentWrapper = styled.div` font-size: 14px; line-height: 1.8; color: var(--color-text); white-space: pre-wrap; word-break: break-word; ` // 思考链相关样式 const ThinkingCollapseContainer = styled(Collapse)` margin-bottom: 10px; width: 100%; background: transparent; border: 1px solid rgba(146, 84, 222, 0.3); border-radius: 8px; .ant-collapse-header { padding: 8px 12px !important; background: rgba(146, 84, 222, 0.08); border-radius: 8px !important; } .ant-collapse-content { background: rgba(146, 84, 222, 0.04); border-top: 1px solid rgba(146, 84, 222, 0.2); } .ant-collapse-content-box { padding: 12px !important; } .ant-collapse-item { border: none; } ` const ThinkingLabel = styled.div` display: flex; align-items: center; gap: 12px; ` const ThinkingText = styled.span` font-size: 13px; color: #9254de; font-weight: 500; ` const ThinkingContent = styled.div` font-size: 13px; line-height: 1.6; color: var(--color-text-2); white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; ` // Markdown内容样式 const MarkdownContent = styled.div` font-size: 14px; line-height: 1.8; color: var(--color-text-1); p { margin: 0 0 12px 0; &:last-child { margin-bottom: 0; } } h1, h2, h3, h4, h5, h6 { margin: 16px 0 8px 0; font-weight: 600; line-height: 1.4; &:first-child { margin-top: 0; } } h1 { font-size: 1.5em; } h2 { font-size: 1.3em; } h3 { font-size: 1.1em; } ul, ol { margin: 8px 0; padding-left: 24px; } li { margin: 4px 0; } table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; } th, td { border: 1px solid var(--color-border); padding: 8px 12px; text-align: left; } th { background: var(--color-background-soft); font-weight: 600; } code { background: rgba(0, 0, 0, 0.06); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; } pre { background: rgba(0, 0, 0, 0.06); padding: 12px; border-radius: 6px; overflow-x: auto; margin: 12px 0; code { background: none; padding: 0; } } blockquote { margin: 12px 0; padding: 8px 16px; border-left: 4px solid var(--color-primary); background: rgba(59, 130, 246, 0.06); color: var(--color-text-2); } strong { font-weight: 600; } a { color: var(--color-primary); text-decoration: none; &:hover { text-decoration: underline; } } hr { border: none; border-top: 1px solid var(--color-border); margin: 16px 0; } ` export default PaperPolishChat