feat: 完成龙虾记忆同步系统

后端:
- Django + DRF
- PostgreSQL 数据库
- 文件扫描服务
- 差异检查服务
- 完整 REST API

前端:
- React + Ant Design
- 文件树展示
- 差异对比
- API 客户端封装

部署:
- Docker Compose
- 后端 Dockerfile
- 前端 Dockerfile
- 一键启动脚本

功能:
- 扫描龙虾记忆文件
- 检查文件差异
- 双向同步(本地 <-> 数据库)
- 版本历史
- 统计信息
This commit is contained in:
道童
2026-04-05 12:04:13 +00:00
commit f176e2d818
24 changed files with 1621 additions and 0 deletions

28
frontend/src/App.css Normal file
View 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
View 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
View 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;

View 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>
);
}

View 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
View 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
View 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>
);