feature:新增章节内容-局部重写功能,支持扩展内容

This commit is contained in:
xiamuceer-j
2026-01-29 15:33:43 +08:00
parent 997235550c
commit fe94dc3a51
7 changed files with 1314 additions and 5 deletions
@@ -0,0 +1,450 @@
import React, { useState, useRef, useEffect } from 'react';
import { Modal, Input, Button, Space, Radio, InputNumber, Card, message, Alert, Spin, Typography, Divider } from 'antd';
import { ThunderboltOutlined, CheckOutlined, ReloadOutlined, EditOutlined, LoadingOutlined } from '@ant-design/icons';
import { chapterApi } from '../services/api';
const { TextArea } = Input;
const { Text, Paragraph } = Typography;
interface PartialRegenerateModalProps {
visible: boolean;
chapterId: string;
selectedText: string;
startPosition: number;
endPosition: number;
styleId?: number;
onClose: () => void;
onApply: (newText: string, startPosition: number, endPosition: number) => void;
}
type LengthMode = 'similar' | 'expand' | 'condense' | 'custom';
/**
* 局部重写弹窗组件
* 用于配置和执行选中文本的AI重写
*/
export const PartialRegenerateModal: React.FC<PartialRegenerateModalProps> = ({
visible,
chapterId,
selectedText,
startPosition,
endPosition,
styleId,
onClose,
onApply,
}) => {
const [userInstructions, setUserInstructions] = useState('');
const [lengthMode, setLengthMode] = useState<LengthMode>('similar');
const [customWordCount, setCustomWordCount] = useState<number>(selectedText.length);
const [isGenerating, setIsGenerating] = useState(false);
const [generatedText, setGeneratedText] = useState('');
const [hasGenerated, setHasGenerated] = useState(false);
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const abortControllerRef = useRef<AbortController | null>(null);
const generatedTextRef = useRef<HTMLDivElement>(null);
// 重置状态
useEffect(() => {
if (visible) {
setUserInstructions('');
setLengthMode('similar');
setCustomWordCount(selectedText.length);
setIsGenerating(false);
setGeneratedText('');
setHasGenerated(false);
setProgress(0);
setProgressMessage('');
}
}, [visible, selectedText.length]);
// 自动滚动到底部
useEffect(() => {
if (generatedTextRef.current && isGenerating) {
generatedTextRef.current.scrollTop = generatedTextRef.current.scrollHeight;
}
}, [generatedText, isGenerating]);
const handleGenerate = async () => {
if (!userInstructions.trim()) {
message.warning('请输入重写要求');
return;
}
setIsGenerating(true);
setGeneratedText('');
setProgress(0);
setProgressMessage('准备生成...');
// 创建 AbortController 用于取消请求
abortControllerRef.current = new AbortController();
try {
await chapterApi.partialRegenerateStream(
chapterId,
{
selected_text: selectedText,
start_position: startPosition,
end_position: endPosition,
user_instructions: userInstructions,
context_chars: 500,
style_id: styleId,
length_mode: lengthMode,
target_word_count: lengthMode === 'custom' ? customWordCount : undefined,
},
{
onProgress: (msg, prog) => {
setProgress(prog);
setProgressMessage(msg);
},
onChunk: (content) => {
setGeneratedText(prev => prev + content);
},
onResult: () => {
setProgress(100);
setProgressMessage('生成完成');
setHasGenerated(true);
},
onError: (error) => {
console.error('SSE错误:', error);
message.error(error || '生成过程中发生错误');
},
onComplete: () => {
setIsGenerating(false);
setHasGenerated(true);
},
}
);
} catch (error) {
console.error('生成失败:', error);
if ((error as Error).name !== 'AbortError') {
message.error('生成失败,请重试');
}
setIsGenerating(false);
}
};
const handleCancel = () => {
if (isGenerating && abortControllerRef.current) {
abortControllerRef.current.abort();
setIsGenerating(false);
message.info('已取消生成');
}
onClose();
};
const handleAccept = async () => {
if (!generatedText.trim()) {
message.warning('没有可应用的内容');
return;
}
try {
// 调用后端应用更改
await chapterApi.applyPartialRegenerate(chapterId, {
new_text: generatedText,
start_position: startPosition,
end_position: endPosition,
});
message.success('已应用重写内容');
onApply(generatedText, startPosition, endPosition);
onClose();
} catch (error) {
console.error('应用失败:', error);
message.error('应用失败,请重试');
}
};
const handleRegenerate = () => {
setGeneratedText('');
setHasGenerated(false);
setProgress(0);
setProgressMessage('');
handleGenerate();
};
const getLengthModeDescription = (mode: LengthMode): string => {
const descriptions: Record<LengthMode, string> = {
similar: '保持与原文相近的长度',
expand: '扩展内容,增加更多细节',
condense: '精简内容,保留核心要点',
custom: '指定目标字数',
};
return descriptions[mode];
};
return (
<Modal
title={
<Space>
<EditOutlined style={{ color: 'var(--color-primary)' }} />
<span>AI局部重写</span>
</Space>
}
open={visible}
onCancel={handleCancel}
width={800}
centered
maskClosable={!isGenerating}
closable={!isGenerating}
keyboard={!isGenerating}
footer={
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={handleCancel} disabled={isGenerating}>
</Button>
{!hasGenerated ? (
<Button
type="primary"
icon={isGenerating ? <LoadingOutlined /> : <ThunderboltOutlined />}
onClick={handleGenerate}
loading={isGenerating}
disabled={!userInstructions.trim()}
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
border: 'none',
boxShadow: '0 4px 12px rgba(77, 128, 136, 0.3)',
}}
>
{isGenerating ? '生成中...' : '开始重写'}
</Button>
) : (
<>
<Button
icon={<ReloadOutlined />}
onClick={handleRegenerate}
>
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleAccept}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
</Button>
</>
)}
</Space>
}
styles={{
body: {
maxHeight: 'calc(100vh - 200px)',
overflowY: 'auto',
},
}}
>
{/* 原文展示 */}
<Card
size="small"
title={
<Space>
<Text strong></Text>
<Text type="secondary">({selectedText.length})</Text>
</Space>
}
style={{ marginBottom: 16 }}
styles={{
body: {
maxHeight: 150,
overflowY: 'auto',
background: '#fafafa',
},
}}
>
<Paragraph
style={{
margin: 0,
whiteSpace: 'pre-wrap',
color: '#595959',
lineHeight: 1.8,
}}
>
{selectedText}
</Paragraph>
</Card>
{/* 重写要求输入 */}
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
<Text type="danger">*</Text>
</Text>
<TextArea
value={userInstructions}
onChange={(e) => setUserInstructions(e.target.value)}
placeholder="请描述您希望如何重写这段内容,例如:&#10;- 让描写更加生动细腻&#10;- 增加环境氛围描写&#10;- 加强角色心理活动&#10;- 改变叙事节奏,更加紧凑&#10;- 添加对话内容"
rows={4}
disabled={isGenerating}
style={{ resize: 'none' }}
/>
</div>
{/* 长度模式选择 */}
<div style={{ marginBottom: 16 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<Radio.Group
value={lengthMode}
onChange={(e) => setLengthMode(e.target.value)}
disabled={isGenerating}
buttonStyle="solid"
>
<Radio.Button value="similar"></Radio.Button>
<Radio.Button value="expand"></Radio.Button>
<Radio.Button value="condense"></Radio.Button>
<Radio.Button value="custom"></Radio.Button>
</Radio.Group>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{getLengthModeDescription(lengthMode)}
</Text>
</div>
{lengthMode === 'custom' && (
<div style={{ marginTop: 12 }}>
<Space>
<Text></Text>
<InputNumber
value={customWordCount}
onChange={(value) => setCustomWordCount(value || selectedText.length)}
min={10}
max={10000}
step={50}
disabled={isGenerating}
addonAfter="字"
style={{ width: 150 }}
/>
</Space>
</div>
)}
</div>
<Divider style={{ margin: '16px 0' }} />
{/* 生成结果展示 */}
{(isGenerating || hasGenerated) && (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
}}>
<Space>
<Text strong></Text>
{generatedText && (
<Text type="secondary">({generatedText.length})</Text>
)}
</Space>
{isGenerating && (
<Space>
<Spin indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />} />
<Text type="secondary">{progressMessage || '生成中...'}</Text>
</Space>
)}
</div>
{/* 进度条 */}
{isGenerating && (
<div style={{ marginBottom: 12 }}>
<div
style={{
height: 4,
background: '#f0f0f0',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
background: 'linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
width: `${progress}%`,
transition: 'width 0.3s ease',
borderRadius: 2,
}}
/>
</div>
</div>
)}
<Card
size="small"
ref={generatedTextRef}
style={{
background: generatedText ? '#f6ffed' : '#fafafa',
border: generatedText ? '1px solid #b7eb8f' : '1px solid #d9d9d9',
}}
styles={{
body: {
maxHeight: 250,
overflowY: 'auto',
minHeight: 100,
},
}}
>
{generatedText ? (
<Paragraph
style={{
margin: 0,
whiteSpace: 'pre-wrap',
lineHeight: 1.8,
}}
>
{generatedText}
{isGenerating && (
<span
style={{
display: 'inline-block',
width: 8,
height: 16,
background: 'var(--color-primary)',
marginLeft: 2,
animation: 'blink 1s infinite',
}}
/>
)}
</Paragraph>
) : (
<div style={{ textAlign: 'center', padding: 20, color: '#8c8c8c' }}>
{isGenerating ? '正在生成内容...' : '等待生成...'}
</div>
)}
</Card>
{hasGenerated && generatedText && (
<Alert
message="生成完成"
description={
<span>
{selectedText.length} {generatedText.length}
{generatedText.length > selectedText.length && (
<Text type="success"> (+{generatedText.length - selectedText.length})</Text>
)}
{generatedText.length < selectedText.length && (
<Text type="warning"> ({generatedText.length - selectedText.length})</Text>
)}
</span>
}
type="success"
showIcon
style={{ marginTop: 12 }}
/>
)}
</div>
)}
{/* 添加闪烁光标动画 */}
<style>{`
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
`}</style>
</Modal>
);
};
export default PartialRegenerateModal;
@@ -0,0 +1,103 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { EditOutlined } from '@ant-design/icons';
interface PartialRegenerateToolbarProps {
visible: boolean;
position: { top: number; left: number };
onRegenerate: () => void;
selectedText: string;
}
/**
* 局部重写浮动工具栏
* 当用户在章节内容编辑器中选中文本时显示
*/
export const PartialRegenerateToolbar: React.FC<PartialRegenerateToolbarProps> = ({
visible,
position,
onRegenerate,
selectedText
}) => {
if (!visible || !selectedText) return null;
// 限制显示的选中文本长度
const displayText = selectedText.length > 20
? selectedText.substring(0, 20) + '...'
: selectedText;
return (
<div
style={{
position: 'fixed',
top: position.top,
left: position.left,
zIndex: 10000,
background: '#fff',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.15)',
padding: '6px 8px',
display: 'flex',
alignItems: 'center',
gap: 8,
animation: 'fadeIn 0.2s ease-out',
border: '1px solid #e8e8e8',
}}
>
<Tooltip
title={`AI重写选中内容: "${displayText}"`}
placement="top"
>
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRegenerate();
}}
style={{
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
border: 'none',
fontWeight: 500,
boxShadow: '0 4px 12px rgba(77, 128, 136, 0.3)',
}}
>
AI重写
</Button>
</Tooltip>
<span style={{
fontSize: 12,
color: '#8c8c8c',
maxWidth: 150,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{selectedText.length}
</span>
</div>
);
};
// 添加动画样式
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
if (!document.head.querySelector('style[data-partial-regenerate-toolbar]')) {
style.setAttribute('data-partial-regenerate-toolbar', 'true');
document.head.appendChild(style);
}
export default PartialRegenerateToolbar;
+265 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
@@ -12,6 +12,8 @@ import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel';
import ChapterReader from '../components/ChapterReader';
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
import PartialRegenerateModal from '../components/PartialRegenerateModal';
const { TextArea } = Input;
@@ -78,6 +80,14 @@ export default function Chapters() {
const [planEditorVisible, setPlanEditorVisible] = useState(false);
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
// 局部重写状态
const [partialRegenerateToolbarVisible, setPartialRegenerateToolbarVisible] = useState(false);
const [partialRegenerateToolbarPosition, setPartialRegenerateToolbarPosition] = useState({ top: 0, left: 0 });
const [selectedTextForRegenerate, setSelectedTextForRegenerate] = useState('');
const [selectionStartPosition, setSelectionStartPosition] = useState(0);
const [selectionEndPosition, setSelectionEndPosition] = useState(0);
const [partialRegenerateModalVisible, setPartialRegenerateModalVisible] = useState(false);
// 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
@@ -106,6 +116,212 @@ export default function Chapters() {
return () => window.removeEventListener('resize', handleResize);
}, []);
// 处理文本选中 - 检测选中文本并显示浮动工具栏
const handleTextSelection = useCallback(() => {
// 只在编辑器打开时处理选中
if (!isEditorOpen || isGenerating) {
setPartialRegenerateToolbarVisible(false);
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
setPartialRegenerateToolbarVisible(false);
return;
}
const selectedText = selection.toString().trim();
// 至少选中10个字符才显示工具栏
if (selectedText.length < 10) {
setPartialRegenerateToolbarVisible(false);
return;
}
// 检查选中是否在 TextArea 内
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
if (!textArea) {
setPartialRegenerateToolbarVisible(false);
return;
}
// 检查选中是否在 textarea 内(需要特殊处理,因为 textarea 的选中不会创建 range
if (document.activeElement !== textArea) {
setPartialRegenerateToolbarVisible(false);
return;
}
// 获取 textarea 中的选中位置
const start = textArea.selectionStart;
const end = textArea.selectionEnd;
const textContent = textArea.value;
const selectedInTextArea = textContent.substring(start, end);
if (selectedInTextArea.trim().length < 10) {
setPartialRegenerateToolbarVisible(false);
return;
}
// 计算浮动工具栏位置
const rect = textArea.getBoundingClientRect();
const computedStyle = window.getComputedStyle(textArea);
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
// 计算选中文本起始位置所在的行号
const textBeforeSelection = textContent.substring(0, start);
const startLine = textBeforeSelection.split('\n').length - 1;
// 计算选中文本在 textarea 中的视觉位置
// 需要考虑 scrollToptextarea 内部滚动偏移)
const scrollTop = textArea.scrollTop;
const visualTop = (startLine * lineHeight) + paddingTop - scrollTop;
// 工具栏位置:textarea 顶部 + 选中文本的视觉位置 - 工具栏高度偏移
const toolbarTop = rect.top + visualTop - 45;
// 水平位置:放在 textarea 的右侧区域,避免遮挡文本
const toolbarLeft = rect.right - 180;
setSelectedTextForRegenerate(selectedInTextArea);
setSelectionStartPosition(start);
setSelectionEndPosition(end);
// 计算工具栏位置,如果选中位置不在可视区域内,固定在边缘
let finalTop = toolbarTop;
if (visualTop < 0) {
finalTop = rect.top + 10;
} else if (visualTop > textArea.clientHeight) {
finalTop = rect.bottom - 50;
}
setPartialRegenerateToolbarPosition({
top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)),
left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200),
});
setPartialRegenerateToolbarVisible(true);
}, [isEditorOpen, isGenerating]);
// 更新工具栏位置的函数(不检测选中,只更新位置)
const updateToolbarPosition = useCallback(() => {
if (!partialRegenerateToolbarVisible || !selectedTextForRegenerate) return;
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
if (!textArea) return;
const rect = textArea.getBoundingClientRect();
const computedStyle = window.getComputedStyle(textArea);
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const textContent = textArea.value;
const textBeforeSelection = textContent.substring(0, selectionStartPosition);
const startLine = textBeforeSelection.split('\n').length - 1;
const scrollTop = textArea.scrollTop;
const visualTop = (startLine * lineHeight) + paddingTop - scrollTop;
const toolbarTop = rect.top + visualTop - 45;
// 固定在 textarea 右上角,不随选中位置变化
const toolbarLeft = rect.right - 180;
// 工具栏固定在 textarea 可视区域内,即使选中文本滚出视野也保持显示
// 如果选中位置在可视区域内,跟随选中位置
// 如果滚出视野,固定在顶部或底部边缘
let finalTop = toolbarTop;
if (visualTop < 0) {
// 选中位置在上方视野外,工具栏固定在顶部
finalTop = rect.top + 10;
} else if (visualTop > textArea.clientHeight) {
// 选中位置在下方视野外,工具栏固定在底部
finalTop = rect.bottom - 50;
}
setPartialRegenerateToolbarPosition({
top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)),
left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200),
});
}, [partialRegenerateToolbarVisible, selectedTextForRegenerate, selectionStartPosition]);
// 监听选中事件
useEffect(() => {
if (!isEditorOpen) return;
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
if (!textArea) return;
const handleMouseUp = () => {
// 鼠标释放时检查选中
setTimeout(handleTextSelection, 50);
};
const handleKeyUp = (e: KeyboardEvent) => {
// Shift + 方向键选中时检查
if (e.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
setTimeout(handleTextSelection, 50);
}
};
const handleScroll = () => {
// 滚动时更新位置(使用 requestAnimationFrame 优化性能)
requestAnimationFrame(updateToolbarPosition);
};
// 监听 textarea 滚动
textArea.addEventListener('mouseup', handleMouseUp);
textArea.addEventListener('keyup', handleKeyUp);
textArea.addEventListener('scroll', handleScroll);
// 同时监听 Modal body 滚动(Modal 内容可能在外层容器滚动)
const modalBody = textArea.closest('.ant-modal-body');
if (modalBody) {
modalBody.addEventListener('scroll', handleScroll);
}
// 监听窗口大小变化
window.addEventListener('resize', handleScroll);
return () => {
textArea.removeEventListener('mouseup', handleMouseUp);
textArea.removeEventListener('keyup', handleKeyUp);
textArea.removeEventListener('scroll', handleScroll);
if (modalBody) {
modalBody.removeEventListener('scroll', handleScroll);
}
window.removeEventListener('resize', handleScroll);
};
}, [isEditorOpen, handleTextSelection, updateToolbarPosition]);
// 点击其他区域时隐藏工具栏
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 如果点击的是工具栏,不隐藏
if (target.closest('[data-partial-regenerate-toolbar]')) {
return;
}
// 如果点击的是 textarea,不隐藏
if (target.tagName === 'TEXTAREA') {
return;
}
// 如果点击的是 Modal 内部(包括滚动条),不隐藏
if (target.closest('.ant-modal-content')) {
return;
}
// 点击 Modal 外部才隐藏工具栏
setPartialRegenerateToolbarVisible(false);
};
if (partialRegenerateToolbarVisible) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [partialRegenerateToolbarVisible]);
const {
refreshChapters,
updateChapter,
@@ -1540,6 +1756,29 @@ export default function Chapters() {
}
};
// 打开局部重写弹窗
const handleOpenPartialRegenerate = () => {
setPartialRegenerateToolbarVisible(false);
setPartialRegenerateModalVisible(true);
};
// 应用局部重写结果
const handleApplyPartialRegenerate = (newText: string, startPos: number, endPos: number) => {
// 获取当前内容
const currentContent = editorForm.getFieldValue('content') || '';
// 替换选中部分
const newContent = currentContent.substring(0, startPos) + newText + currentContent.substring(endPos);
// 更新表单
editorForm.setFieldsValue({ content: newContent });
// 关闭弹窗
setPartialRegenerateModalVisible(false);
message.success('局部重写已应用');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{contextHolder}
@@ -2086,7 +2325,7 @@ export default function Chapters() {
setIsEditorOpen(false);
}}
closable={!isGenerating}
maskClosable={!isGenerating}
maskClosable={false}
keyboard={!isGenerating}
width={isMobile ? 'calc(100vw - 32px)' : '85%'}
centered
@@ -2253,6 +2492,16 @@ export default function Chapters() {
/>
</Form.Item>
{/* 局部重写浮动工具栏 */}
<div data-partial-regenerate-toolbar>
<PartialRegenerateToolbar
visible={partialRegenerateToolbarVisible && !isGenerating}
position={partialRegenerateToolbarPosition}
selectedText={selectedTextForRegenerate}
onRegenerate={handleOpenPartialRegenerate}
/>
</div>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
@@ -2641,6 +2890,20 @@ export default function Chapters() {
/>
)}
{/* 局部重写弹窗 */}
{editingId && (
<PartialRegenerateModal
visible={partialRegenerateModalVisible}
chapterId={editingId}
selectedText={selectedTextForRegenerate}
startPosition={selectionStartPosition}
endPosition={selectionEndPosition}
styleId={selectedStyleId}
onClose={() => setPartialRegenerateModalVisible(false)}
onApply={handleApplyPartialRegenerate}
/>
)}
{/* 规划编辑器 */}
{editingPlanChapter && currentProject && (() => {
let parsedPlanData = null;
+39
View File
@@ -616,6 +616,45 @@ export const chapterApi = {
completed_at: string | null;
}>;
}>(`/chapters/${chapterId}/regeneration/tasks`, { params: { limit } }),
// 局部重写相关
partialRegenerateStream: (
chapterId: string,
data: {
selected_text: string;
start_position: number;
end_position: number;
user_instructions: string;
context_chars?: number;
style_id?: number;
length_mode?: 'similar' | 'expand' | 'condense' | 'custom';
target_word_count?: number;
},
options?: SSEClientOptions
) => ssePost<{
new_text: string;
word_count: number;
original_word_count: number;
start_position: number;
end_position: number;
}>(
`/api/chapters/${chapterId}/partial-regenerate-stream`,
data,
options
),
applyPartialRegenerate: (chapterId: string, data: {
new_text: string;
start_position: number;
end_position: number;
}) =>
api.post<unknown, {
success: boolean;
chapter_id: string;
word_count: number;
old_word_count: number;
message: string;
}>(`/chapters/${chapterId}/apply-partial-regenerate`, data),
};
export const writingStyleApi = {