feat: 龙虾记忆同步系统完整版本
功能特性: - 文件树展示 - 差异对比 - 双向同步(本地 <-> 数据库) - 版本历史追踪 - 统计信息展示 核心补丁: 1. 分块读取与流式传输(防止大文件内存飙升) 2. .lobsterignore 机制(排除临时文件) 3. 操作溯源(Audit Log,记录同步历史) 技术栈: - 后端: Django + DRF + PostgreSQL - 前端: React + Ant Design - 部署: Docker + Docker Compose 项目已完整部署,可直接使用 docker-compose up -d 启动
This commit is contained in:
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# React 前端 Dockerfile
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm ci
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 构建生产版本
|
||||
RUN npm run build
|
||||
|
||||
# 生产环境镜像
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# 复制 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "lobster-memory-sync-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"antd": "^5.0.0",
|
||||
"react-diff-viewer-continued": "^3.2.6",
|
||||
"axios": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:8087"
|
||||
}
|
||||
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="龙虾记忆同步系统 - 管理和同步龙虾的记忆文件"
|
||||
/>
|
||||
<title>🦐 龙虾记忆同步系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/src/App.css
Normal file
28
frontend/src/App.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-header h1 {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 10px 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.App-main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
23
frontend/src/App.js
Normal file
23
frontend/src/App.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import FileTree from './components/FileTree';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<h1>🦐 龙虾记忆同步系统</h1>
|
||||
<p className="subtitle">管理和同步龙虾的记忆文件</p>
|
||||
</header>
|
||||
<main className="App-main">
|
||||
<FileTree />
|
||||
</main>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
33
frontend/src/api/index.js
Normal file
33
frontend/src/api/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8087/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
152
frontend/src/components/FileDiff.js
Normal file
152
frontend/src/components/FileDiff.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Spin, Alert, Tabs } from 'antd';
|
||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
||||
import api from '../api';
|
||||
|
||||
export default function FileDiff({ filePath, lobsterId }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiff = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get('/diff/', {
|
||||
params: { file_path: filePath, lobster_id: lobsterId }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setDiffData(response.data);
|
||||
} else {
|
||||
setError(response.error || '加载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '网络错误');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (filePath) {
|
||||
loadDiff();
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
if (loading) {
|
||||
return <Spin tip="加载中..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert message={error} type="error" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Alert
|
||||
message="文件仅存在于数据库"
|
||||
description="点击「同步到本地」将文件恢复到本地"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!db_content) {
|
||||
return (
|
||||
<Alert
|
||||
message="文件仅存在于本地"
|
||||
description="点击「同步到数据库」将文件备份到数据库"
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_MESSAGES = {
|
||||
consistent: '文件内容一致',
|
||||
local_newer: '本地文件有更新',
|
||||
db_newer: '数据库版本更新',
|
||||
conflict: '文件内容冲突',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
message={STATUS_MESSAGES[status] || '未知状态'}
|
||||
type={status === 'consistent' ? 'success' : 'warning'}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="diff"
|
||||
items={[
|
||||
{
|
||||
key: 'diff',
|
||||
label: '差异对比',
|
||||
children: (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<ReactDiffViewer
|
||||
oldValue={db_content || ''}
|
||||
newValue={local_content || ''}
|
||||
splitView={true}
|
||||
useDarkTheme={false}
|
||||
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>
|
||||
);
|
||||
}
|
||||
273
frontend/src/components/FileTree.js
Normal file
273
frontend/src/components/FileTree.js
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tree, Button, message, Spin, Alert, Card, Row, Col, Tag } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SyncOutlined,
|
||||
FileOutlined,
|
||||
FolderOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../api';
|
||||
import FileDiff from './FileDiff';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
consistent: 'success',
|
||||
local_newer: 'warning',
|
||||
db_newer: 'info',
|
||||
conflict: 'error',
|
||||
local_only: 'warning',
|
||||
db_only: 'info',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
consistent: '一致',
|
||||
local_newer: '本地更新',
|
||||
db_newer: '数据库更新',
|
||||
conflict: '冲突',
|
||||
local_only: '仅本地',
|
||||
db_only: '仅数据库',
|
||||
};
|
||||
|
||||
export default function FileTree() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
const lobsterId = 'daotong';
|
||||
|
||||
// 加载同步状态
|
||||
const loadSyncStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/status/', { params: { lobster_id: lobsterId } });
|
||||
setSyncStatus(response.data.data);
|
||||
|
||||
// 加载统计信息
|
||||
const statsResponse = await api.get('/stats/', { params: { lobster_id: lobsterId } });
|
||||
setStats(statsResponse.data.data);
|
||||
} catch (error) {
|
||||
message.error('加载失败: ' + error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文件树
|
||||
const loadFileTree = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get('/tree/', { params: { lobster_id: lobsterId } });
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
message.error('加载失败: ' + error.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 转换为 Ant Design Tree 数据格式
|
||||
const convertToTreeData = (tree, parentPath = '') => {
|
||||
const data = [];
|
||||
|
||||
for (const [name, children] of Object.entries(tree)) {
|
||||
const currentPath = parentPath ? `${parentPath}/${name}` : name;
|
||||
|
||||
if (children && typeof children === 'object' && !children.file_path) {
|
||||
// 这是一个目录
|
||||
data.push({
|
||||
title: name,
|
||||
key: currentPath,
|
||||
icon: <FolderOutlined />,
|
||||
children: convertToTreeData(children, currentPath),
|
||||
});
|
||||
} else if (children && children.file_path) {
|
||||
// 这是一个文件
|
||||
const fileStatus = getFileStatus(children.file_path);
|
||||
data.push({
|
||||
title: (
|
||||
<span>
|
||||
<FileOutlined /> {name}
|
||||
{fileStatus && (
|
||||
<Tag color={STATUS_COLORS[fileStatus]} style={{ marginLeft: 8 }}>
|
||||
{STATUS_LABELS[fileStatus]}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
key: children.file_path,
|
||||
icon: <FileOutlined />,
|
||||
isLeaf: true,
|
||||
children: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// 获取文件状态
|
||||
const getFileStatus = (filePath) => {
|
||||
if (!syncStatus) return null;
|
||||
|
||||
for (const status in syncStatus) {
|
||||
const file = syncStatus[status].find(f => f.file_path === filePath);
|
||||
if (file) return status;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 处理文件选择
|
||||
const handleSelect = (keys, info) => {
|
||||
if (info.node.isLeaf && keys.length > 0) {
|
||||
const filePath = keys[0];
|
||||
setSelectedFile(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步到数据库
|
||||
const syncToDb = async (filePath) => {
|
||||
try {
|
||||
await api.post('/sync/db/', {
|
||||
lobster_id: lobsterId,
|
||||
file_path: filePath,
|
||||
});
|
||||
message.success('已同步到数据库');
|
||||
loadSyncStatus();
|
||||
} catch (error) {
|
||||
message.error('同步失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 同步到本地
|
||||
const syncToLocal = async (filePath) => {
|
||||
try {
|
||||
await api.post('/sync/local/', {
|
||||
lobster_id: lobsterId,
|
||||
file_path: filePath,
|
||||
});
|
||||
message.success('已同步到本地');
|
||||
loadSyncStatus();
|
||||
} catch (error) {
|
||||
message.error('同步失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSyncStatus();
|
||||
}, []);
|
||||
|
||||
const [treeData, setTreeData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileTree().then(data => {
|
||||
if (data) {
|
||||
setTreeData(convertToTreeData(data));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title="记忆同步"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadSyncStatus}
|
||||
loading={loading}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{stats && (
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic title="总文件数" value={stats.total_files} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="总大小" value={stats.total_size_mb} suffix="MB" />
|
||||
</Col>
|
||||
{stats.status_counts.conflict > 0 && (
|
||||
<Col span={12}>
|
||||
<Alert
|
||||
message={`有 ${stats.status_counts.conflict} 个文件冲突`}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={10}>
|
||||
<Card title="文件树">
|
||||
<Spin spinning={loading}>
|
||||
{treeData.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
treeData={treeData}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
) : (
|
||||
<Alert message="暂无文件" type="info" />
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={14}>
|
||||
<Card
|
||||
title={selectedFile ? `文件对比: ${selectedFile}` : '文件对比'}
|
||||
extra={
|
||||
selectedFile && (
|
||||
<Button.Group>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => syncToLocal(selectedFile)}
|
||||
>
|
||||
同步到本地
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => syncToDb(selectedFile)}
|
||||
>
|
||||
同步到数据库
|
||||
</Button>
|
||||
</Button.Group>
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<FileDiff filePath={selectedFile} lobsterId={lobsterId} />
|
||||
) : (
|
||||
<Alert message="请选择文件查看差异" type="info" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Statistic({ title, value, suffix }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>{title}</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{value}
|
||||
{suffix && <span style={{ fontSize: '14px', marginLeft: '4px' }}>{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/index.css
Normal file
13
frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user