2026-04-02 11:02:32 +08:00
|
|
|
import React, { useMemo, useState } from 'react';
|
2026-03-06 14:14:57 +08:00
|
|
|
import { Modal, Button, Card, Statistic, Row, Col, message, theme } from 'antd';
|
2025-11-11 19:50:12 +08:00
|
|
|
import { CheckOutlined, CloseOutlined, SwapOutlined } from '@ant-design/icons';
|
|
|
|
|
import ReactDiffViewer from 'react-diff-viewer-continued';
|
2026-04-02 11:02:32 +08:00
|
|
|
import { useThemeMode } from '../theme/useThemeMode';
|
2025-11-11 19:50:12 +08:00
|
|
|
|
|
|
|
|
interface ChapterContentComparisonProps {
|
|
|
|
|
visible: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
chapterId: string;
|
|
|
|
|
chapterTitle: string;
|
|
|
|
|
originalContent: string;
|
|
|
|
|
newContent: string;
|
|
|
|
|
wordCount: number;
|
|
|
|
|
onApply: () => void;
|
|
|
|
|
onDiscard: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ChapterContentComparison: React.FC<ChapterContentComparisonProps> = ({
|
|
|
|
|
visible,
|
|
|
|
|
onClose,
|
|
|
|
|
chapterId,
|
|
|
|
|
chapterTitle,
|
|
|
|
|
originalContent,
|
|
|
|
|
newContent,
|
|
|
|
|
wordCount,
|
|
|
|
|
onApply,
|
|
|
|
|
onDiscard
|
|
|
|
|
}) => {
|
2026-03-06 14:14:57 +08:00
|
|
|
const { token } = theme.useToken();
|
2026-04-02 11:02:32 +08:00
|
|
|
const { resolvedMode } = useThemeMode();
|
2025-11-11 19:50:12 +08:00
|
|
|
const [applying, setApplying] = useState(false);
|
|
|
|
|
const [viewMode, setViewMode] = useState<'split' | 'unified'>('split');
|
2025-12-30 10:05:34 +08:00
|
|
|
const [modal, contextHolder] = Modal.useModal();
|
2025-11-11 19:50:12 +08:00
|
|
|
|
|
|
|
|
const originalWordCount = originalContent.length;
|
|
|
|
|
const wordCountDiff = wordCount - originalWordCount;
|
|
|
|
|
const wordCountDiffPercent = ((wordCountDiff / originalWordCount) * 100).toFixed(1);
|
2026-04-02 11:02:32 +08:00
|
|
|
const isDarkMode = resolvedMode === 'dark';
|
|
|
|
|
const diffViewerStyles = useMemo(() => ({
|
|
|
|
|
variables: {
|
|
|
|
|
light: {
|
|
|
|
|
diffViewerBackground: token.colorBgContainer,
|
|
|
|
|
diffViewerColor: token.colorText,
|
|
|
|
|
diffViewerTitleBackground: token.colorBgElevated,
|
|
|
|
|
diffViewerTitleColor: token.colorTextHeading,
|
|
|
|
|
addedBackground: token.colorSuccessBg,
|
|
|
|
|
addedColor: token.colorText,
|
|
|
|
|
removedBackground: token.colorErrorBg,
|
|
|
|
|
removedColor: token.colorText,
|
|
|
|
|
wordAddedBackground: token.colorSuccessBorder,
|
|
|
|
|
wordRemovedBackground: token.colorErrorBorder,
|
|
|
|
|
addedGutterBackground: token.colorSuccessBg,
|
|
|
|
|
removedGutterBackground: token.colorErrorBg,
|
|
|
|
|
gutterBackground: token.colorFillQuaternary,
|
|
|
|
|
gutterBackgroundDark: token.colorFillTertiary,
|
|
|
|
|
highlightBackground: token.colorWarningBg,
|
|
|
|
|
highlightGutterBackground: token.colorWarningBorder,
|
|
|
|
|
},
|
|
|
|
|
dark: {
|
|
|
|
|
diffViewerBackground: token.colorBgContainer,
|
|
|
|
|
diffViewerColor: token.colorText,
|
|
|
|
|
diffViewerTitleBackground: token.colorBgElevated,
|
|
|
|
|
diffViewerTitleColor: token.colorTextHeading,
|
|
|
|
|
addedBackground: 'rgba(82, 196, 26, 0.16)',
|
|
|
|
|
addedColor: token.colorText,
|
|
|
|
|
removedBackground: 'rgba(255, 77, 79, 0.16)',
|
|
|
|
|
removedColor: token.colorText,
|
|
|
|
|
wordAddedBackground: 'rgba(82, 196, 26, 0.3)',
|
|
|
|
|
wordRemovedBackground: 'rgba(255, 77, 79, 0.3)',
|
|
|
|
|
addedGutterBackground: 'rgba(82, 196, 26, 0.12)',
|
|
|
|
|
removedGutterBackground: 'rgba(255, 77, 79, 0.12)',
|
|
|
|
|
gutterBackground: token.colorFillQuaternary,
|
|
|
|
|
gutterBackgroundDark: token.colorFillSecondary,
|
|
|
|
|
highlightBackground: 'rgba(250, 173, 20, 0.18)',
|
|
|
|
|
highlightGutterBackground: 'rgba(250, 173, 20, 0.28)',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
line: {
|
|
|
|
|
padding: '10px 2px',
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
lineHeight: '20px',
|
|
|
|
|
whiteSpace: 'pre-wrap' as const,
|
|
|
|
|
wordBreak: 'break-word' as const,
|
|
|
|
|
},
|
|
|
|
|
}), [token]);
|
2025-11-11 19:50:12 +08:00
|
|
|
|
|
|
|
|
const handleApply = async () => {
|
|
|
|
|
setApplying(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/chapters/${chapterId}`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
content: newContent
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('应用新内容失败');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 17:29:15 +08:00
|
|
|
message.success('新内容已应用!');
|
2025-12-11 17:01:25 +08:00
|
|
|
|
2025-11-21 17:29:15 +08:00
|
|
|
// 先调用 onApply 通知父组件刷新
|
|
|
|
|
onApply();
|
2025-12-11 17:01:25 +08:00
|
|
|
|
2025-11-21 17:29:15 +08:00
|
|
|
// 延迟触发章节分析,给父组件时间刷新
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const analysisResponse = await fetch(`/api/chapters/${chapterId}/analyze`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-11-11 19:50:12 +08:00
|
|
|
|
2025-11-21 17:29:15 +08:00
|
|
|
if (analysisResponse.ok) {
|
|
|
|
|
message.success('章节分析已开始,请稍后查看结果');
|
|
|
|
|
} else {
|
|
|
|
|
message.warning('章节分析触发失败,您可以手动触发分析');
|
|
|
|
|
}
|
|
|
|
|
} catch (analysisError) {
|
|
|
|
|
console.error('触发分析失败:', analysisError);
|
2025-11-11 19:50:12 +08:00
|
|
|
message.warning('章节分析触发失败,您可以手动触发分析');
|
|
|
|
|
}
|
2025-11-21 17:29:15 +08:00
|
|
|
}, 500);
|
2025-12-11 17:01:25 +08:00
|
|
|
|
2025-11-11 19:50:12 +08:00
|
|
|
onClose();
|
2026-01-14 14:33:43 +08:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error as Error;
|
|
|
|
|
message.error(err.message || '应用失败');
|
2025-11-11 19:50:12 +08:00
|
|
|
} finally {
|
|
|
|
|
setApplying(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDiscard = () => {
|
2025-12-30 10:05:34 +08:00
|
|
|
modal.confirm({
|
2025-11-11 19:50:12 +08:00
|
|
|
title: '确认放弃',
|
|
|
|
|
content: '确定要放弃新生成的内容吗?此操作不可恢复。',
|
2025-12-30 10:05:34 +08:00
|
|
|
centered: true,
|
2025-11-11 19:50:12 +08:00
|
|
|
okText: '确定放弃',
|
|
|
|
|
cancelText: '取消',
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
onOk: () => {
|
|
|
|
|
onDiscard();
|
|
|
|
|
onClose();
|
|
|
|
|
message.info('已放弃新内容');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-30 10:05:34 +08:00
|
|
|
<>
|
|
|
|
|
{contextHolder}
|
|
|
|
|
<Modal
|
2025-11-11 19:50:12 +08:00
|
|
|
title={`内容对比 - ${chapterTitle}`}
|
|
|
|
|
open={visible}
|
|
|
|
|
onCancel={onClose}
|
|
|
|
|
width="95%"
|
|
|
|
|
centered
|
|
|
|
|
style={{ maxWidth: 1600 }}
|
|
|
|
|
footer={[
|
2025-12-11 17:01:25 +08:00
|
|
|
<Button
|
|
|
|
|
key="discard"
|
|
|
|
|
danger
|
2025-11-11 19:50:12 +08:00
|
|
|
icon={<CloseOutlined />}
|
|
|
|
|
onClick={handleDiscard}
|
|
|
|
|
>
|
|
|
|
|
放弃新内容
|
|
|
|
|
</Button>,
|
|
|
|
|
<Button
|
|
|
|
|
key="toggle"
|
|
|
|
|
icon={<SwapOutlined />}
|
|
|
|
|
onClick={() => setViewMode(viewMode === 'split' ? 'unified' : 'split')}
|
|
|
|
|
>
|
|
|
|
|
切换视图
|
|
|
|
|
</Button>,
|
2025-12-11 17:01:25 +08:00
|
|
|
<Button
|
|
|
|
|
key="apply"
|
|
|
|
|
type="primary"
|
2025-11-11 19:50:12 +08:00
|
|
|
icon={<CheckOutlined />}
|
|
|
|
|
loading={applying}
|
|
|
|
|
onClick={handleApply}
|
|
|
|
|
>
|
|
|
|
|
应用新内容
|
|
|
|
|
</Button>
|
|
|
|
|
]}
|
|
|
|
|
>
|
|
|
|
|
{/* 统计信息 */}
|
|
|
|
|
<Card size="small" style={{ marginBottom: 16 }}>
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="原内容字数"
|
|
|
|
|
value={originalWordCount}
|
|
|
|
|
suffix="字"
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="新内容字数"
|
|
|
|
|
value={wordCount}
|
|
|
|
|
suffix="字"
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="字数变化"
|
|
|
|
|
value={wordCountDiff}
|
|
|
|
|
suffix="字"
|
2025-12-11 17:01:25 +08:00
|
|
|
valueStyle={{ color: wordCountDiff > 0 ? 'var(--color-success)' : 'var(--color-error)' }}
|
2025-11-11 19:50:12 +08:00
|
|
|
prefix={wordCountDiff > 0 ? '+' : ''}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={6}>
|
|
|
|
|
<Statistic
|
|
|
|
|
title="变化比例"
|
|
|
|
|
value={wordCountDiffPercent}
|
|
|
|
|
suffix="%"
|
2025-12-11 17:01:25 +08:00
|
|
|
valueStyle={{ color: Math.abs(parseFloat(wordCountDiffPercent)) < 10 ? 'var(--color-primary)' : 'var(--color-warning)' }}
|
2025-11-11 19:50:12 +08:00
|
|
|
prefix={wordCountDiff > 0 ? '+' : ''}
|
|
|
|
|
/>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 内容对比 */}
|
2025-12-11 17:01:25 +08:00
|
|
|
<div style={{
|
|
|
|
|
maxHeight: 'calc(90vh - 300px)',
|
2025-11-11 19:50:12 +08:00
|
|
|
overflow: 'auto',
|
2026-04-02 11:02:32 +08:00
|
|
|
border: `1px solid ${token.colorBorder}`,
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
background: token.colorBgContainer
|
2025-11-11 19:50:12 +08:00
|
|
|
}}>
|
|
|
|
|
<ReactDiffViewer
|
|
|
|
|
oldValue={originalContent}
|
|
|
|
|
newValue={newContent}
|
|
|
|
|
splitView={viewMode === 'split'}
|
|
|
|
|
leftTitle="原内容"
|
|
|
|
|
rightTitle="新内容"
|
|
|
|
|
showDiffOnly={false}
|
2026-04-02 11:02:32 +08:00
|
|
|
useDarkTheme={isDarkMode}
|
|
|
|
|
styles={diffViewerStyles}
|
2025-11-11 19:50:12 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-12-30 10:05:34 +08:00
|
|
|
</Modal>
|
|
|
|
|
</>
|
2025-11-11 19:50:12 +08:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ChapterContentComparison;
|