Compare commits

...

10 Commits

Author SHA1 Message Date
hu xiaotong
41814a2bc8 feat(frontend): 优化配装页面布局和功能
-调整顶部角色和选项区的布局,增加角色头像和神器信息
- 优化属性区、属性过滤区和指定套装区的样式,采用纵向布局
- 添加角色信息编辑弹窗,可编辑神器、阵型等信息
- 移除按钮区的固定宽度样式,使其自适应布局
- 调整配装结果列表和详情区的样式,优化间距和对齐
2025-07-04 17:04:04 +08:00
hu xiaotong
721770f70a refactor(frontend): 更新 OptimizerPage 组件注释
-将"顶部选项区"注释修改为"顶部角色和选项区"
- 此修改更好地描述了该区域的内容,包括角色头像和选择
2025-07-04 15:27:57 +08:00
hu xiaotong
69c3546ac0 feat(database): 实现数据库功能并优化数据导出
- 新增数据库相关 API 和服务
- 实现数据导出功能,支持导出到 JSON 文件
- 优化数据导入流程,增加数据校验
- 新增数据库页面,展示解析数据和统计信息
- 更新捕获页面,支持导入数据到数据库
2025-07-04 15:20:14 +08:00
hu xiaotong
1b90af57ba feat(database): 实现数据库功能并优化数据导出
- 新增数据库相关 API 和服务
- 实现数据导出功能,支持导出到 JSON 文件
- 优化数据导入流程,增加数据校验
- 新增数据库页面,展示解析数据和统计信息
- 更新捕获页面,支持导入数据到数据库
2025-07-04 12:48:40 +08:00
hu xiaotong
910e2d4c4d feat(frontend): 添加捕获存储钩子
- 新增 useCapture 使用 Zustand 库创建状态管理
2025-07-03 10:00:32 +08:00
hxt
7865b0da7f refactor(frontend): 重构数据解析逻辑
- 新增 StopAndParseCapture 函数,整合停止抓包和解析数据流程
-重构 ReadRawJsonFile 函数,返回解析后的 ParsedResult 对象
- 优化数据解析流程,提高代码可读性和性能
- 调整前端 CapturePage组件,使用新的解析接口
2025-07-02 22:42:43 +08:00
hxt
3af8fd6e5e docs(README): 更新安装后端依赖说明
- 添加安装 Wails 最新版本的命令
- 更新 package.json.md5 校验值
2025-07-02 21:20:58 +08:00
hxt
c4014499e3 docs(README): 更新安装后端依赖说明
- 添加安装 Wails 最新版本的命令
- 更新 package.json.md5 校验值
2025-07-02 20:31:16 +08:00
hu xiaotong
92807c8554 feat(frontend): 添加捕获存储钩子
- 新增 useCapture 使用 Zustand 库创建状态管理
2025-07-02 17:21:59 +08:00
hu xiaotong
44ee8da1ed feat(frontend): 添加捕获存储钩子
- 新增 useCaptureStore 钩子用于管理捕获结果数据
- 定义
2025-07-02 16:42:19 +08:00
26 changed files with 1488 additions and 451713 deletions

View File

@@ -7,7 +7,11 @@
- 🎯 **TCP包抓取** - 实时抓取游戏客户端的TCP通信数据
- 🔍 **数据解析** - 自动解析十六进制数据为可读的装备信息
- 📊 **数据可视化** - 现代化的React界面展示装备数据
- 💾 **数据导出** - 支持导出为JSON格式
- 💾 **数据持久化** - 基于SQLite的本地数据存储支持装备数据的增删改查
- 📤 **数据导出** - 支持导出为JSON格式
- 🔍 **数据搜索** - 支持装备数据的模糊搜索和筛选
- 📈 **数据统计** - 提供装备数据的统计分析功能
- ⚙️ **设置管理** - 应用设置的本地持久化存储
- 🚀 **高性能** - 基于Go语言的高性能网络处理
## 技术栈
@@ -17,6 +21,7 @@
- **Wails v2** - 桌面应用框架
- **gopacket** - 网络包抓取
- **zap** - 结构化日志
- **SQLite** - 本地数据存储
### 前端
- **React 18** - 用户界面框架
@@ -42,6 +47,7 @@ cd equipment-analyzer
2. **安装后端依赖**
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
go mod tidy
```
@@ -108,6 +114,56 @@ equipment-analyzer/
## 开发指南
### 常见问题解决
#### 1. React严格模式导致的重复调用
在开发模式下React严格模式会导致组件渲染两次这可能导致
- useEffect被重复执行
- 异步操作被重复调用
- 用户提示消息重复显示
**解决方案:**
- **全局消息去重配置**:在`frontend/src/utils/messageConfig.ts`中配置Antd message的全局设置
- **防重复机制**使用缓存机制防止2秒内相同消息重复显示
- **最大显示数量限制**:设置`maxCount: 1`确保同时只显示一个消息
**全局配置:**
```typescript
// frontend/src/utils/messageConfig.ts
import { message } from 'antd';
// 配置全局消息设置,防止重复显示
message.config({
maxCount: 1, // 最多同时显示1个消息
duration: 3, // 消息显示3秒
top: 24, // 距离顶部24px
});
// 防重复消息函数
const messageCache = new Map<string, number>();
const DEBOUNCE_TIME = 2000; // 2秒内相同消息不重复显示
export const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string) => {
const messageId = `${type}:${content}`;
const now = Date.now();
// 检查是否在防抖时间内
const lastTime = messageCache.get(messageId);
if (lastTime && now - lastTime < DEBOUNCE_TIME) {
return; // 跳过重复消息
}
// 更新缓存并显示消息
messageCache.set(messageId, now);
message[type](content);
};
```
**使用方法:**
-`main.tsx`中导入配置:`import './utils/messageConfig'`
- 在组件中正常使用`message.success()``message.error()`
- 全局配置会自动防止重复消息显示
### 添加新功能
1. **后端功能**
@@ -120,6 +176,23 @@ equipment-analyzer/
- 使用TypeScript确保类型安全
- 遵循Ant Design设计规范
### 数据库操作
1. **添加新的数据表**
-`internal/model/database.go``initTables()`方法中添加表结构
-`Database`结构体中添加相应的CRUD方法
-`DatabaseService`中添加业务逻辑方法
-`App`中添加API接口方法
2. **数据迁移**
- 数据库结构变更时,在`initTables()`方法中处理版本升级
- 使用事务确保数据一致性
3. **性能优化**
- 为常用查询字段添加索引
- 使用批量操作减少数据库交互
- 合理使用连接池和事务
### 测试
```bash
@@ -146,8 +219,27 @@ make release
make clean
```
## 配置说明
## 数据存储
### 数据库存储
应用使用SQLite数据库进行本地数据持久化数据库文件位置`~/.equipment-analyzer/equipment_analyzer.db`
#### 数据库表结构
- **equipment** - 装备基本信息表
- **equipment_operations** - 装备操作属性表
- **capture_sessions** - 抓包会话记录表
- **raw_packet_data** - 原始数据包表
- **app_settings** - 应用设置表
#### 数据库功能
- ✅ 装备数据的增删改查
- ✅ 批量数据导入导出
- ✅ 装备数据搜索和筛选
- ✅ 数据统计分析
- ✅ 应用设置持久化
- ✅ 抓包会话历史记录
### 配置文件
应用会在用户目录下创建配置文件:`~/.equipment-analyzer/config.json`
```json

View File

@@ -1,14 +1,10 @@
import React, {useEffect, useState, useRef} from 'react'
import {Button, Card, Layout, message, Select, Spin, Table} from 'antd'
import {DownloadOutlined, PlayCircleOutlined, SettingOutlined, StopOutlined} from '@ant-design/icons'
import React from 'react'
import {Layout, Menu} from 'antd'
import './App.css'
import {ExportData, GetCapturedData,GetNetworkInterfaces,
ParseData,StartCapture,StopCapture, ReadRawJsonFile
} from "../wailsjs/go/service/App";
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Menu } from 'antd';
import {BrowserRouter as Router, Link, Route, Routes, useLocation} from 'react-router-dom';
import CapturePage from './pages/CapturePage';
import OptimizerPage from './pages/OptimizerPage';
import DatabasePage from './pages/DatabasePage';
const {Header, Content, Sider} = Layout
@@ -51,6 +47,9 @@ function AppContent() {
<Menu.Item key="/">
<Link to="/"></Link>
</Menu.Item>
<Menu.Item key="/database">
<Link to="/database"></Link>
</Menu.Item>
<Menu.Item key="/optimizer">
<Link to="/optimizer"></Link>
</Menu.Item>
@@ -59,6 +58,7 @@ function AppContent() {
<Content style={{ padding: 0, minHeight: 280 }}>
<Routes>
<Route path="/" element={<CapturePage />} />
<Route path="/database" element={<DatabasePage />} />
<Route path="/optimizer" element={<OptimizerPage />} />
</Routes>
</Content>

View File

@@ -48,10 +48,6 @@ button {
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {

View File

@@ -1,15 +1,13 @@
import React, {useEffect, useRef, useState} from 'react';
import {Button, Card, Layout, message, Select, Spin, Table} from 'antd';
import {DownloadOutlined, PlayCircleOutlined, SettingOutlined, StopOutlined} from '@ant-design/icons';
import {DownloadOutlined, PlayCircleOutlined, StopOutlined, UploadOutlined} from '@ant-design/icons';
import '../App.css';
import {
ExportData,
GetCapturedData,
GetNetworkInterfaces,
ParseData,
ReadRawJsonFile,
SaveParsedDataToDatabase,
StartCapture,
StopCapture
StopAndParseCapture
} from '../../wailsjs/go/service/App';
import {useCaptureStore} from '../store/useCaptureStore';
@@ -64,36 +62,36 @@ function CapturePage() {
const safeApiCall = async <T,>(
apiCall: () => Promise<T>,
errorMessage: string,
fallbackValue: T
): Promise<T> => {
errorMessage: string
): Promise<T | undefined> => {
try {
return await apiCall();
} catch (error) {
console.error(`${errorMessage}:`, error);
showMessage('error', errorMessage);
return fallbackValue;
return undefined;
}
};
const fetchInterfaces = async () => {
setIsCapturing(false);
setCapturedData([]);
setParsedData(null);
setLoading(false);
setInterfaceLoading(true);
try {
const response = await safeApiCall(
() => GetNetworkInterfaces(),
'获取网络接口失败,使用模拟数据',
[
{ name: 'eth0', description: 'Ethernet', addresses: ['192.168.1.100'], is_loopback: false },
{ name: 'wlan0', description: 'Wireless', addresses: ['192.168.1.101'], is_loopback: false },
{ name: 'lo', description: 'Loopback', addresses: ['127.0.0.1'], is_loopback: true }
]
'获取网络接口失败'
);
if (!response || response.length === 0) {
showMessage('error', '未获取到任何网络接口');
setInterfaces([]);
setSelectedInterface('');
return;
}
setInterfaces(response);
let defaultSelected = '';
console.log("获取的网卡:"+JSON.stringify(response))
for (const iface of response) {
if (iface.addresses && iface.addresses.some(ip => ip.includes('192.168'))) {
defaultSelected = iface.name;
@@ -106,13 +104,9 @@ function CapturePage() {
setSelectedInterface(defaultSelected);
} catch (error) {
console.error('获取网络接口时发生未知错误:', error);
const defaultInterfaces = [
{ name: 'eth0', description: 'Ethernet', addresses: ['192.168.1.100'], is_loopback: false },
{ name: 'wlan0', description: 'Wireless', addresses: ['192.168.1.101'], is_loopback: false },
{ name: 'lo', description: 'Loopback', addresses: ['127.0.0.1'], is_loopback: true }
];
setInterfaces(defaultInterfaces);
setSelectedInterface(defaultInterfaces[0].name);
showMessage('error', '获取网络接口时发生未知错误');
setInterfaces([]);
setSelectedInterface('');
} finally {
setInterfaceLoading(false);
}
@@ -125,17 +119,20 @@ function CapturePage() {
}
setLoading(true);
try {
await safeApiCall(
const result = await safeApiCall(
() => StartCapture(selectedInterface),
'开始抓包失败,但界面将继续工作',
undefined
'开始抓包失败'
);
if (result === undefined) {
setIsCapturing(false);
return;
}
setIsCapturing(true);
showMessage('success', '开始抓包');
} catch (error) {
console.error('开始抓包时发生未知错误:', error);
setIsCapturing(true);
showMessage('success', '开始抓包(模拟模式)');
setIsCapturing(false);
showMessage('error', '开始抓包失败');
} finally {
setLoading(false);
}
@@ -145,69 +142,26 @@ function CapturePage() {
setLoading(false);
setIsCapturing(false);
setCapturedData([]);
setParsedData(null);
try {
setLoading(true);
await safeApiCall(
() => StopCapture(),
'停止抓包失败,但界面将继续工作',
undefined
// 新接口:直接停止抓包并解析
const parsedData = await safeApiCall(
() => StopAndParseCapture(),
'停止抓包并解析数据失败'
);
setIsCapturing(false);
const data = await safeApiCall(
() => GetCapturedData(),
'获取抓包数据失败,使用模拟数据',
['mock_data_1', 'mock_data_2', 'mock_data_3']
);
setCapturedData(data);
if (data && data.length > 0) {
data.forEach((item, idx) => {
let hexStr = '';
if (/^[0-9a-fA-F\s]+$/.test(item)) {
hexStr = item;
} else {
hexStr = Array.from(item).map(c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
console.log("解析数据:"+JSON.stringify(parsedData))
if (!parsedData || !Array.isArray((parsedData as CaptureResult).items)) {
setParsedData({ items: [], heroes: [] } as CaptureResult);
showMessage('error', '解析数据失败');
return;
}
console.log(`抓包数据[${idx}]:`, hexStr);
});
}
if (data.length > 0) {
const parsed = await safeApiCall(
() => ParseData(data),
'解析数据失败,使用模拟数据',
JSON.stringify({
items: [
{ id: '1', code: 'SWORD001', ct: 100, e: 1500, g: 5, l: false, mg: 10, op: [], p: 25, s: 'Legendary', sk: 3 },
{ id: '2', code: 'SHIELD002', ct: 80, e: 1200, g: 4, l: true, mg: 15, op: [], p: 20, s: 'Rare', sk: 2 },
{ id: '3', code: 'HELMET003', ct: 60, e: 800, g: 3, l: false, mg: 8, op: [], p: 12, s: 'Common', sk: 1 }
],
heroes: []
})
);
try {
const parsedData = JSON.parse(parsed);
setParsedData(parsedData);
setParsedData(parsedData as CaptureResult);
showMessage('success', '数据处理完成');
} catch (parseError) {
console.error('解析JSON失败:', parseError);
setParsedData({
items: [
{ id: '1', code: 'SWORD001', ct: 100, e: 1500, g: 5, l: false, mg: 10, op: [], p: 25, s: 'Legendary', sk: 3 },
{ id: '2', code: 'SHIELD002', ct: 80, e: 1200, g: 4, l: true, mg: 15, op: [], p: 20, s: 'Rare', sk: 2 },
{ id: '3', code: 'HELMET003', ct: 60, e: 800, g: 3, l: false, mg: 8, op: [], p: 12, s: 'Common', sk: 1 }
],
heroes: []
});
showMessage('success', '数据处理完成(使用模拟数据)');
}
} else {
showMessage('warning', '未捕获到数据');
}
} catch (error) {
console.error('停止抓包时发生未知错误:', error);
setIsCapturing(false);
setCapturedData([]);
setParsedData(null);
setParsedData({ items: [], heroes: [] } as CaptureResult);
setLoading(false);
showMessage('error', '抓包失败,已重置状态');
return;
@@ -216,47 +170,102 @@ function CapturePage() {
}
};
const exportData = async () => {
if (!capturedData.length) {
const exportData = () => {
// 检查是否有解析数据
if (!parsedData || !Array.isArray(parsedData.items) || parsedData.items.length === 0) {
showMessage('warning', '没有数据可导出');
return;
}
try {
const filename = `equipment_data_${Date.now()}.json`;
await safeApiCall(
() => ExportData(capturedData, filename),
'导出数据失败',
undefined
);
// 创建要导出的数据内容
const exportContent = JSON.stringify(parsedData, null, 2);
// 创建 Blob 对象
const blob = new Blob([exportContent], { type: 'text/plain;charset=utf-8' });
// 创建下载链接
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'gear.txt';
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
showMessage('success', '数据导出成功');
} catch (error) {
console.error('导出数据时发生未知错误:', error);
showMessage('success', '数据导出成功(模拟模式)');
console.error('导出数据时发生错误:', error);
showMessage('error', '导出数据失败');
}
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploadedFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const json = JSON.parse(text);
// 校验数据格式
if (!json || typeof json !== 'object') {
showMessage('error', '文件格式错误不是有效的JSON对象');
return;
}
if (!json.heroes || !Array.isArray(json.heroes)) {
showMessage('error', '数据格式错误缺少heroes数组');
return;
}
if (!json.items || !Array.isArray(json.items)) {
showMessage('error', '数据格式错误缺少items数组');
return;
}
if (json.heroes.length === 0) {
showMessage('error', '数据格式错误heroes数组不能为空');
return;
}
if (json.items.length === 0) {
showMessage('error', '数据格式错误items数组不能为空');
return;
}
// 数据校验通过,保存到数据库
const sessionName = `import_${Date.now()}`;
const equipmentJSON = JSON.stringify(json.items);
const heroesJSON = JSON.stringify(json.heroes);
await SaveParsedDataToDatabase(sessionName, equipmentJSON, heroesJSON);
// 更新界面显示
const safeData = {
items: Array.isArray(json.items) ? json.items : [],
heroes: Array.isArray(json.heroes) ? json.heroes : []
items: json.items,
heroes: json.heroes
};
setParsedData(safeData);
if (safeData.items.length === 0 && safeData.heroes.length === 0) {
showMessage('warning', '上传文件数据为空,请检查文件内容');
} else {
showMessage('success', `文件解析成功:${safeData.items.length}件装备,${safeData.heroes.length}个英雄`);
}
setUploadedFileName(''); // 清空文件名显示
showMessage('success', `数据导入成功:${json.items.length}件装备,${json.heroes.length}个英雄,已保存到数据库`);
} catch (err) {
console.error('文件格式错误,无法解析:', err);
showMessage('error', '文件格式错误,无法解析');
console.error('文件处理错误:', err);
if (err instanceof SyntaxError) {
showMessage('error', '文件格式错误不是有效的JSON格式');
} else {
showMessage('error', '数据导入失败');
}
setParsedData({ items: [], heroes: [] });
}
};
@@ -266,8 +275,7 @@ function CapturePage() {
const fetchParsedDataFromBackend = async () => {
setLoading(true);
try {
const raw = await ReadRawJsonFile();
const json = JSON.parse(raw);
const json = await ReadRawJsonFile();
console.log('已加载本地解析数据:', json);
const safeData = {
items: Array.isArray(json.items) ? json.items : [],
@@ -347,39 +355,10 @@ function CapturePage() {
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#fff', padding: '0 16px', height: 48, lineHeight: '48px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: 48 }}>
<h1 style={{ margin: 0, fontSize: 20 }}></h1>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button
type="primary"
icon={<SettingOutlined />}
onClick={handleRefreshParsedData}
loading={loading}
style={{ flex: 1, height: 32 }}
></Button>
<Button style={{ flex: 1, height: 32 }} onClick={handleUploadButtonClick}>
JSON
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
{uploadedFileName && (
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{uploadedFileName}</span>
)}
</div>
</div>
</Header>
<Layout>
<Sider width={220} style={{ background: '#fff' }}>
<div style={{ padding: '12px' }}>
<Card title="抓包控制" size="small">
<Sider width={220} style={{ background: '#f5f5f5' }}>
<div style={{ padding: '16px 0px 12px 12px' }}>
<Card title="抓包控制" size="small" style={{ marginBottom: 12, marginTop: 0 }}>
<div style={{ marginBottom: 12 }}>
<label></label>
<Select
@@ -423,19 +402,38 @@ function CapturePage() {
<Button
icon={<DownloadOutlined />}
onClick={exportData}
disabled={!capturedData.length}
disabled={!parsedData || !Array.isArray(parsedData.items) || parsedData.items.length === 0}
style={{ width: '100%', height: 32, fontSize: 14 }}>
</Button>
<Button
icon={<UploadOutlined />}
onClick={handleUploadButtonClick}
style={{ width: '100%', height: 32, fontSize: 14 }}>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
{uploadedFileName && (
<span style={{ marginTop: 4, color: '#888', fontSize: 10, textAlign: 'center', display: 'block' }}>
{uploadedFileName}
</span>
)}
</div>
</Card>
<Card title="抓包状态" size="small" style={{ marginTop: 12 }}>
<div>
<p style={{ marginBottom: 4 }}>: {isCapturing ? '正在抓包...' : '准备就绪'}</p>
<p style={{ marginBottom: 4 }}>: {capturedData.length} </p>
{/*<p style={{ marginBottom: 4 }}>捕获数据: {capturedData.length} 条</p>*/}
<p style={{ marginBottom: 4 }}>: {Array.isArray(parsedData?.heroes) ? parsedData.heroes.length : 0} </p>
{parsedData && (
<p style={{ marginBottom: 0 }}>: {parsedData.items.length} </p>
<p style={{ marginBottom: 0 }}>: {Array.isArray(parsedData?.items) ? parsedData.items.length : 0} </p>
)}
</div>
</Card>
@@ -444,7 +442,7 @@ function CapturePage() {
<Content style={{ padding: '16px' }}>
<Spin spinning={loading}>
{parsedData && parsedData.items.length > 0 ? (
{Array.isArray(parsedData?.items) && parsedData.items.length > 0 ? (
<Card title="装备数据">
<Table
dataSource={parsedData.items}
@@ -467,7 +465,6 @@ function CapturePage() {
</Spin>
</Content>
</Layout>
</Layout>
);
}

View File

@@ -0,0 +1,207 @@
import React, {useEffect, useState} from 'react';
import {Button, Card, Col, Layout, Row, Space, Statistic, Table, Tag} from 'antd';
import {BarChartOutlined, DatabaseOutlined, ReloadOutlined, SettingOutlined,} from '@ant-design/icons';
import * as App from '../../wailsjs/go/service/App';
import {model} from '../../wailsjs/go/models';
import {useMessage} from '../utils/useMessage';
const { Content } = Layout;
// 定义Equipment接口
interface Equipment {
id: string | number;
code: string;
ct: number;
e: number;
g: number;
l: boolean;
mg: number;
op: any[];
p: number;
s: string;
sk: number;
}
const DatabasePage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [latestData, setLatestData] = useState<model.ParsedResult | null>(null);
const { success, error, info } = useMessage();
// 加载最新解析数据
const loadLatestData = async () => {
// 防止重复调用
if (loading) return;
setLoading(true);
try {
const parsedResult = await App.GetLatestParsedDataFromDatabase();
if (parsedResult && (parsedResult.items?.length > 0 || parsedResult.heroes?.length > 0)) {
setLatestData(parsedResult);
success('数据加载成功');
} else {
setLatestData(null);
info('暂无解析数据');
}
} catch (err) {
error('加载数据失败');
console.error('Load data error:', err);
setLatestData(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLatestData();
}, []);
// 装备表格列定义
const equipmentColumns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
render: (id: any) => {
const idStr = String(id || '');
return <span style={{ fontSize: '12px' }}>{idStr.length > 8 ? `${idStr.slice(0, 8)}...` : idStr}</span>;
},
},
{
title: '代码',
dataIndex: 'code',
key: 'code',
width: 120,
},
{
title: '等级',
dataIndex: 'g',
key: 'g',
width: 80,
render: (grade: number) => (
<Tag color={grade >= 5 ? 'red' : grade >= 3 ? 'orange' : 'green'}>
{grade}
</Tag>
),
},
{
title: '经验',
dataIndex: 'e',
key: 'e',
width: 100,
render: (exp: number) => exp.toLocaleString(),
},
{
title: '力量',
dataIndex: 'p',
key: 'p',
width: 80,
},
{
title: '魔法',
dataIndex: 'mg',
key: 'mg',
width: 80,
},
{
title: '技能',
dataIndex: 'sk',
key: 'sk',
width: 80,
},
{
title: '状态',
dataIndex: 'l',
key: 'l',
width: 80,
render: (locked: boolean) => (
<Tag color={locked ? 'red' : 'green'}>
{locked ? '锁定' : '正常'}
</Tag>
),
},
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Content style={{ padding: 16 }}>
{/* 统计卡片 */}
<Row gutter={16} style={{ marginBottom: '24px' }}>
<Col span={8}>
<Card>
<Statistic
title="数据库状态"
value="SQLite"
prefix={<DatabaseOutlined />}
suffix="已连接"
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="装备数量"
value={latestData?.items?.length || 0}
prefix={<BarChartOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="英雄数量"
value={latestData?.heroes?.length || 0}
prefix={<SettingOutlined />}
/>
</Card>
</Col>
</Row>
{/* 操作栏 */}
<Card style={{ marginBottom: '16px' }}>
<Space>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={loadLatestData}
loading={loading}
>
</Button>
<span style={{ color: '#666' }}>
</span>
</Space>
</Card>
{/* 装备表格 */}
<Card title="最新解析的装备数据">
{latestData?.items && latestData.items.length > 0 ? (
<Table
columns={equipmentColumns}
dataSource={latestData.items}
rowKey="id"
loading={loading}
pagination={{
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total}`,
}}
scroll={{ x: 800 }}
/>
) : (
<div style={{ textAlign: 'center', padding: '40px' }}>
<p></p>
<p style={{ color: '#999', fontSize: '12px' }}>
</p>
</div>
)}
</Card>
</Content>
</Layout>
);
};
export default DatabasePage;

View File

@@ -1,12 +1,243 @@
import React from 'react';
import {
Avatar,
Button,
Card,
Col,
Divider,
Input,
InputNumber,
Layout,
Modal,
Pagination,
Row,
Select,
Table,
Tag
} from 'antd';
import {AppstoreOutlined, FilterOutlined, UserOutlined} from '@ant-design/icons';
const {Content} = Layout;
// 静态数据示例
const heroList = [
{id: 1, name: '雅娜凯', avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=1'},
{id: 2, name: '艾莉丝', avatar: 'https://api.dicebear.com/7.x/miniavs/svg?seed=2'},
];
const hero = heroList[0];
const attributes = [
{label: '攻击', value: 1567},
{label: '防御', value: 1654},
{label: '生命', value: 24447},
{label: '速度', value: 188},
{label: '暴击', value: 49},
{label: '爆伤', value: 166},
{label: '命中', value: 46},
{label: '抵抗', value: 52},
];
const setOptions = [
{label: '任意套装', value: 'any'},
{label: '暴击套', value: 'crit'},
{label: '速度套', value: 'speed'},
];
const filterOptions = [
{label: '攻击', value: 'atk'},
{label: '防御', value: 'def'},
{label: '生命', value: 'hp'},
{label: '速度', value: 'spd'},
{label: '暴击', value: 'cr'},
{label: '爆伤', value: 'cd'},
{label: '命中', value: 'acc'},
{label: '抵抗', value: 'res'},
];
const resultColumns = [
{title: '套装', dataIndex: 'set', key: 'set', render: (v: string) => <Tag>{v}</Tag>},
{title: '攻击', dataIndex: 'atk', key: 'atk'},
{title: '防御', dataIndex: 'def', key: 'def'},
{title: '生命', dataIndex: 'hp', key: 'hp'},
{title: '速度', dataIndex: 'spd', key: 'spd'},
{title: '暴击', dataIndex: 'cr', key: 'cr'},
{title: '爆伤', dataIndex: 'cd', key: 'cd'},
{title: '命中', dataIndex: 'acc', key: 'acc'},
{title: '抵抗', dataIndex: 'res', key: 'res'},
];
const resultData = [
{key: 1, set: '暴击套', atk: 2000, def: 1500, hp: 20000, spd: 200, cr: 100, cd: 250, acc: 30, res: 20},
{key: 2, set: '速度套', atk: 1800, def: 1400, hp: 21000, spd: 220, cr: 80, cd: 200, acc: 40, res: 30},
];
export default function OptimizerPage() {
const [editVisible, setEditVisible] = React.useState(false);
const [editData, setEditData] = React.useState({
artifact: '',
artifactLevel: 0,
formation: '',
exclusive: '',
star: 5,
});
function OptimizerPage() {
return (
<div style={{ padding: 24 }}>
<h2></h2>
<p></p>
<Layout style={{minHeight: '100vh'}}>
<Content style={{padding: 16}}>
{/* 顶部角色和选项区 */}
<Card style={{marginBottom: 16, position: 'relative'}}>
<Row gutter={16} align="top">
{/* 角色头像与神器区 */}
<Col span={6} style={{display: 'flex', alignItems: 'flex-start'}}>
<Avatar size={64} src={hero.avatar} icon={<UserOutlined/>} style={{marginRight: 16}}/>
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
<div style={{fontWeight: 500}}>{hero.name}</div>
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
<span></span>
<span style={{cursor: 'pointer', color: '#1677ff'}}
onClick={() => setEditVisible(true)}>
{/* 假设有artifactUrl则显示图片否则暂无 */}
{editData.artifact ? <img src={editData.artifact} alt="神器"
style={{width: 32, height: 32}}/> : '暂无'}
</span>
</div>
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
<span></span>
<span style={{cursor: 'pointer', color: '#1677ff'}}
onClick={() => setEditVisible(true)}>
{editData.exclusive ? editData.exclusive : '暂无'}
</span>
</div>
</div>
</Col>
{/* 属性区(纵向) */}
<Col span={6}>
<div style={{fontWeight: 500, marginBottom: 8}}></div>
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
{attributes.map(attr => (
<div key={attr.label}>{attr.label}: <b>{attr.value}</b></div>
))}
</div>
</Col>
{/* 属性过滤区(纵向,最小最大值) */}
<Col span={6}>
<div style={{fontWeight: 500, marginBottom: 8}}></div>
<div style={{display: 'flex', flexDirection: 'column', gap: 8}}>
{filterOptions.map(opt => (
<div key={opt.value} style={{display: 'flex', alignItems: 'center', gap: 4}}>
<span style={{minWidth: 36}}>{opt.label}</span>
<InputNumber size="small" placeholder="最小值" style={{width: 70}}/>
<span>~</span>
<InputNumber size="small" placeholder="最大值" style={{width: 70}}/>
</div>
))}
</div>
</Col>
{/* 指定套装区(纵向,多选) */}
<Col span={6} style={{display: 'flex', flexDirection: 'column', justifyContent: 'flex-start'}}>
<div style={{fontWeight: 500, marginBottom: 8}}></div>
<Select
mode="multiple"
style={{width: '100%', marginBottom: 8}}
defaultValue={[setOptions[0].value]}
>
{setOptions.map(opt => <Select.Option key={opt.value}
value={opt.value}>{opt.label}</Select.Option>)}
</Select>
</Col>
{/* 按钮区绝对定位到Card右下角 */}
<div style={{position: 'absolute', right: 24, bottom: 16, display: 'flex', gap: 8}}>
<Button type="primary" icon={<AppstoreOutlined/>}></Button>
<Button icon={<FilterOutlined/>}></Button>
</div>
</Row>
</Card>
{/* 配装结果列表区 */}
<Card title="配装结果" style={{marginBottom: 16}}>
<div style={{display: 'flex', justifyContent: 'space-between', marginBottom: 8}}>
<div><b>123,456</b> | <b>2</b> </div>
<Pagination size="small" total={2} pageSize={10} current={1} showSizeChanger={false}/>
</div>
<Table
dataSource={resultData}
columns={resultColumns}
pagination={false}
scroll={{x: true}}
rowKey="key"
/>
</Card>
{/* 单个配装详情区 */}
<Card title="配装详情">
<div style={{display: 'flex', gap: 24}}>
<div>
<Avatar size={48} src={hero.avatar}/>
<div style={{marginTop: 8}}>{hero.name}</div>
</div>
<Divider type="vertical" style={{height: 80}}/>
<div>
<div><Tag color="blue"></Tag></div>
<div>2000150020000200</div>
<div>100%250%30%20%</div>
</div>
<Divider type="vertical" style={{height: 80}}/>
<div>
<Button type="primary"></Button>
<Button style={{marginLeft: 8}}></Button>
</div>
</div>
</Card>
{/* 角色信息编辑弹窗 */}
<Modal
title="角色信息编辑"
open={editVisible}
onCancel={() => setEditVisible(false)}
onOk={() => setEditVisible(false)}
>
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
<div>
<span></span>
<Input
value={editData.artifact}
onChange={e => setEditData({...editData, artifact: e.target.value})}
placeholder="神器图片URL或名称"
/>
</div>
<div>
<span></span>
<InputNumber
min={0}
max={20}
value={editData.artifactLevel}
onChange={v => setEditData({...editData, artifactLevel: v || 0})}
/>
</div>
<div>
<span></span>
<Input
value={editData.formation}
onChange={e => setEditData({...editData, formation: e.target.value})}
placeholder="如:前排/后排"
/>
</div>
<div>
<span></span>
<Input
value={editData.exclusive}
onChange={e => setEditData({...editData, exclusive: e.target.value})}
placeholder="如:+9速度"
/>
</div>
<div>
<span></span>
<InputNumber
min={1}
max={6}
value={editData.star}
onChange={v => setEditData({...editData, star: v || 1})}
/>
</div>
</div>
</Modal>
</Content>
</Layout>
);
}
export default OptimizerPage;

View File

@@ -0,0 +1,38 @@
import {create} from 'zustand';
import {persist} from 'zustand/middleware';
export interface Equipment {
id: string;
code: string;
ct: number;
e: number;
g: number;
l: boolean;
mg: number;
op: Array<[string, any]>;
p: number;
s: string;
sk: number;
}
export interface CaptureResult {
items: Equipment[];
heroes: any[];
}
interface CaptureStoreState {
parsedData: CaptureResult | null;
setParsedData: (data: CaptureResult | null) => void;
}
export const useCaptureStore = create<CaptureStoreState>()(
persist(
(set) => ({
parsedData: null,
setParsedData: (data) => set({ parsedData: data }),
}),
{
name: 'capture-store', // localStorage key
partialize: (state) => ({ parsedData: state.parsedData }) }
)
);

View File

@@ -0,0 +1,66 @@
import {message} from 'antd';
import {useRef} from 'react';
// 全局消息缓存,用于防止重复显示
const messageCache = new Map<string, number>();
const DEBOUNCE_TIME = 2000; // 2秒内相同消息不重复显示
export const useMessage = () => {
const messageKeyRef = useRef<string>('');
const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string, duration?: number) => {
// 生成消息的唯一标识
const messageId = `${type}:${content}`;
const now = Date.now();
// 检查是否在防抖时间内
const lastTime = messageCache.get(messageId);
if (lastTime && now - lastTime < DEBOUNCE_TIME) {
return; // 跳过重复消息
}
// 更新缓存
messageCache.set(messageId, now);
// 清理过期的缓存项超过10秒的
const expiredKeys: string[] = [];
messageCache.forEach((timestamp, msgId) => {
if (now - timestamp > 10000) {
expiredKeys.push(msgId);
}
});
expiredKeys.forEach(key => messageCache.delete(key));
// 显示消息
message[type](content, duration);
};
const success = (content: string, duration?: number) => {
showMessage('success', content, duration);
};
const error = (content: string, duration?: number) => {
showMessage('error', content, duration);
};
const warning = (content: string, duration?: number) => {
showMessage('warning', content, duration);
};
const info = (content: string, duration?: number) => {
showMessage('info', content, duration);
};
const destroy = () => {
message.destroy();
};
return {
success,
error,
warning,
info,
destroy,
showMessage,
};
};

View File

@@ -34,6 +34,20 @@ export namespace model {
this.is_loopback = source["is_loopback"];
}
}
export class ParsedResult {
items: any[];
heroes: any[];
static createFrom(source: any = {}) {
return new ParsedResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.items = source["items"];
this.heroes = source["heroes"];
}
}
}

View File

@@ -2,18 +2,34 @@
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
export function ExportCurrentData(arg1:string):Promise<void>;
export function ExportData(arg1:Array<string>,arg2:string):Promise<void>;
export function GetAllAppSettings():Promise<Record<string, string>>;
export function GetAppSetting(arg1:string):Promise<string>;
export function GetCaptureStatus():Promise<model.CaptureStatus>;
export function GetCapturedData():Promise<Array<string>>;
export function GetCurrentDataForExport():Promise<string>;
export function GetLatestParsedDataFromDatabase():Promise<model.ParsedResult>;
export function GetNetworkInterfaces():Promise<Array<model.NetworkInterface>>;
export function ParseData(arg1:Array<string>):Promise<string>;
export function ReadRawJsonFile():Promise<string>;
export function ReadRawJsonFile():Promise<model.ParsedResult>;
export function SaveAppSetting(arg1:string,arg2:string):Promise<void>;
export function SaveParsedDataToDatabase(arg1:string,arg2:string,arg3:string):Promise<void>;
export function StartCapture(arg1:string):Promise<void>;
export function StopAndParseCapture():Promise<model.ParsedResult>;
export function StopCapture():Promise<void>;

View File

@@ -2,10 +2,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function ExportCurrentData(arg1) {
return window['go']['service']['App']['ExportCurrentData'](arg1);
}
export function ExportData(arg1, arg2) {
return window['go']['service']['App']['ExportData'](arg1, arg2);
}
export function GetAllAppSettings() {
return window['go']['service']['App']['GetAllAppSettings']();
}
export function GetAppSetting(arg1) {
return window['go']['service']['App']['GetAppSetting'](arg1);
}
export function GetCaptureStatus() {
return window['go']['service']['App']['GetCaptureStatus']();
}
@@ -14,6 +26,14 @@ export function GetCapturedData() {
return window['go']['service']['App']['GetCapturedData']();
}
export function GetCurrentDataForExport() {
return window['go']['service']['App']['GetCurrentDataForExport']();
}
export function GetLatestParsedDataFromDatabase() {
return window['go']['service']['App']['GetLatestParsedDataFromDatabase']();
}
export function GetNetworkInterfaces() {
return window['go']['service']['App']['GetNetworkInterfaces']();
}
@@ -26,10 +46,22 @@ export function ReadRawJsonFile() {
return window['go']['service']['App']['ReadRawJsonFile']();
}
export function SaveAppSetting(arg1, arg2) {
return window['go']['service']['App']['SaveAppSetting'](arg1, arg2);
}
export function SaveParsedDataToDatabase(arg1, arg2, arg3) {
return window['go']['service']['App']['SaveParsedDataToDatabase'](arg1, arg2, arg3);
}
export function StartCapture(arg1) {
return window['go']['service']['App']['StartCapture'](arg1);
}
export function StopAndParseCapture() {
return window['go']['service']['App']['StopAndParseCapture']();
}
export function StopCapture() {
return window['go']['service']['App']['StopCapture']();
}

1
go.mod
View File

@@ -6,6 +6,7 @@ toolchain go1.24.4
require (
github.com/google/gopacket v1.1.19
github.com/mattn/go-sqlite3 v1.14.17
github.com/wailsapp/wails/v2 v2.10.1
go.uber.org/zap v1.26.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1

2
go.sum
View File

@@ -34,6 +34,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

View File

@@ -4,6 +4,7 @@ import (
"equipment-analyzer/internal/model"
"fmt"
"github.com/google/gopacket/pcap"
"log"
)
// GetNetworkInterfaces 获取网络接口列表
@@ -13,6 +14,8 @@ func GetNetworkInterfaces() ([]model.NetworkInterface, error) {
return nil, fmt.Errorf("failed to find network devices: %v", err)
}
log.Printf("抓取到的网卡设备数量: %d", len(devices)) // 打印网卡总数
var interfaces []model.NetworkInterface
for _, device := range devices {
// 跳过回环接口
@@ -20,6 +23,8 @@ func GetNetworkInterfaces() ([]model.NetworkInterface, error) {
continue
}
//log.Printf("网卡名称: %s, 描述: %s", device.Name, device.Description) // 打印每个网卡的名称和描述
// 提取IP地址
var addresses []string
for _, address := range device.Addresses {

163
internal/model/database.go Normal file
View File

@@ -0,0 +1,163 @@
package model
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Database 数据库管理器
type Database struct {
db *sql.DB
}
// NewDatabase 创建新的数据库连接
func NewDatabase() (*Database, error) {
dbPath := getDatabasePath()
// 确保目录存在
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
}
// 连接数据库
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
// 测试连接
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("数据库连接测试失败: %w", err)
}
database := &Database{db: db}
// 初始化表结构
if err := database.initTables(); err != nil {
return nil, fmt.Errorf("初始化数据库表失败: %w", err)
}
return database, nil
}
// Close 关闭数据库连接
func (d *Database) Close() error {
return d.db.Close()
}
// getDatabasePath 获取数据库文件路径
func getDatabasePath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return "equipment_analyzer.db"
}
return filepath.Join(homeDir, ".equipment-analyzer", "equipment_analyzer.db")
}
// initTables 初始化数据库表结构
func (d *Database) initTables() error {
// 解析数据表 - 存储抓包解析后的装备和角色数据
parsedDataTable := `
CREATE TABLE IF NOT EXISTS parsed_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_name TEXT NOT NULL,
items_json TEXT NOT NULL,
heroes_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);`
// 应用设置表
settingsTable := `
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);`
tables := []string{
parsedDataTable,
settingsTable,
}
for _, table := range tables {
if _, err := d.db.Exec(table); err != nil {
return fmt.Errorf("创建表失败: %w", err)
}
}
return nil
}
// SaveParsedData 保存解析后的数据
func (d *Database) SaveParsedData(sessionName string, itemsJSON, heroesJSON string) error {
stmt := `
INSERT INTO parsed_data (session_name, items_json, heroes_json, created_at)
VALUES (?, ?, ?, ?)`
_, err := d.db.Exec(stmt, sessionName, itemsJSON, heroesJSON, time.Now().Unix())
return err
}
// GetLatestParsedData 获取最新的解析数据
func (d *Database) GetLatestParsedData() (string, string, error) {
stmt := `
SELECT items_json, heroes_json
FROM parsed_data
ORDER BY created_at DESC
LIMIT 1`
var itemsJSON, heroesJSON string
err := d.db.QueryRow(stmt).Scan(&itemsJSON, &heroesJSON)
if err != nil {
return "", "", err
}
return itemsJSON, heroesJSON, nil
}
// SaveSetting 保存应用设置
func (d *Database) SaveSetting(key, value string) error {
stmt := "INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)"
_, err := d.db.Exec(stmt, key, value, time.Now().Unix())
return err
}
// GetSetting 获取应用设置
func (d *Database) GetSetting(key string) (string, error) {
stmt := "SELECT value FROM app_settings WHERE key = ?"
var value string
err := d.db.QueryRow(stmt, key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
// GetAllSettings 获取所有设置
func (d *Database) GetAllSettings() (map[string]string, error) {
stmt := "SELECT key, value FROM app_settings"
rows, err := d.db.Query(stmt)
if err != nil {
return nil, err
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, err
}
settings[key] = value
}
return settings, nil
}

View File

@@ -29,3 +29,9 @@ type CaptureStatus struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// ParsedResult 解析结果
type ParsedResult struct {
Items []interface{} `json:"items"`
Heroes []interface{} `json:"heroes"`
}

View File

@@ -101,3 +101,33 @@ func (cs *CaptureService) processData(ctx context.Context) {
}
}
}
// StopAndParseCapture 停止抓包并解析数据
func (cs *CaptureService) StopAndParseCapture(parser *ParserService) (*model.ParsedResult, error) {
cs.mutex.Lock()
defer cs.mutex.Unlock()
if !cs.isCapturing {
return nil, fmt.Errorf("capture not running")
}
cs.packetCapture.Stop()
cs.isCapturing = false
cs.logger.Info("Packet capture stopped (StopAndParseCapture)")
// 处理所有收集的数据
cs.packetCapture.ProcessAllData()
// 获取抓包数据
rawData := cs.packetCapture.GetCapturedData()
if len(rawData) == 0 {
return nil, fmt.Errorf("no captured data")
}
// 解析数据
result, _, err := parser.ParseHexData(rawData)
if err != nil {
return nil, fmt.Errorf("解析数据失败: %v", err)
}
return result, nil
}

View File

@@ -0,0 +1,82 @@
package service
import (
"fmt"
"equipment-analyzer/internal/model"
"equipment-analyzer/internal/utils"
)
// DatabaseService 数据库服务
type DatabaseService struct {
db *model.Database
logger *utils.Logger
}
// NewDatabaseService 创建数据库服务
func NewDatabaseService(db *model.Database, logger *utils.Logger) *DatabaseService {
return &DatabaseService{
db: db,
logger: logger,
}
}
// SaveParsedDataToDatabase 保存解析后的数据到数据库
func (s *DatabaseService) SaveParsedDataToDatabase(sessionName string, itemsJSON, heroesJSON string) error {
err := s.db.SaveParsedData(sessionName, itemsJSON, heroesJSON)
if err != nil {
s.logger.Error("保存解析数据到数据库失败", "error", err, "session_name", sessionName)
return fmt.Errorf("保存解析数据失败: %w", err)
}
s.logger.Info("解析数据保存成功", "session_name", sessionName)
return nil
}
// GetLatestParsedDataFromDatabase 从数据库获取最新的解析数据
func (s *DatabaseService) GetLatestParsedDataFromDatabase() (string, string, error) {
itemsJSON, heroesJSON, err := s.db.GetLatestParsedData()
if err != nil {
s.logger.Error("从数据库获取最新解析数据失败", "error", err)
return "", "", fmt.Errorf("获取解析数据失败: %w", err)
}
s.logger.Info("最新解析数据获取成功")
return itemsJSON, heroesJSON, nil
}
// SaveAppSetting 保存应用设置
func (s *DatabaseService) SaveAppSetting(key, value string) error {
err := s.db.SaveSetting(key, value)
if err != nil {
s.logger.Error("保存应用设置失败", "error", err, "key", key)
return fmt.Errorf("保存设置失败: %w", err)
}
s.logger.Info("应用设置保存成功", "key", key)
return nil
}
// GetAppSetting 获取应用设置
func (s *DatabaseService) GetAppSetting(key string) (string, error) {
value, err := s.db.GetSetting(key)
if err != nil {
s.logger.Error("获取应用设置失败", "error", err, "key", key)
return "", fmt.Errorf("获取设置失败: %w", err)
}
return value, nil
}
// GetAllAppSettings 获取所有应用设置
func (s *DatabaseService) GetAllAppSettings() (map[string]string, error) {
settings, err := s.db.GetAllSettings()
if err != nil {
s.logger.Error("获取所有应用设置失败", "error", err)
return nil, fmt.Errorf("获取设置失败: %w", err)
}
return settings, nil
}

View File

@@ -17,9 +17,16 @@ type App struct {
logger *utils.Logger
captureService *CaptureService
parserService *ParserService
database *model.Database
databaseService *DatabaseService
}
func NewApp(cfg *config.Config, logger *utils.Logger) *App {
// 初始化数据库
database, err := model.NewDatabase()
if err != nil {
logger.Error("初始化数据库失败", "error", err)
// 如果数据库初始化失败,仍然创建应用,但数据库功能不可用
return &App{
config: cfg,
logger: logger,
@@ -28,6 +35,18 @@ func NewApp(cfg *config.Config, logger *utils.Logger) *App {
}
}
databaseService := NewDatabaseService(database, logger)
return &App{
config: cfg,
logger: logger,
captureService: NewCaptureService(cfg, logger),
parserService: NewParserService(cfg, logger),
database: database,
databaseService: databaseService,
}
}
func (a *App) Startup(ctx context.Context) {
a.logger.Info("应用启动")
}
@@ -43,6 +62,15 @@ func (a *App) BeforeClose(ctx context.Context) (prevent bool) {
func (a *App) Shutdown(ctx context.Context) {
a.logger.Info("应用关闭")
// 关闭数据库连接
if a.database != nil {
if err := a.database.Close(); err != nil {
a.logger.Error("关闭数据库连接失败", "error", err)
} else {
a.logger.Info("数据库连接已关闭")
}
}
}
// GetNetworkInterfaces 获取网络接口列表
@@ -104,49 +132,106 @@ func (a *App) GetCapturedData() ([]string, error) {
// ParseData 解析数据为JSON
func (a *App) ParseData(hexDataList []string) (string, error) {
result, err := a.parserService.ParseHexData(hexDataList)
_, rawJson, err := a.parserService.ParseHexData(hexDataList)
if err != nil {
a.logger.Error("解析数据失败", "error", err)
return "", err
}
jsonData, err := json.MarshalIndent(result, "", " ")
if err != nil {
a.logger.Error("JSON序列化失败", "error", err)
return "", err
}
a.logger.Info("数据解析完成", "count", len(result.Data))
return string(jsonData), nil
return rawJson, nil
}
// ExportData 导出数据到文件
func (a *App) ExportData(hexDataList []string, filename string) error {
result, err := a.parserService.ParseHexData(hexDataList)
result, rawJson, err := a.parserService.ParseHexData(hexDataList)
if err != nil {
a.logger.Error("解析数据失败", "error", err)
return err
}
jsonData, err := json.MarshalIndent(result, "", " ")
// 这里可以添加文件写入逻辑
a.logger.Info("导出数据", "filename", filename, "count", len(result.Items))
// 简单示例:写入到当前目录
err = utils.WriteFile(filename, []byte(rawJson))
if err != nil {
a.logger.Error("JSON序列化失败", "error", err)
a.logger.Error("写入文件失败", "error", err)
return err
}
return nil
}
// ExportCurrentData 导出当前数据库中的数据到文件
func (a *App) ExportCurrentData(filename string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
// 从数据库获取最新数据
parsedResult, err := a.GetLatestParsedDataFromDatabase()
if err != nil {
a.logger.Error("获取数据库数据失败", "error", err)
return err
}
// 这里可以添加文件写入逻辑
a.logger.Info("导出数据", "filename", filename, "count", len(result.Data))
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
return fmt.Errorf("没有数据可导出")
}
// 简单示例:写入到当前目录
// 创建导出数据格式
exportData := map[string]interface{}{
"items": parsedResult.Items,
"heroes": parsedResult.Heroes,
}
// 序列化为JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
a.logger.Error("序列化数据失败", "error", err)
return err
}
// 写入文件
err = utils.WriteFile(filename, jsonData)
if err != nil {
a.logger.Error("写入文件失败", "error", err)
return err
}
a.logger.Info("数据导出成功", "filename", filename, "items_count", len(parsedResult.Items), "heroes_count", len(parsedResult.Heroes))
return nil
}
// GetCurrentDataForExport 获取当前数据库中的数据,供前端导出使用
func (a *App) GetCurrentDataForExport() (string, error) {
if a.databaseService == nil {
return "", fmt.Errorf("数据库服务未初始化")
}
// 从数据库获取最新数据
parsedResult, err := a.GetLatestParsedDataFromDatabase()
if err != nil {
a.logger.Error("获取数据库数据失败", "error", err)
return "", err
}
if parsedResult == nil || (len(parsedResult.Items) == 0 && len(parsedResult.Heroes) == 0) {
return "", fmt.Errorf("没有数据可导出")
}
// 创建导出数据格式
exportData := map[string]interface{}{
"items": parsedResult.Items,
"heroes": parsedResult.Heroes,
}
// 序列化为JSON
jsonData, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
a.logger.Error("序列化数据失败", "error", err)
return "", err
}
return string(jsonData), nil
}
// GetCaptureStatus 获取抓包状态
func (a *App) GetCaptureStatus() model.CaptureStatus {
return model.CaptureStatus{
@@ -162,7 +247,113 @@ func (a *App) getStatusMessage() string {
return "准备就绪"
}
// ReadRawJsonFile 供前端调用读取output_raw.json内容
func (a *App) ReadRawJsonFile() (string, error) {
return a.parserService.ReadRawJsonFile()
// ReadRawJsonFile 已废弃请使用GetLatestParsedDataFromDatabase从数据库获取数据
func (a *App) ReadRawJsonFile() (*model.ParsedResult, error) {
return a.GetLatestParsedDataFromDatabase()
}
// StopAndParseCapture 停止抓包并解析数据,供前端调用
func (a *App) StopAndParseCapture() (*model.ParsedResult, error) {
result, err := a.captureService.StopAndParseCapture(a.parserService)
if err != nil {
a.logger.Error("停止抓包并解析数据失败", "error", err)
return nil, err
}
// 将解析结果保存到数据库
if a.databaseService != nil && result != nil {
// 序列化装备数据
itemsJSON := "[]"
if result.Items != nil {
if jsonData, err := json.Marshal(result.Items); err == nil {
itemsJSON = string(jsonData)
}
}
// 序列化英雄数据
heroesJSON := "[]"
if result.Heroes != nil {
if jsonData, err := json.Marshal(result.Heroes); err == nil {
heroesJSON = string(jsonData)
}
}
// 保存到数据库
sessionName := fmt.Sprintf("capture_%d", time.Now().Unix())
if err := a.databaseService.SaveParsedDataToDatabase(sessionName, itemsJSON, heroesJSON); err != nil {
a.logger.Error("保存解析数据到数据库失败", "error", err)
// 不返回错误,因为解析成功了,只是保存失败
} else {
a.logger.Info("解析数据已保存到数据库", "session_name", sessionName)
}
}
return result, nil
}
// ========== 数据库相关API ==========
// SaveParsedDataToDatabase 保存解析后的数据到数据库
func (a *App) SaveParsedDataToDatabase(sessionName string, itemsJSON, heroesJSON string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.SaveParsedDataToDatabase(sessionName, itemsJSON, heroesJSON)
}
// GetLatestParsedDataFromDatabase 从数据库获取最新的解析数据
func (a *App) GetLatestParsedDataFromDatabase() (*model.ParsedResult, error) {
if a.databaseService == nil {
return nil, fmt.Errorf("数据库服务未初始化")
}
itemsJSON, heroesJSON, err := a.databaseService.GetLatestParsedDataFromDatabase()
if err != nil {
return nil, err
}
// 解析装备数据
var items []interface{}
if itemsJSON != "" {
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
return nil, fmt.Errorf("解析装备数据失败: %w", err)
}
}
// 解析英雄数据
var heroes []interface{}
if heroesJSON != "" {
if err := json.Unmarshal([]byte(heroesJSON), &heroes); err != nil {
return nil, fmt.Errorf("解析英雄数据失败: %w", err)
}
}
return &model.ParsedResult{
Items: items,
Heroes: heroes,
}, nil
}
// SaveAppSetting 保存应用设置
func (a *App) SaveAppSetting(key, value string) error {
if a.databaseService == nil {
return fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.SaveAppSetting(key, value)
}
// GetAppSetting 获取应用设置
func (a *App) GetAppSetting(key string) (string, error) {
if a.databaseService == nil {
return "", fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.GetAppSetting(key)
}
// GetAllAppSettings 获取所有应用设置
func (a *App) GetAllAppSettings() (map[string]string, error) {
if a.databaseService == nil {
return nil, fmt.Errorf("数据库服务未初始化")
}
return a.databaseService.GetAllAppSettings()
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,18 +28,17 @@ func NewParserService(cfg *config.Config, logger *utils.Logger) *ParserService {
}
// ParseHexData 解析十六进制数据
func (ps *ParserService) ParseHexData(hexDataList []string) (*model.CaptureResult, error) {
func (ps *ParserService) ParseHexData(hexDataList []string) (*model.ParsedResult, string, error) {
if len(hexDataList) == 0 {
ps.logger.Warn("没有数据需要解析")
return &model.CaptureResult{
Data: make([]model.Equipment, 0),
Units: make([]interface{}, 0),
}, nil
return &model.ParsedResult{
Items: make([]interface{}, 0),
Heroes: make([]interface{}, 0),
}, "", nil
}
ps.logger.Info("开始远程解析数据", "count", len(hexDataList))
// 远程接口解析
url := "https://krivpfvxi0.execute-api.us-west-2.amazonaws.com/dev/getItems"
reqBody := map[string]interface{}{
"data": hexDataList,
@@ -54,45 +53,47 @@ func (ps *ParserService) ParseHexData(hexDataList []string) (*model.CaptureResul
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
// 直接写入本地文件
fileErr := ioutil.WriteFile("output_raw.json", body, 0644)
if fileErr != nil {
ps.logger.Error("写入原始json文件失败", "error", fileErr)
// 新校验逻辑校验data和units字段
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
ps.logger.Error("远程json解析失败", "error", err)
return nil, "", fmt.Errorf("远程json解析失败: %v", err)
}
ps.logger.Info("远程原始数据已写入output_raw.json")
// 校验data字段
dataArr, dataOk := raw["data"].([]interface{})
if !dataOk || len(dataArr) == 0 {
ps.logger.Error("远程json校验失败data字段缺失或为空")
return nil, "", fmt.Errorf("远程json校验失败data字段缺失或为空")
}
// 返回空集合,保证前端不报错
return &model.CaptureResult{
Data: make([]model.Equipment, 0),
Units: make([]interface{}, 0),
}, nil
// 校验通过,直接解析数据
ps.logger.Info("远程原始数据校验通过,开始解析")
parsedResult, err := ps.ReadRawJsonFile(string(body))
if err != nil {
return nil, "", err
}
return parsedResult, "", nil
} else if err != nil {
ps.logger.Error("远程解析请求失败", "error", err)
return nil, fmt.Errorf("远程解析请求失败: %v", err)
return nil, "", fmt.Errorf("远程解析请求失败: %v", err)
} else {
ps.logger.Error("远程解析响应码异常", "status", resp.StatusCode)
return nil, fmt.Errorf("远程解析响应码异常: %d", resp.StatusCode)
return nil, "", fmt.Errorf("远程解析响应码异常: %d", resp.StatusCode)
}
} else {
ps.logger.Error("远程解析请求构建失败", "error", err)
return nil, fmt.Errorf("远程解析请求构建失败: %v", err)
return nil, "", fmt.Errorf("远程解析请求构建失败: %v", err)
}
}
// ReadRawJsonFile 读取output_raw.json文件内容并进行数据转换
func (ps *ParserService) ReadRawJsonFile() (string, error) {
data, err := ioutil.ReadFile("output_raw.json")
if err != nil {
ps.logger.Error("读取output_raw.json失败", "error", err)
return "", err
}
// 解析原始JSON数据
// ReadRawJsonFile 读取rawJson内容并进行数据转换返回ParsedResult对象
func (ps *ParserService) ReadRawJsonFile(rawJson string) (*model.ParsedResult, error) {
var rawData map[string]interface{}
if err := json.Unmarshal(data, &rawData); err != nil {
if err := json.Unmarshal([]byte(rawJson), &rawData); err != nil {
ps.logger.Error("解析JSON失败", "error", err)
return "", err
return nil, err
}
// 提取装备和英雄数据
@@ -109,9 +110,6 @@ func (ps *ParserService) ReadRawJsonFile() (string, error) {
}
}
// 1. 原始装备总数
fmt.Println("原始装备总数:", len(equips))
// 过滤有效装备 (x => !!x.f)
var validEquips []interface{}
for _, equip := range equips {
@@ -121,29 +119,24 @@ func (ps *ParserService) ReadRawJsonFile() (string, error) {
}
}
}
fmt.Println("过滤f字段后装备数:", len(validEquips))
// 转换装备数据
convertedItems := ps.convertItemsAllWithLog(validEquips)
fmt.Println("转换后装备数:", len(convertedItems))
// 转换英雄数据(只对最大组)
convertedHeroes := ps.convertUnits(rawUnits)
// 构建最终结果
result := map[string]interface{}{
"items": convertedItems,
"heroes": convertedHeroes,
result := &model.ParsedResult{
Items: make([]interface{}, len(convertedItems)),
Heroes: make([]interface{}, len(convertedHeroes)),
}
for i, v := range convertedItems {
result.Items[i] = v
}
for i, v := range convertedHeroes {
result.Heroes[i] = v
}
// 序列化为JSON字符串
resultJSON, err := json.Marshal(result)
if err != nil {
ps.logger.Error("序列化结果失败", "error", err)
return "", err
}
return string(resultJSON), nil
return result, nil
}
// convertItems 转换装备数据

View File

@@ -1,85 +1,13 @@
package service
import (
"encoding/json"
"equipment-analyzer/internal/config"
"equipment-analyzer/internal/utils"
"fmt"
"testing"
)
func TestReadRawJsonFile(t *testing.T) {
// 创建测试用的配置和日志器
config := &config.Config{}
logger := utils.NewLogger()
// 创建ParserService实例
ps := NewParserService(config, logger)
// 调用ReadRawJsonFile方法
result, err := ps.ReadRawJsonFile()
if err != nil {
t.Fatalf("ReadRawJsonFile failed: %v", err)
// 此测试已废弃因为ReadRawJsonFile方法已被移除
// 现在数据存储在SQLite数据库中不再依赖output_raw.json文件
t.Skip("ReadRawJsonFile测试已废弃数据现在存储在SQLite数据库中")
}
fmt.Printf("Raw result length: %d\n", len(result))
fmt.Printf("Raw result preview: %s\n", result[:min(200, len(result))])
// 解析JSON结果
var parsedData map[string]interface{}
if err := json.Unmarshal([]byte(result), &parsedData); err != nil {
t.Fatalf("Failed to parse JSON result: %v", err)
}
// 检查数据结构
fmt.Printf("Parsed data keys: %v\n", getKeys(parsedData))
// 检查items字段
if items, exists := parsedData["items"]; exists {
if itemsArray, ok := items.([]interface{}); ok {
fmt.Printf("Items count: %d\n", len(itemsArray))
if len(itemsArray) > 0 {
fmt.Printf("First item: %+v\n", itemsArray[0])
}
} else {
fmt.Printf("Items is not an array: %T\n", items)
}
} else {
fmt.Println("Items field not found")
}
// 检查heroes字段
if heroes, exists := parsedData["heroes"]; exists {
if heroesArray, ok := heroes.([]interface{}); ok {
fmt.Printf("Heroes count: %d\n", len(heroesArray))
if len(heroesArray) > 0 {
fmt.Printf("First hero: %+v\n", heroesArray[0])
}
} else {
fmt.Printf("Heroes is not an array: %T\n", heroes)
}
} else {
fmt.Println("Heroes field not found")
}
// 如果没有数据,输出更多调试信息
if len(result) < 100 {
fmt.Printf("Result seems empty or very short: %q\n", result)
}
}
// 辅助函数
func min(a, b int) int {
if a < b {
return a
}
return b
}
func getKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// 辅助函数已移除,因为测试已废弃

View File

@@ -13,7 +13,7 @@ type Logger struct {
func NewLogger() *Logger {
// 配置日志轮转
lumberJackLogger := &lumberjack.Logger{
Filename: "logs/equipment-analyzer.log",
Filename: "./logs/equipment-analyzer.log",
MaxSize: 100, // MB
MaxBackups: 3,
MaxAge: 28, // days

126588
opepic.json

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff