飞牛OS微信聊天记录备份工具

这是一个运行在飞牛OS上的微信聊天记录备份工具
This commit is contained in:
Dāi méng kǒng lóng
2025-06-02 04:22:18 +08:00
committed by GitHub
commit 93b0122325
20 changed files with 2121 additions and 0 deletions

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# 多阶段构建
# 阶段1: 构建前端
FROM node:18-alpine as frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# 阶段2: 构建后端
FROM node:18-alpine
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --production
# 复制后端代码
COPY backend/ ./
# 从前端构建阶段复制构建结果
COPY --from=frontend-builder /app/frontend/build ./public
# 创建数据目录
RUN mkdir -p /app/data
# 暴露端口
EXPOSE 5000
# 启动命令
CMD ["node", "index.js"]

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# 飞牛OS微信备份工具
这是一个运行在飞牛OS上的微信聊天记录备份工具提供以下功能
- 备份微信聊天记录
- 随时查看备份的聊天记录
- 恢复聊天记录到微信中
- 精致的UI界面与微信界面一致
- 安全的登录系统
## 界面预览
- 登录界面:精美的登录页面,确保用户安全访问
- 聊天列表:与微信界面一致的聊天列表,包含置顶功能、未读消息提示等
- 聊天详情:与微信聊天界面一致的布局,支持文本、图片、语音、视频等多种消息类型
- 仪表盘:直观展示备份数据统计信息
## 项目结构
```
.
├── backend/ # 后端代码
│ ├── api/ # API接口
│ ├── services/ # 业务逻辑
│ └── utils/ # 工具函数
├── frontend/ # 前端代码
│ ├── public/ # 静态资源
│ └── src/ # 源代码
│ ├── assets/ # 图片等资源
│ ├── components/# 组件
│ ├── pages/ # 页面
│ └── utils/ # 工具函数
└── README.md # 项目说明
```
## 技术栈
- 前端React, Ant Design
- 后端Node.js, Express
- 数据库SQLite
## 特色功能
- **微信风格界面**完全模仿微信的UI设计让用户有熟悉的使用体验
- **多种消息类型支持**:支持文本、图片、语音、视频等多种消息类型的备份和查看
- **聊天记录恢复**:可将备份的聊天记录恢复到微信中
- **数据安全**:本地存储,数据不会上传到云端,确保用户隐私
## 安装与使用
1. 克隆仓库到本地
2. 运行安装脚本:`./install.sh`
3. 访问 `http://localhost:8888`
4. 使用默认账号登录:
- 用户名admin
- 密码admin

148
backend/index.js Normal file
View File

@@ -0,0 +1,148 @@
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// 初始化Express应用
const app = express();
const PORT = process.env.PORT || 5000;
const JWT_SECRET = 'flyinox-wechat-backup-secret'; // 实际应用中应使用环境变量存储密钥
// 中间件
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// 简单的用户验证(实际应用中应使用数据库)
const users = [
{
id: 1,
username: 'admin',
password: '$2b$10$8KVH7I3NKGjaa2SYj1CMmuOFjBP5.U6H69H8y8fMHpgxBG.xzR.Ke', // admin
name: '管理员'
}
];
// 身份验证中间件
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ message: '未提供认证令牌' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ message: '无效或过期的令牌' });
req.user = user;
next();
});
};
// 路由定义
// 登录
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = users.find(user => user.username === username);
if (!user) return res.status(401).json({ message: '用户名或密码错误' });
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) return res.status(401).json({ message: '用户名或密码错误' });
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '24h' });
res.json({
token,
user: {
id: user.id,
username: user.username,
name: user.name
}
});
});
// 获取仪表盘统计数据
app.get('/api/dashboard/stats', authenticateToken, (req, res) => {
// 在实际应用中,这些数据应从数据库获取
res.json({
totalChats: 142,
totalContacts: 237,
totalMessages: 12483,
lastBackup: '2023-08-15 14:30:22'
});
});
// 获取聊天列表
app.get('/api/chats', authenticateToken, (req, res) => {
// 在实际应用中,这些数据应从数据库获取
const chats = [
{
id: '1',
name: '张三',
avatar: 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
lastMessage: '晚上一起吃饭吗?',
lastTime: '14:23',
unread: 3,
isGroup: false,
},
{
id: '2',
name: '工作群',
avatar: '',
lastMessage: '李四: 明天的会议推迟到下午2点',
lastTime: '昨天',
unread: 0,
isGroup: true,
},
// 其他聊天记录...
];
res.json(chats);
});
// 获取聊天详情
app.get('/api/chats/:chatId', authenticateToken, (req, res) => {
const { chatId } = req.params;
// 在实际应用中,这些数据应从数据库获取
const chatInfo = {
id: chatId,
name: chatId === '1' ? '张三' : (chatId === '2' ? '工作群' : '聊天'),
avatar: chatId === '1' ? 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png' : '',
isGroup: chatId === '2' || chatId === '4',
memberCount: chatId === '2' ? 15 : (chatId === '4' ? 5 : 2),
createdAt: '2022-05-20',
};
res.json(chatInfo);
});
// 获取聊天消息
app.get('/api/chats/:chatId/messages', authenticateToken, (req, res) => {
const { chatId } = req.params;
// 在实际应用中,这些数据应从数据库获取
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const messages = [
{
id: '1',
senderId: chatId === '1' ? '1' : 'user',
senderName: chatId === '1' ? '张三' : '我',
content: '你好,最近怎么样?',
type: 'text',
timestamp: '08:30',
date: today.toISOString().split('T')[0],
},
// 其他消息...
];
res.json(messages);
});
// 启动服务器
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
});

22
backend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "wechat-backup-backend",
"version": "0.1.0",
"private": true,
"main": "index.js",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"sqlite3": "^5.1.6",
"jsonwebtoken": "^9.0.2",
"bcrypt": "^5.1.1",
"multer": "^1.4.5-lts.1"
},
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3'
services:
wechat-backup:
build: .
container_name: wechat-backup
restart: always
ports:
- "8888:5000"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- PORT=5000
networks:
- wechat-backup-network
networks:
wechat-backup-network:
driver: bridge

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "wechat-backup-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.16.0",
"antd": "^5.8.6",
"axios": "^1.5.0",
"dayjs": "^1.11.9"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-scripts": "5.0.1"
}
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="飞牛OS微信备份工具"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>飞牛OS微信备份</title>
</head>
<body>
<noscript>您需要启用JavaScript才能运行此应用。</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
{
"short_name": "微信备份",
"name": "飞牛OS微信备份工具",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

35
frontend/src/App.css Normal file
View File

@@ -0,0 +1,35 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: #333;
background-color: #f5f5f5;
}
.app {
min-height: 100vh;
}
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

70
frontend/src/App.js Normal file
View File

@@ -0,0 +1,70 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/lib/locale/zh_CN';
// 导入页面组件
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import ChatView from './pages/ChatView';
import ChatDetail from './pages/ChatDetail';
// 导入全局样式
import './App.css';
function App() {
// 认证状态
const [isAuthenticated, setIsAuthenticated] = useState(false);
// 检查本地存储中是否有有效的token
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setIsAuthenticated(true);
}
}, []);
// 私有路由组件,只有认证后才能访问
const PrivateRoute = ({ children }) => {
return isAuthenticated ? children : <Navigate to="/login" />;
};
return (
<ConfigProvider locale={zhCN}>
<Router>
<div className="app">
<Routes>
<Route path="/login" element={
isAuthenticated ?
<Navigate to="/" /> :
<Login setIsAuthenticated={setIsAuthenticated} />
} />
<Route path="/" element={
<PrivateRoute>
<Dashboard setIsAuthenticated={setIsAuthenticated} />
</PrivateRoute>
} />
<Route path="/chats" element={
<PrivateRoute>
<ChatView />
</PrivateRoute>
} />
<Route path="/chat/:chatId" element={
<PrivateRoute>
<ChatDetail />
</PrivateRoute>
} />
{/* 默认重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</Router>
</ConfigProvider>
);
}
export default App;

10
frontend/src/index.js Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,297 @@
.chat-detail-layout {
min-height: 100vh;
background-color: #ededed;
display: flex;
flex-direction: column;
}
.chat-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background-color: #ededed;
box-shadow: none;
position: sticky;
top: 0;
z-index: 10;
height: 44px;
}
.chat-detail-header h4 {
margin: 0;
font-size: 17px;
font-weight: 500;
}
.back-button {
margin-right: 16px;
font-size: 16px;
color: #07c160;
}
.more-button {
color: #07c160;
font-size: 20px;
}
.chat-info {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.member-count {
font-size: 12px;
color: #888;
}
.chat-detail-content {
flex: 1;
padding: 0;
overflow-y: auto;
background-color: #ededed;
}
.messages-container {
padding: 16px;
display: flex;
flex-direction: column;
}
.message-date-group {
margin-bottom: 16px;
}
.date-divider {
text-align: center;
margin: 10px 0;
}
.date-divider span {
background-color: rgba(0, 0, 0, 0.1);
color: #888;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.message-wrapper {
margin: 16px 0;
}
.message-time-indicator {
text-align: center;
margin: 8px 0;
}
.message-time-indicator span {
color: #888;
font-size: 12px;
}
.message-item {
display: flex;
margin: 8px 0;
padding: 0;
border: none;
}
.message-mine {
flex-direction: row-reverse;
}
.message-avatar {
margin: 0 8px;
flex-shrink: 0;
}
.message-avatar-mine {
background-color: #07c160;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 70%;
}
.message-content-mine {
align-items: flex-end;
}
.message-sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.message-bubble {
background-color: #fff;
border-radius: 3px;
padding: 8px 12px;
word-break: break-word;
display: flex;
align-items: center;
position: relative;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
.message-bubble::before {
content: '';
position: absolute;
top: 12px;
left: -6px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid #fff;
}
.message-content-mine .message-bubble {
background-color: #95ec69;
}
.message-content-mine .message-bubble::before {
left: auto;
right: -6px;
border-right: none;
border-left: 6px solid #95ec69;
}
.message-bubble-image {
padding: 4px;
background-color: transparent;
box-shadow: none;
}
.message-bubble-image::before,
.message-bubble-video::before {
display: none;
}
.message-image img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
display: block;
}
.message-bubble-voice {
min-width: 80px;
}
.message-voice {
display: flex;
align-items: center;
}
.voice-duration {
margin-left: 8px;
color: #888;
}
.message-bubble-video {
padding: 4px;
background-color: transparent;
box-shadow: none;
}
.message-video {
position: relative;
}
.video-thumbnail {
position: relative;
}
.video-thumbnail img {
max-width: 200px;
max-height: 150px;
border-radius: 4px;
display: block;
}
.video-play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 12px;
padding: 2px 4px;
border-radius: 2px;
}
.chat-input-footer {
padding: 8px;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
}
.chat-input-container {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.input-action-button {
color: #888;
font-size: 20px;
}
.text-input-wrapper {
flex: 1;
margin: 0 8px;
}
.text-input-wrapper .ant-input {
border: none;
box-shadow: none;
padding: 4px 0;
}
.input-actions {
display: flex;
align-items: center;
}
.send-button {
background-color: #07c160;
border-color: #07c160;
margin-left: 8px;
}
.action-buttons {
display: flex;
justify-content: center;
margin-top: 8px;
}
.action-button {
margin: 0 8px;
}
.action-button:first-child {
background-color: #07c160;
border-color: #07c160;
}

View File

@@ -0,0 +1,398 @@
import React, { useState, useEffect, useRef } from 'react';
import { Layout, Button, Typography, Avatar, List, Spin, Divider, message, Tag, Space, Input, Tooltip } from 'antd';
import {
ArrowLeftOutlined,
DownloadOutlined,
RestoreOutlined,
CalendarOutlined,
FileImageOutlined,
AudioOutlined,
VideoCameraOutlined,
SmileOutlined,
PlusOutlined,
SendOutlined,
EllipsisOutlined
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import axios from 'axios';
import './ChatDetail.css';
const { Header, Content, Footer } = Layout;
const { Title, Text } = Typography;
const { TextArea } = Input;
const ChatDetail = () => {
const { chatId } = useParams();
const [loading, setLoading] = useState(true);
const [chatInfo, setChatInfo] = useState(null);
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const navigate = useNavigate();
// 获取聊天详情和消息
useEffect(() => {
const fetchData = async () => {
try {
// 实际应用中应该从后端API获取数据
// const chatResponse = await axios.get(`http://localhost:5000/api/chats/${chatId}`);
// const messagesResponse = await axios.get(`http://localhost:5000/api/chats/${chatId}/messages`);
// setChatInfo(chatResponse.data);
// setMessages(messagesResponse.data);
// 模拟API响应
setTimeout(() => {
const mockChatInfo = {
id: chatId,
name: chatId === '1' ? '张三' : (chatId === '2' ? '工作群' : '聊天'),
avatar: chatId === '1' ? 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png' : '',
isGroup: chatId === '2' || chatId === '4',
memberCount: chatId === '2' ? 15 : (chatId === '4' ? 5 : 2),
createdAt: '2022-05-20',
};
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const mockMessages = [
{
id: '1',
senderId: chatId === '1' ? '1' : 'user2',
senderName: chatId === '1' ? '张三' : '李四',
content: '你好,最近怎么样?',
type: 'text',
timestamp: '08:30',
date: today.toISOString().split('T')[0],
},
{
id: '2',
senderId: 'user',
senderName: '我',
content: '还不错,你呢?',
type: 'text',
timestamp: '08:32',
date: today.toISOString().split('T')[0],
},
{
id: '3',
senderId: chatId === '1' ? '1' : 'user2',
senderName: chatId === '1' ? '张三' : '李四',
content: '我很好,谢谢关心!',
type: 'text',
timestamp: '08:35',
date: today.toISOString().split('T')[0],
},
{
id: '4',
senderId: chatId === '1' ? '1' : 'user3',
senderName: chatId === '1' ? '张三' : '王五',
content: '[图片]',
type: 'image',
timestamp: '09:15',
date: today.toISOString().split('T')[0],
imageUrl: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
},
{
id: '5',
senderId: 'user',
senderName: '我',
content: '这张照片拍得真不错!',
type: 'text',
timestamp: '09:20',
date: today.toISOString().split('T')[0],
},
{
id: '6',
senderId: chatId === '1' ? '1' : 'user2',
senderName: chatId === '1' ? '张三' : '李四',
content: '谢谢!',
type: 'text',
timestamp: '09:25',
date: today.toISOString().split('T')[0],
},
{
id: '7',
senderId: 'user',
senderName: '我',
content: '晚上一起吃饭吗?',
type: 'text',
timestamp: '14:23',
date: today.toISOString().split('T')[0],
},
{
id: '8',
senderId: chatId === '1' ? '1' : 'user3',
senderName: chatId === '1' ? '张三' : '王五',
content: '[语音]',
type: 'voice',
timestamp: '15:30',
date: yesterday.toISOString().split('T')[0],
duration: '0:12'
},
{
id: '9',
senderId: 'user',
senderName: '我',
content: '[视频]',
type: 'video',
timestamp: '16:05',
date: yesterday.toISOString().split('T')[0],
duration: '0:48',
thumbnail: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'
},
];
setChatInfo(mockChatInfo);
setMessages(mockMessages);
setLoading(false);
}, 1000);
} catch (error) {
console.error('Error fetching chat data:', error);
message.error('获取聊天数据失败');
setLoading(false);
}
};
fetchData();
}, [chatId]);
// 滚动到底部
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
// 返回到聊天列表
const handleBack = () => {
navigate('/chats');
};
// 下载聊天记录
const handleDownload = () => {
message.info('正在下载聊天记录...');
// 实际应用中应调用后端API下载聊天记录
};
// 恢复到微信
const handleRestore = () => {
message.info('准备恢复聊天记录到微信...');
// 实际应用中应调用后端API启动恢复过程
};
// 发送消息
const handleSendMessage = () => {
if (!inputText.trim()) return;
const newMessage = {
id: `new-${Date.now()}`,
senderId: 'user',
senderName: '我',
content: inputText,
type: 'text',
timestamp: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
date: new Date().toISOString().split('T')[0],
};
setMessages([...messages, newMessage]);
setInputText('');
};
// 渲染消息内容
const renderMessageContent = (message) => {
switch (message.type) {
case 'image':
return (
<div className="message-image">
<img src={message.imageUrl} alt="图片消息" />
</div>
);
case 'voice':
return (
<div className="message-voice">
<AudioOutlined />
<span className="voice-duration">{message.duration}</span>
</div>
);
case 'video':
return (
<div className="message-video">
<div className="video-thumbnail">
<img src={message.thumbnail} alt="视频缩略图" />
<div className="video-play-icon">
<VideoCameraOutlined />
</div>
</div>
<span className="video-duration">{message.duration}</span>
</div>
);
default:
return <span>{message.content}</span>;
}
};
// 根据日期对消息分组
const messagesByDate = messages.reduce((acc, message) => {
if (!acc[message.date]) {
acc[message.date] = [];
}
acc[message.date].push(message);
return acc;
}, {});
// 检查是否需要显示时间
const shouldShowTime = (messages, index) => {
if (index === 0) return true;
const currentMsg = messages[index];
const prevMsg = messages[index - 1];
// 如果两条消息间隔超过5分钟显示时间
const currentTime = new Date(`2023-01-01 ${currentMsg.timestamp}`);
const prevTime = new Date(`2023-01-01 ${prevMsg.timestamp}`);
const diffMinutes = (currentTime - prevTime) / (1000 * 60);
return diffMinutes > 5;
};
return (
<Layout className="chat-detail-layout">
<Header className="chat-detail-header">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBack}
className="back-button"
/>
{chatInfo && (
<div className="chat-info">
<Title level={4}>{chatInfo.name}</Title>
{chatInfo.isGroup && (
<span className="member-count">{chatInfo.memberCount}</span>
)}
</div>
)}
<Button
type="text"
icon={<EllipsisOutlined />}
className="more-button"
/>
</Header>
<Content className="chat-detail-content" ref={messagesContainerRef}>
<Spin spinning={loading}>
<div className="messages-container">
{Object.keys(messagesByDate).map(date => (
<div key={date} className="message-date-group">
<div className="date-divider">
<span>{new Date(date).toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' })}</span>
</div>
{messagesByDate[date].map((message, index) => (
<div key={message.id} className="message-wrapper">
{shouldShowTime(messagesByDate[date], index) && (
<div className="message-time-indicator">
<span>{message.timestamp}</span>
</div>
)}
<div className={`message-item ${message.senderId === 'user' ? 'message-mine' : ''}`}>
{message.senderId !== 'user' && (
<Avatar
className="message-avatar"
src={message.senderId === '1' && chatInfo?.avatar ? chatInfo.avatar : null}
style={{ backgroundColor: !chatInfo?.avatar ? '#1890ff' : null }}
>
{!chatInfo?.avatar ? message.senderName.charAt(0) : null}
</Avatar>
)}
<div className={`message-content ${message.senderId === 'user' ? 'message-content-mine' : ''}`}>
{chatInfo?.isGroup && message.senderId !== 'user' && (
<div className="message-sender-name">{message.senderName}</div>
)}
<div className={`message-bubble ${message.type !== 'text' ? `message-bubble-${message.type}` : ''}`}>
{renderMessageContent(message)}
</div>
</div>
{message.senderId === 'user' && (
<Avatar className="message-avatar message-avatar-mine"></Avatar>
)}
</div>
</div>
))}
</div>
))}
<div ref={messagesEndRef} />
</div>
</Spin>
</Content>
<Footer className="chat-input-footer">
<div className="chat-input-container">
<Button
type="text"
icon={<AudioOutlined />}
className="input-action-button"
/>
<div className="text-input-wrapper">
<TextArea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="发送消息"
autoSize={{ minRows: 1, maxRows: 4 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
</div>
<div className="input-actions">
<Button
type="text"
icon={<SmileOutlined />}
className="input-action-button"
/>
<Button
type="text"
icon={<PlusOutlined />}
className="input-action-button"
/>
{inputText.trim() ? (
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSendMessage}
className="send-button"
shape="circle"
/>
) : null}
</div>
</div>
<div className="action-buttons">
<Tooltip title="下载聊天记录">
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleDownload}
className="action-button"
/>
</Tooltip>
<Tooltip title="恢复到微信">
<Button
icon={<RestoreOutlined />}
onClick={handleRestore}
className="action-button"
/>
</Tooltip>
</div>
</Footer>
</Layout>
);
};
export default ChatDetail;

View File

@@ -0,0 +1,161 @@
.chat-view-layout {
min-height: 100vh;
background-color: #ededed;
}
.chat-view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background-color: #ededed;
box-shadow: none;
position: sticky;
top: 0;
z-index: 10;
height: 44px;
}
.chat-view-header h4 {
margin: 0;
font-size: 17px;
font-weight: 500;
}
.back-button {
margin-right: 16px;
font-size: 16px;
color: #07c160;
}
.search-icon-button {
color: #07c160;
}
.wechat-tabs-container {
background-color: #ededed;
border-bottom: 1px solid #e0e0e0;
}
.wechat-tabs {
margin-bottom: 0;
}
.wechat-tabs .ant-tabs-nav {
margin-bottom: 0;
}
.wechat-tabs .ant-tabs-nav::before {
border: none;
}
.wechat-tabs .ant-tabs-tab {
padding: 8px 0;
font-size: 15px;
}
.wechat-tabs .ant-tabs-tab-active {
font-weight: 500;
}
.wechat-tabs .ant-tabs-ink-bar {
background-color: #07c160;
}
.search-container {
padding: 8px 16px;
background-color: #ededed;
}
.search-wrapper {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 4px;
padding: 4px 8px;
}
.search-icon {
color: #b2b2b2;
margin-right: 8px;
}
.search-input {
background-color: transparent;
}
.search-input:focus {
box-shadow: none;
}
.chat-view-content {
padding: 0;
background-color: #fff;
}
.chat-list-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.chat-item-pinned {
background-color: #f7f7f7;
}
.pinned-indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: #07c160;
}
.chat-list-item:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.chat-item-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-time {
margin-left: auto;
font-size: 12px;
}
.chat-item-desc {
display: flex;
align-items: center;
margin-top: 4px;
}
.last-message-text {
flex: 1;
max-width: 240px;
color: #b2b2b2 !important;
font-size: 13px;
}
.unread-badge {
margin-left: auto;
}
.unread-badge .ant-badge-count {
background-color: #f43530;
box-shadow: none;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: #b2b2b2;
}

View File

@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react';
import { Layout, List, Avatar, Input, Button, Spin, Typography, Badge, Tag, Tabs } from 'antd';
import {
SearchOutlined,
MessageOutlined,
UserOutlined,
ArrowLeftOutlined,
FileImageOutlined,
AudioOutlined,
VideoCameraOutlined,
WechatOutlined,
StarOutlined,
TeamOutlined
} from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import './ChatView.css';
const { Header, Content } = Layout;
const { Search } = Input;
const { Title, Text } = Typography;
const { TabPane } = Tabs;
const ChatView = () => {
const [loading, setLoading] = useState(true);
const [chats, setChats] = useState([]);
const [searchText, setSearchText] = useState('');
const [filteredChats, setFilteredChats] = useState([]);
const [activeTab, setActiveTab] = useState('chats');
const navigate = useNavigate();
// 加载聊天数据
useEffect(() => {
const fetchData = async () => {
try {
// 实际应用中应该从后端API获取数据
// const response = await axios.get('http://localhost:5000/api/chats');
// setChats(response.data);
// 模拟API响应
setTimeout(() => {
const mockChats = [
{
id: '1',
name: '张三',
avatar: 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
lastMessage: '晚上一起吃饭吗?',
lastTime: '14:23',
unread: 3,
isGroup: false,
isPinned: true,
},
{
id: '2',
name: '工作群',
avatar: '',
lastMessage: '李四: 明天的会议推迟到下午2点',
lastTime: '昨天',
unread: 0,
isGroup: true,
isPinned: true,
},
{
id: '3',
name: '王五',
avatar: 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
lastMessage: '[图片]',
lastTime: '周一',
unread: 0,
isGroup: false,
isPinned: false,
},
{
id: '4',
name: '家人群',
avatar: '',
lastMessage: '妈妈: 注意天气变化,记得添衣服',
lastTime: '08/12',
unread: 5,
isGroup: true,
isPinned: false,
},
{
id: '5',
name: '赵六',
avatar: 'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
lastMessage: '[语音]',
lastTime: '08/10',
unread: 0,
isGroup: false,
isPinned: false,
},
{
id: '6',
name: '同学群',
avatar: '',
lastMessage: '小明: 周末聚会,大家都来啊',
lastTime: '08/05',
unread: 0,
isGroup: true,
isPinned: false,
},
{
id: '7',
name: '李华',
avatar: '',
lastMessage: '好的,我知道了',
lastTime: '08/01',
unread: 0,
isGroup: false,
isPinned: false,
},
{
id: '8',
name: '技术交流群',
avatar: '',
lastMessage: '小张: 有人遇到这个问题吗?',
lastTime: '07/28',
unread: 0,
isGroup: true,
isPinned: false,
},
];
setChats(mockChats);
setFilteredChats(mockChats);
setLoading(false);
}, 1000);
} catch (error) {
console.error('Error fetching chats:', error);
setLoading(false);
}
};
fetchData();
}, []);
// 搜索功能
const handleSearch = (value) => {
setSearchText(value);
if (!value) {
setFilteredChats(chats);
return;
}
const filtered = chats.filter(chat =>
chat.name.toLowerCase().includes(value.toLowerCase()) ||
chat.lastMessage.toLowerCase().includes(value.toLowerCase())
);
setFilteredChats(filtered);
};
// 返回仪表盘
const handleBack = () => {
navigate('/');
};
// 渲染消息类型图标
const renderMessageTypeIcon = (message) => {
if (message.includes('[图片]')) {
return <FileImageOutlined style={{ marginRight: 8, color: '#52c41a' }} />;
} else if (message.includes('[语音]')) {
return <AudioOutlined style={{ marginRight: 8, color: '#1890ff' }} />;
} else if (message.includes('[视频]')) {
return <VideoCameraOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />;
}
return null;
};
// 根据置顶状态和最后消息时间排序
const sortedChats = [...filteredChats].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return 0;
});
return (
<Layout className="chat-view-layout">
<Header className="chat-view-header">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBack}
className="back-button"
/>
<Title level={4}>微信</Title>
<Button
type="text"
icon={<SearchOutlined />}
className="search-icon-button"
onClick={() => document.querySelector('.search-input').focus()}
/>
</Header>
<div className="wechat-tabs-container">
<Tabs
defaultActiveKey="chats"
onChange={setActiveTab}
centered
className="wechat-tabs"
>
<TabPane
tab={<span><WechatOutlined />聊天</span>}
key="chats"
/>
<TabPane
tab={<span><TeamOutlined />通讯录</span>}
key="contacts"
/>
<TabPane
tab={<span><StarOutlined />收藏</span>}
key="favorites"
/>
</Tabs>
</div>
<div className="search-container">
<div className="search-wrapper">
<SearchOutlined className="search-icon" />
<Input
placeholder="搜索"
bordered={false}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onPressEnter={() => handleSearch(searchText)}
className="search-input"
/>
</div>
</div>
<Content className="chat-view-content">
<Spin spinning={loading}>
{activeTab === 'chats' && (
<List
dataSource={sortedChats}
renderItem={item => (
<List.Item
className={`chat-list-item ${item.isPinned ? 'chat-item-pinned' : ''}`}
onClick={() => navigate(`/chat/${item.id}`)}
>
{item.isPinned && <div className="pinned-indicator"></div>}
<List.Item.Meta
avatar={
item.avatar ? (
<Avatar src={item.avatar} size={48} />
) : (
<Avatar size={48} style={{ backgroundColor: item.isGroup ? '#1890ff' : '#f56a00' }}>
{item.isGroup ? 'G' : item.name.charAt(0)}
</Avatar>
)
}
title={
<div className="chat-item-title">
<Text strong>{item.name}</Text>
<Text type="secondary" className="chat-time">{item.lastTime}</Text>
</div>
}
description={
<div className="chat-item-desc">
{renderMessageTypeIcon(item.lastMessage)}
<Text type="secondary" ellipsis className="last-message-text">
{item.lastMessage}
</Text>
{item.unread > 0 && (
<Badge count={item.unread} className="unread-badge" />
)}
</div>
}
/>
</List.Item>
)}
/>
)}
{activeTab === 'contacts' && (
<div className="placeholder-content">
<TeamOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<p>通讯录功能正在开发中</p>
</div>
)}
{activeTab === 'favorites' && (
<div className="placeholder-content">
<StarOutlined style={{ fontSize: 48, color: '#d9d9d9' }} />
<p>收藏功能正在开发中</p>
</div>
)}
</Spin>
</Content>
</Layout>
);
};
export default ChatView;

View File

@@ -0,0 +1,86 @@
.dashboard-layout {
min-height: 100vh;
}
.dashboard-sider {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
z-index: 10;
}
.logo {
display: flex;
align-items: center;
height: 64px;
padding: 0 16px;
overflow: hidden;
border-bottom: 1px solid #f0f0f0;
}
.logo-img {
width: 32px;
height: 32px;
margin-right: 8px;
}
.logo-text {
color: #333;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
.dashboard-header {
display: flex;
align-items: center;
padding: 0 16px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 1;
}
.trigger-button {
font-size: 18px;
margin-right: 16px;
}
.header-title {
margin: 0;
flex: 1;
}
.dashboard-content {
margin: 24px;
overflow: initial;
}
.content-wrapper {
padding: 16px;
background: #fff;
border-radius: 4px;
}
.action-row {
margin-top: 24px;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.action-description {
margin-top: 12px;
color: #999;
}
.sider-footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 16px;
border-top: 1px solid #f0f0f0;
}

View File

@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, Typography, Statistic, Card, Row, Col, Spin, message } from 'antd';
import {
MessageOutlined,
UserOutlined,
FileOutlined,
CloudUploadOutlined,
CloudDownloadOutlined,
LogoutOutlined,
HomeOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
ClockCircleOutlined
} from '@ant-design/icons';
import { useNavigate, Link } from 'react-router-dom';
import axios from 'axios';
import './Dashboard.css';
const { Header, Sider, Content } = Layout;
const { Title } = Typography;
const Dashboard = ({ setIsAuthenticated }) => {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalChats: 0,
totalContacts: 0,
totalMessages: 0,
lastBackup: '未备份'
});
const navigate = useNavigate();
// 假设从后端API获取数据
useEffect(() => {
const fetchData = async () => {
try {
// 实际应用中应该从后端API获取数据
// const response = await axios.get('http://localhost:5000/api/dashboard/stats');
// setStats(response.data);
// 模拟API响应
setTimeout(() => {
setStats({
totalChats: 142,
totalContacts: 237,
totalMessages: 12483,
lastBackup: '2023-08-15 14:30:22'
});
setLoading(false);
}, 1000);
} catch (error) {
console.error('Error fetching data:', error);
message.error('获取数据失败');
setLoading(false);
}
};
fetchData();
}, []);
// 登出函数
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setIsAuthenticated(false);
navigate('/login');
};
// 创建新备份
const handleCreateBackup = () => {
message.info('正在启动备份过程...');
// 实际应用中应调用后端API启动备份
};
// 恢复备份到微信
const handleRestoreBackup = () => {
message.info('准备恢复备份到微信...');
// 实际应用中应调用后端API启动恢复过程
};
return (
<Layout className="dashboard-layout">
<Sider trigger={null} collapsible collapsed={collapsed} theme="light" className="dashboard-sider">
<div className="logo">
<img src="/logo.png" alt="Logo" className="logo-img" />
{!collapsed && <span className="logo-text">飞牛OS微信备份</span>}
</div>
<Menu
mode="inline"
defaultSelectedKeys={['1']}
items={[
{
key: '1',
icon: <HomeOutlined />,
label: '仪表盘',
},
{
key: '2',
icon: <MessageOutlined />,
label: <Link to="/chats">聊天记录</Link>,
},
{
key: '3',
icon: <UserOutlined />,
label: '联系人',
},
{
key: '4',
icon: <FileOutlined />,
label: '文件管理',
},
]}
/>
<div className="sider-footer">
<Button
type="primary"
danger
icon={<LogoutOutlined />}
onClick={handleLogout}
block
>
{!collapsed && '退出登录'}
</Button>
</div>
</Sider>
<Layout className="site-layout">
<Header className="dashboard-header">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="trigger-button"
/>
<div className="header-title">
<Title level={4}>微信备份仪表盘</Title>
</div>
</Header>
<Content className="dashboard-content">
<Spin spinning={loading}>
<div className="content-wrapper">
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="聊天会话"
value={stats.totalChats}
prefix={<MessageOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="联系人"
value={stats.totalContacts}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="消息总数"
value={stats.totalMessages}
prefix={<MessageOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Card>
<Statistic
title="最近备份时间"
value={stats.lastBackup}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="action-row">
<Col xs={24} sm={12}>
<Card className="action-card">
<Button
type="primary"
icon={<CloudUploadOutlined />}
size="large"
onClick={handleCreateBackup}
block
>
创建新备份
</Button>
<p className="action-description">
创建微信聊天记录联系人和媒体文件的新备份
</p>
</Card>
</Col>
<Col xs={24} sm={12}>
<Card className="action-card">
<Button
type="default"
icon={<CloudDownloadOutlined />}
size="large"
onClick={handleRestoreBackup}
block
>
恢复备份到微信
</Button>
<p className="action-description">
将已备份的聊天记录和媒体文件恢复到微信中
</p>
</Card>
</Col>
</Row>
</div>
</Spin>
</Content>
</Layout>
</Layout>
);
};
export default Dashboard;

View File

@@ -0,0 +1,50 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.login-content {
width: 100%;
max-width: 420px;
padding: 20px;
}
.login-card {
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.login-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
}
.login-logo {
width: 80px;
height: 80px;
margin-bottom: 16px;
}
.login-form {
margin-top: 24px;
}
.login-button {
width: 100%;
height: 45px;
border-radius: 6px;
font-size: 16px;
background: #4361ee;
border-color: #4361ee;
}
.login-button:hover,
.login-button:focus {
background: #3f37c9;
border-color: #3f37c9;
}

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { Form, Input, Button, Card, Typography, message, Spin } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import axios from 'axios';
import './Login.css';
const { Title } = Typography;
const Login = ({ setIsAuthenticated }) => {
const [loading, setLoading] = useState(false);
const onFinish = async (values) => {
try {
setLoading(true);
// 实际应用中会连接到后端API
const response = await axios.post('http://localhost:5000/api/login', {
username: values.username,
password: values.password,
});
if (response.data.token) {
localStorage.setItem('token', response.data.token);
localStorage.setItem('user', JSON.stringify(response.data.user));
setIsAuthenticated(true);
message.success('登录成功!');
}
} catch (error) {
message.error('登录失败,请检查用户名和密码!');
console.error('Login error:', error);
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-content">
<Card className="login-card">
<div className="login-header">
<img src="/logo.png" alt="飞牛OS微信备份" className="login-logo" />
<Title level={3}>飞牛OS微信备份</Title>
</div>
<Spin spinning={loading}>
<Form
name="login"
initialValues={{ remember: true }}
onFinish={onFinish}
size="large"
className="login-form"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="login-button">
登录
</Button>
</Form.Item>
</Form>
</Spin>
</Card>
</div>
</div>
);
};
export default Login;

49
install.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# 打印彩色信息
function print_info() {
echo -e "\033[36m$1\033[0m"
}
function print_success() {
echo -e "\033[32m$1\033[0m"
}
function print_error() {
echo -e "\033[31m$1\033[0m"
}
print_info "=== 飞牛OS微信备份工具安装脚本 ==="
print_info "正在检查环境..."
# 检查Docker是否安装
if ! command -v docker &> /dev/null; then
print_error "未检测到Docker请先安装Docker。"
exit 1
fi
# 检查Docker Compose是否安装
if ! command -v docker-compose &> /dev/null; then
print_error "未检测到Docker Compose请先安装Docker Compose。"
exit 1
fi
print_success "环境检查通过!"
# 创建数据目录
print_info "创建数据目录..."
mkdir -p data
# 构建并启动容器
print_info "构建并启动容器..."
docker-compose up -d
if [ $? -eq 0 ]; then
print_success "飞牛OS微信备份工具已成功安装"
print_info "您现在可以通过浏览器访问 http://localhost:8888 使用该工具"
print_info "默认用户名: admin"
print_info "默认密码: admin"
print_info "请尽快修改默认密码以确保安全"
else
print_error "安装过程中出现错误,请检查上面的错误信息"
fi