feat: 前端 - 接好 Ant Design 树形控件和差异对比组件
前端更新内容: 1. FileTree.js - Ant Design Tree 组件集成 - 文件状态标签显示(一致/冲突/本地更新/数据库更新) - 统计信息展示(总文件数、总大小、冲突数) - 刷新状态按钮 - 文件选择事件处理 2. FileDiff.js - 丝滑的差异对比组件 - 使用 diff 库计算行级差异 - 颜色区分:绿色(新增)、红色(删除) - 显示变动行数标签 - 支持大文件截断提示 - 刷新按钮 3. package.json - 新增 diff 依赖(行级差异计算) - 新增 react-syntax-highlighter 依赖(代码高亮) 用户体验: - 点选文件 → 自动加载差异 - 实时状态显示 - 一键同步按钮 - 流畅的动画效果 点选-对比-同步流程完整实现!
This commit is contained in:
@@ -8,7 +8,9 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"antd": "^5.0.0",
|
"antd": "^5.0.0",
|
||||||
"react-diff-viewer-continued": "^3.2.6",
|
"react-diff-viewer-continued": "^3.2.6",
|
||||||
"axios": "^1.0.0"
|
"axios": "^1.0.0",
|
||||||
|
"diff": "^5.1.0",
|
||||||
|
"react-syntax-highlighter": "^15.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
|||||||
@@ -1,8 +1,44 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Spin, Alert, Tabs } from 'antd';
|
import { Spin, Alert, Tag, Button, Descriptions, Space, Tooltip, Badge } from 'antd';
|
||||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { diffLines, ChangeType } from 'diff';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
consistent: {
|
||||||
|
color: 'success',
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
text: '内容一致',
|
||||||
|
description: '本地文件与数据库内容完全相同',
|
||||||
|
},
|
||||||
|
local_newer: {
|
||||||
|
color: 'warning',
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
text: '本地更新',
|
||||||
|
description: '本地文件比数据库更新',
|
||||||
|
},
|
||||||
|
db_newer: {
|
||||||
|
color: 'info',
|
||||||
|
icon: <SyncOutlined spin />,
|
||||||
|
text: '数据库更新',
|
||||||
|
description: '数据库文件比本地更新',
|
||||||
|
},
|
||||||
|
conflict: {
|
||||||
|
color: 'error',
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
text: '存在冲突',
|
||||||
|
description: '本地与数据库内容不一致',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function FileDiff({ filePath, lobsterId }) {
|
export default function FileDiff({ filePath, lobsterId }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [diffData, setDiffData] = useState(null);
|
const [diffData, setDiffData] = useState(null);
|
||||||
@@ -14,13 +50,17 @@ export default function FileDiff({ filePath, lobsterId }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/diff/', {
|
const response = await api.get('/diff/', {
|
||||||
params: { file_path: filePath, lobster_id: lobsterId }
|
params: {
|
||||||
|
lobster_id,
|
||||||
|
file_path: filePath,
|
||||||
|
chunked: 'true',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.data.success) {
|
||||||
setDiffData(response.data);
|
setDiffData(response.data.data);
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || '加载失败');
|
setError(response.data.error || '加载失败');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || '网络错误');
|
setError(err.message || '网络错误');
|
||||||
@@ -33,120 +73,187 @@ export default function FileDiff({ filePath, lobsterId }) {
|
|||||||
if (filePath) {
|
if (filePath) {
|
||||||
loadDiff();
|
loadDiff();
|
||||||
}
|
}
|
||||||
}, [filePath]);
|
}, [filePath, lobsterId]);
|
||||||
|
|
||||||
|
const renderDiff = () => {
|
||||||
|
if (!diffData) return null;
|
||||||
|
|
||||||
|
const { local_content, db_content, diff, status } = diffData;
|
||||||
|
|
||||||
|
if (!local_content || !db_content) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="文件不存在"
|
||||||
|
description={local_content ? '数据库中不存在此文件' : '本地不存在此文件'}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 diff 库计算行级差异
|
||||||
|
const changes = diffLines(db_content || '', local_content || '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxHeight: '600px', overflowY: 'auto' }}>
|
||||||
|
<Descriptions
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
column={2}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="状态" span={2}>
|
||||||
|
<Badge
|
||||||
|
status={STATUS_CONFIG[status]?.color}
|
||||||
|
text={
|
||||||
|
<Space>
|
||||||
|
{STATUS_CONFIG[status]?.icon}
|
||||||
|
{STATUS_CONFIG[status]?.text}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{diff.lines_changed !== 0 && (
|
||||||
|
<Descriptions.Item label="变动行数" span={2}>
|
||||||
|
<Tag color={diff.lines_changed > 0 ? 'green' : 'red'}>
|
||||||
|
{diff.lines_changed > 0 ? '+' : ''}{diff.lines_changed}
|
||||||
|
</Tag>
|
||||||
|
{diff.is_truncated && (
|
||||||
|
<Tooltip title="大文件,仅显示头尾差异">
|
||||||
|
<Tag color="orange">已截断</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="本地哈希" span={1}>
|
||||||
|
<code style={{ fontSize: '12px' }}>
|
||||||
|
{diffData.local_hash?.slice(0, 16)}...
|
||||||
|
</code>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数据库哈希" span={1}>
|
||||||
|
<code style={{ fontSize: '12px' }}>
|
||||||
|
{diffData.db_hash?.slice(0, 16)}...
|
||||||
|
</code>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<div className="diff-container">
|
||||||
|
{changes.map((change, index) => {
|
||||||
|
const lineStyle = {
|
||||||
|
paddingLeft: '16px',
|
||||||
|
paddingRight: '16px',
|
||||||
|
margin: '2px 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (change.type === ChangeType.Insert) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
...lineStyle,
|
||||||
|
backgroundColor: '#e6fffb',
|
||||||
|
borderLeft: '3px solid #52c41a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#52c41a', marginRight: '8px' }}>+</span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (change.type === ChangeType.Delete) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
...lineStyle,
|
||||||
|
backgroundColor: '#fff1f0',
|
||||||
|
borderLeft: '3px solid #ff4d4f',
|
||||||
|
textDecoration: 'line-through',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#ff4d4f', marginRight: '8px' }}>-</span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div key={index} style={{ ...lineStyle }}>
|
||||||
|
<span style={{ color: '#d9d9d9', marginRight: '8px' }}> </span>
|
||||||
|
{change.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Spin tip="加载中..." />;
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||||
|
<Spin size="large" tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Alert message={error} type="error" />;
|
return (
|
||||||
|
<Alert
|
||||||
|
message="加载失败"
|
||||||
|
description={error}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={loadDiff}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!diffData) {
|
if (!diffData) {
|
||||||
return <Alert message="请选择文件" type="info" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { local_content, db_content, status, diff } = diffData;
|
|
||||||
|
|
||||||
// 文件不存在的情况
|
|
||||||
if (!local_content && !db_content) {
|
|
||||||
return <Alert message="文件不存在" type="warning" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!local_content) {
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
message="文件仅存在于数据库"
|
message="请选择文件"
|
||||||
description="点击「同步到本地」将文件恢复到本地"
|
description="点击左侧文件树中的文件查看差异"
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!db_content) {
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
message="文件仅存在于本地"
|
|
||||||
description="点击「同步到数据库」将文件备份到数据库"
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_MESSAGES = {
|
|
||||||
consistent: '文件内容一致',
|
|
||||||
local_newer: '本地文件有更新',
|
|
||||||
db_newer: '数据库版本更新',
|
|
||||||
conflict: '文件内容冲突',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="file-diff">
|
||||||
<Alert
|
<div style={{ marginBottom: 16 }}>
|
||||||
message={STATUS_MESSAGES[status] || '未知状态'}
|
<Space>
|
||||||
type={status === 'consistent' ? 'success' : 'warning'}
|
<Button
|
||||||
style={{ marginBottom: 16 }}
|
size="small"
|
||||||
showIcon
|
icon={<ClockCircleOutlined />}
|
||||||
/>
|
onClick={loadDiff}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs
|
{STATUS_CONFIG[diffData.status] && (
|
||||||
defaultActiveKey="diff"
|
<Alert
|
||||||
items={[
|
message={STATUS_CONFIG[diffData.status].text}
|
||||||
{
|
description={STATUS_CONFIG[diffData.status].description}
|
||||||
key: 'diff',
|
type={STATUS_CONFIG[diffData.status].color}
|
||||||
label: '差异对比',
|
showIcon
|
||||||
children: (
|
icon={STATUS_CONFIG[diffData.status].icon}
|
||||||
<div style={{ overflowX: 'auto' }}>
|
style={{ marginBottom: 16 }}
|
||||||
<ReactDiffViewer
|
closable
|
||||||
oldValue={db_content || ''}
|
/>
|
||||||
newValue={local_content || ''}
|
)}
|
||||||
splitView={true}
|
|
||||||
useDarkTheme={false}
|
{renderDiff()}
|
||||||
leftTitle="数据库版本"
|
|
||||||
rightTitle="本地版本"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'local',
|
|
||||||
label: '本地内容',
|
|
||||||
children: (
|
|
||||||
<pre style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: '4px',
|
|
||||||
maxHeight: '500px',
|
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
{local_content}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'db',
|
|
||||||
label: '数据库内容',
|
|
||||||
children: (
|
|
||||||
<pre style={{
|
|
||||||
padding: '16px',
|
|
||||||
background: '#f5f5f5',
|
|
||||||
borderRadius: '4px',
|
|
||||||
maxHeight: '500px',
|
|
||||||
overflow: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word'
|
|
||||||
}}>
|
|
||||||
{db_content}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user