Update Rust version: fix console window, add API test, update README
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -56,3 +56,8 @@ test*.*
|
|||||||
# 其它
|
# 其它
|
||||||
main.exe
|
main.exe
|
||||||
sse_screen*.png
|
sse_screen*.png
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
target/
|
||||||
|
rust/target/
|
||||||
|
Cargo.lock
|
||||||
|
|||||||
216
README.md
216
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 股吧人气指示器 (Guba Sentiment Indicator)
|
# 股吧人气指示器 (Guba Sentiment Indicator)
|
||||||
|
|
||||||
一个基于 Python + PySide6 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。
|
一个基于 **Rust + eframe** 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
@@ -8,14 +8,14 @@
|
|||||||
- **自定义爬取规则**: 支持通过XPath表达式自定义爬取目标,可灵活配置目标URL和内容选择器
|
- **自定义爬取规则**: 支持通过XPath表达式自定义爬取目标,可灵活配置目标URL和内容选择器
|
||||||
- **智能去重机制**: 使用内容哈希算法自动识别和过滤重复评论,避免重复分析
|
- **智能去重机制**: 使用内容哈希算法自动识别和过滤重复评论,避免重复分析
|
||||||
- **自适应刷新**: 根据爬取结果动态调整刷新频率,无新内容时自动延长刷新间隔
|
- **自适应刷新**: 根据爬取结果动态调整刷新频率,无新内容时自动延长刷新间隔
|
||||||
- **多重解析策略**: 采用 lxml + BeautifulSoup 双重解析机制,提高爬取成功率
|
- **多重解析策略**: 采用正则表达式多重解析机制,提高爬取成功率
|
||||||
- **用户代理配置**: 支持自定义User-Agent,增强反爬虫适应性
|
- **用户代理配置**: 支持自定义User-Agent,增强反爬虫适应性
|
||||||
|
|
||||||
### 2. 大语言模型情感分析
|
### 2. 大语言模型情感分析
|
||||||
- **多模型支持**: 支持OpenAI兼容API,包括NVIDIA API等主流大模型平台
|
- **多模型支持**: 支持OpenAI兼容API,包括NVIDIA API等主流大模型平台
|
||||||
- **精准情感评分**: 为每条评论生成0-100分的情感倾向评分(0为极度悲观,100为极度乐观)
|
- **精准情感评分**: 为每条评论生成0-100分的情感倾向评分(0为极度悲观,100为极度乐观)
|
||||||
- **智能标签生成**: 自动为分析结果生成情感标签(极度悲观、悲观、中立、乐观、极度乐观等)
|
- **智能标签生成**: 自动为分析结果生成情感标签(极度悲观、悲观、中立、乐观、极度乐观等)
|
||||||
- **重试机制**: 集成API调用重试机制,应对网络波动和API限流
|
- **API测试功能**: 配置界面提供API连接测试,确保配置正确
|
||||||
- **分析历史记录**: 完整保存每次分析的结果和时间戳
|
- **分析历史记录**: 完整保存每次分析的结果和时间戳
|
||||||
|
|
||||||
### 3. 可视化情感指示器
|
### 3. 可视化情感指示器
|
||||||
@@ -27,24 +27,23 @@
|
|||||||
- **精确数值显示**: 实时显示当前平均情感分数和整体情绪标签
|
- **精确数值显示**: 实时显示当前平均情感分数和整体情绪标签
|
||||||
- **发光特效**: 采用发光效果增强视觉体验
|
- **发光特效**: 采用发光效果增强视觉体验
|
||||||
|
|
||||||
### 4. 上证所网页截图功能
|
### 4. 股票数据可视化
|
||||||
- **自动化截图**: 使用Playwright自动截取上证所网页指定区域
|
- **上证指数实时数据**: 自动获取上证指数数据并绘制波形图
|
||||||
- **元素定位**: 通过XPath精确定位图表元素进行截图
|
- **历史数据追踪**: 保留最近100个数据点,展示价格变化趋势
|
||||||
- **定时更新**: 每5分钟自动更新一次截图
|
- **图表自动更新**: 与评论分析同步更新
|
||||||
- **历史管理**: 自动管理截图文件,保留最新记录
|
|
||||||
|
|
||||||
### 5. 桌面客户端界面
|
### 5. 桌面客户端界面
|
||||||
- **悬浮窗口**: 无边框设计,支持窗口拖拽和置顶显示
|
- **悬浮窗口**: 现代化无边框设计,支持窗口拖拽和置顶显示
|
||||||
- **系统托盘**: 集成系统托盘功能,支持最小化到托盘
|
- **白色主题**: 简洁美观的白色主题界面,提高可读性
|
||||||
- **透明度调节**: 支持0.3-1.0范围内的透明度调节
|
- **中文字体支持**: 自动加载系统中文字体,确保界面显示正常
|
||||||
- **配置对话框**: 图形化配置界面,支持实时修改各项参数
|
- **配置对话框**: 图形化配置界面,支持实时修改各项参数
|
||||||
- **状态显示**: 实时显示当前运行状态和处理进度
|
- **状态显示**: 实时显示当前运行状态和处理进度
|
||||||
|
|
||||||
### 6. 数据管理与持久化
|
### 6. 数据管理与持久化
|
||||||
- **SQLite数据库**: 使用SQLite存储所有评论、分析结果和历史数据
|
- **SQLite数据库**: 使用SQLite存储所有评论、分析结果和历史数据
|
||||||
- **结构化存储**: 评论表、分析历史表、配置表分离存储
|
- **结构化存储**: 评论表、分析历史表分离存储
|
||||||
- **数据统计**: 实时统计爬取次数、分析次数等运行指标
|
- **数据统计**: 实时统计爬取次数、分析次数等运行指标
|
||||||
- **自动清理**: 支持历史数据管理和自动清理
|
- **自动清理**: 支持历史数据管理
|
||||||
|
|
||||||
### 7. 高级配置选项
|
### 7. 高级配置选项
|
||||||
- **API配置**: 支持自定义API Base URL、API Key、模型选择和超时设置
|
- **API配置**: 支持自定义API Base URL、API Key、模型选择和超时设置
|
||||||
@@ -54,51 +53,53 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
### 核心技术
|
### Rust版本(当前版本)
|
||||||
|
- **Rust 1.70+**: 主要开发语言
|
||||||
|
- **eframe/egui**: 现代化跨平台GUI框架
|
||||||
|
- **SQLite**: 本地数据存储(通过rusqlite)
|
||||||
|
- **reqwest**: HTTP请求处理
|
||||||
|
- **serde**: 序列化/反序列化
|
||||||
|
- **tokio**: 异步运行时
|
||||||
|
- **regex**: 正则表达式解析
|
||||||
|
- **chrono**: 时间处理
|
||||||
|
- **parking_lot**: 线程安全锁
|
||||||
|
|
||||||
|
### Python版本(旧版本)
|
||||||
- **Python 3.10+**: 主要开发语言
|
- **Python 3.10+**: 主要开发语言
|
||||||
- **PySide6**: 桌面GUI界面开发
|
- **PySide6**: 桌面GUI界面开发
|
||||||
- **SQLite**: 本地数据存储
|
- **SQLite**: 本地数据存储
|
||||||
- **Requests**: HTTP请求处理
|
- **Requests**: HTTP请求处理
|
||||||
- **LXML**: HTML解析和XPath支持
|
- **LXML**: HTML解析和XPath支持
|
||||||
- **BeautifulSoup4**: 备选HTML解析器
|
- **BeautifulSoup4**: 备选HTML解析器
|
||||||
- **Selenium**: Web自动化(备用方案)
|
|
||||||
- **Playwright**: 高级网页自动化和截图
|
- **Playwright**: 高级网页自动化和截图
|
||||||
|
|
||||||
### AI技术
|
|
||||||
- **OpenAI API**: 大语言模型接口
|
|
||||||
- **JSON解析**: 结构化响应处理
|
|
||||||
- **情感分析算法**: 0-100分评分系统
|
|
||||||
|
|
||||||
### 开发工具
|
|
||||||
- **Loguru**: 高级日志管理
|
|
||||||
- **Playwright**: 自动化浏览器控制
|
|
||||||
- **PyInstaller**: 应用程序打包(通过build.spec)
|
|
||||||
|
|
||||||
## 安装与配置
|
## 安装与配置
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求(Rust版本)
|
||||||
- Python 3.10 或更高版本
|
- **Windows 10/11**: 64位系统
|
||||||
- Windows/macOS/Linux 操作系统
|
- **MSYS2 + MinGW**: 用于编译Rust代码
|
||||||
|
- **Rust 1.70+**: 安装rustup后自动管理
|
||||||
|
|
||||||
### 安装步骤
|
### 安装步骤(Rust版本)
|
||||||
|
|
||||||
1. **克隆或下载项目**
|
1. **克隆或下载项目**
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone http://124.223.26.33:3000/xiaji/guba-indicator.git
|
||||||
cd guba
|
cd guba
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **安装Python依赖**
|
2. **编译项目**
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
# 进入rust目录
|
||||||
|
cd rust
|
||||||
|
|
||||||
|
# 编译release版本(无控制台窗口)
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# 生成的可执行文件在 target/release/guba.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **安装Playwright浏览器驱动**
|
3. **配置API密钥**
|
||||||
```bash
|
|
||||||
playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **配置API密钥**
|
|
||||||
编辑 `config.json` 文件,填入您的大语言模型API配置:
|
编辑 `config.json` 文件,填入您的大语言模型API配置:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -111,7 +112,7 @@ playwright install chromium
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **配置爬取参数**
|
4. **配置爬取参数**
|
||||||
在配置中设置目标URL和XPath表达式:
|
在配置中设置目标URL和XPath表达式:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -124,9 +125,9 @@ playwright install chromium
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 运行程序
|
### 运行程序
|
||||||
```bash
|
- **直接运行**: 双击 `guba-rust.exe` 文件
|
||||||
python main.py
|
- **无控制台窗口**: 程序以GUI模式运行,不会显示命令行窗口
|
||||||
```
|
- **图标支持**: 程序使用 `guba.ico` 作为应用图标
|
||||||
|
|
||||||
## 配置详解
|
## 配置详解
|
||||||
|
|
||||||
@@ -135,15 +136,12 @@ python main.py
|
|||||||
- `api_key`: API访问密钥
|
- `api_key`: API访问密钥
|
||||||
- `model`: 使用的模型名称
|
- `model`: 使用的模型名称
|
||||||
- `timeout`: API请求超时时间(秒)
|
- `timeout`: API请求超时时间(秒)
|
||||||
- `retry_times`: API调用失败时的重试次数
|
|
||||||
|
|
||||||
### 爬虫配置
|
### 爬虫配置
|
||||||
- `target_url`: 目标网站URL
|
- `target_url`: 目标网站URL
|
||||||
- `xpath`: 用于提取评论的XPath表达式
|
- `xpath`: 用于提取评论的XPath表达式
|
||||||
- `user_agent`: HTTP请求的User-Agent字符串
|
- `user_agent`: HTTP请求的User-Agent字符串
|
||||||
- `fetch_interval`: 爬取间隔时间(秒)
|
- `fetch_interval`: 爬取间隔时间(秒)
|
||||||
- `retry_times`: 爬取失败重试次数
|
|
||||||
- `retry_interval`: 爬取重试间隔(秒)
|
|
||||||
|
|
||||||
### UI配置
|
### UI配置
|
||||||
- `opacity`: 窗口透明度(0.3-1.0)
|
- `opacity`: 窗口透明度(0.3-1.0)
|
||||||
@@ -155,48 +153,48 @@ python main.py
|
|||||||
### 数据库配置
|
### 数据库配置
|
||||||
- `path`: SQLite数据库文件路径
|
- `path`: SQLite数据库文件路径
|
||||||
|
|
||||||
### 日志配置
|
|
||||||
- `level`: 日志级别(INFO/DEBUG/ERROR等)
|
|
||||||
- `path`: 日志文件路径
|
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
### 基本操作
|
### 基本操作
|
||||||
1. 启动程序后,主界面将显示情感指示器
|
1. 启动程序后,主界面将显示情感指示器
|
||||||
2. 程序自动开始爬取评论并分析情感
|
2. 程序自动开始爬取评论并分析情感
|
||||||
3. 指示器颜色和数值实时反映市场情绪
|
3. 指示器颜色和数值实时反映市场情绪
|
||||||
4. 波形图显示股票价格走势
|
4. 波形图显示上证指数走势
|
||||||
|
|
||||||
### 按钮功能
|
### 按钮功能
|
||||||
|
- **开始/停止**: 控制爬取和分析过程
|
||||||
- **刷新**: 手动触发一次评论爬取和分析
|
- **刷新**: 手动触发一次评论爬取和分析
|
||||||
- **配置**: 打开配置对话框修改各项参数
|
- **配置**: 打开配置对话框修改各项参数
|
||||||
- **退出**: 关闭应用程序
|
|
||||||
|
|
||||||
### 系统托盘菜单
|
### 配置界面
|
||||||
- **显示**: 显示主窗口
|
- **API配置**: 设置大语言模型API参数,包含测试按钮验证配置
|
||||||
- **隐藏**: 隐藏到系统托盘
|
- **爬虫配置**: 设置目标网站和爬取参数
|
||||||
- **退出**: 完全退出程序
|
- **阈值设置**: 调整情感分析的阈值
|
||||||
|
- **界面设置**: 调整窗口透明度和置顶状态
|
||||||
|
|
||||||
## 高级功能
|
## 高级功能
|
||||||
|
|
||||||
### 批量分析模式
|
### API测试功能
|
||||||
支持一次性分析多条评论,适用于历史数据分析场景。
|
在配置界面中点击"测试API"按钮,程序会:
|
||||||
|
- 检查API连接是否正常
|
||||||
|
- 验证API Key是否有效
|
||||||
|
- 获取并显示可用的模型列表
|
||||||
|
- 显示测试结果和状态
|
||||||
|
|
||||||
### 实时数据统计
|
### 实时数据统计
|
||||||
程序会统计以下运行指标:
|
程序会统计以下运行指标:
|
||||||
- 爬取网站次数
|
- 爬取网站次数
|
||||||
- 提交API分析次数
|
- 提交API分析次数
|
||||||
- 评论总数和已分析数
|
|
||||||
- 平均情感分数趋势
|
- 平均情感分数趋势
|
||||||
|
|
||||||
### 自定义情感标签
|
### 自定义情感标签
|
||||||
系统根据以下规则自动分类情感:
|
系统根据以下规则自动分类情感:
|
||||||
- 0-30分: 极度悲观
|
- 0-29分: 极度悲观
|
||||||
- 30-39分: 悲观
|
- 30-39分: 悲观
|
||||||
- 39-45分: 偏悲观
|
- 40-44分: 偏悲观
|
||||||
- 45-55分: 中立
|
- 45-55分: 中立
|
||||||
- 55-65分: 偏乐观
|
- 56-64分: 偏乐观
|
||||||
- 65-70分: 乐观
|
- 65-69分: 乐观
|
||||||
- 70-100分: 极度乐观
|
- 70-100分: 极度乐观
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
@@ -214,47 +212,67 @@ python main.py
|
|||||||
- 查看网络连接和防火墙设置
|
- 查看网络连接和防火墙设置
|
||||||
|
|
||||||
3. **界面显示异常**
|
3. **界面显示异常**
|
||||||
- 检查PySide6是否正确安装
|
- 检查系统中是否安装了中文字体
|
||||||
- 尝试调整透明度设置
|
- 尝试调整透明度设置
|
||||||
|
|
||||||
### 日志文件
|
### 日志文件
|
||||||
运行日志保存在 `guba.log` 文件中,记录所有操作和错误信息。
|
运行日志输出到控制台,可通过命令行启动查看详细信息:
|
||||||
|
|
||||||
### 统计信息
|
|
||||||
程序退出时会生成运行统计信息,保存在 `statistics.txt` 文件中。
|
|
||||||
|
|
||||||
## 打包发布
|
|
||||||
|
|
||||||
项目使用PyInstaller将应用打包为独立可执行文件:
|
|
||||||
|
|
||||||
### 打包命令
|
|
||||||
```bash
|
```bash
|
||||||
# 安装PyInstaller
|
# 查看日志
|
||||||
pip install pyinstaller
|
.uba-rust.exe > log.txt 2>&1
|
||||||
|
|
||||||
# 打包应用(生成单个exe文件)
|
|
||||||
pyinstaller --onefile --noconsole --icon=guba.ico --distpath=. --hidden-import=PySide6.Qt6Compat --exclude-module PyQt5 --exclude-module PyQt6 main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 打包参数说明
|
## 项目结构
|
||||||
- `--onefile`: 打包成单个exe文件
|
|
||||||
- `--noconsole`: 不显示控制台窗口
|
|
||||||
- `--icon=guba.ico`: 使用guba.ico作为程序图标
|
|
||||||
- `--distpath=.`: 在当前目录生成exe文件
|
|
||||||
- `--hidden-import=PySide6.Qt6Compat`: 显式导入PySide6兼容模块
|
|
||||||
- `--exclude-module PyQt5 --exclude-module PyQt6`: 排除其他Qt绑定,避免冲突
|
|
||||||
|
|
||||||
### 清理打包残留
|
### Rust版本
|
||||||
打包完成后,可运行清理脚本删除build和dist文件夹:
|
- `rust/src/main.rs`: 主程序入口
|
||||||
```bash
|
- `rust/src/config.rs`: 配置管理
|
||||||
python .trae/skills/pyinstaller-one/clean.py
|
- `rust/src/database.rs`: 数据库操作
|
||||||
```
|
- `rust/src/spider.rs`: 网页爬取
|
||||||
|
- `rust/src/analyzer.rs`: 情感分析
|
||||||
|
- `rust/src/ui.rs`: 界面组件
|
||||||
|
- `rust/Cargo.toml`: 项目依赖配置
|
||||||
|
- `rust/build.rs`: Windows资源配置
|
||||||
|
|
||||||
### 打包日志
|
### Python版本(保留)
|
||||||
打包命令已保存在 `package.log` 文件中,方便后续查阅和重复使用。
|
- `main.py`: 主程序入口
|
||||||
|
- `main_window.py`: GUI界面
|
||||||
|
- `config_manager.py`: 配置管理
|
||||||
|
- `database.py`: 数据库操作
|
||||||
|
- `spider.py`: 网页爬取
|
||||||
|
- `llm_analyzer.py`: 情感分析
|
||||||
|
- `waveform_widget.py`: 波形图绘制
|
||||||
|
|
||||||
### 执行文件
|
## 编译说明
|
||||||
打包完成后,`main.exe` 文件将在当前目录生成,可直接运行,无需安装Python环境。
|
|
||||||
|
### MSYS2 + MinGW编译
|
||||||
|
项目使用MSYS2的MinGW工具链编译:
|
||||||
|
|
||||||
|
1. **安装MSYS2**
|
||||||
|
- 下载并安装 [MSYS2](https://www.msys2.org/)
|
||||||
|
- 安装MinGW工具链:`pacman -S mingw-w64-x86_64-toolchain`
|
||||||
|
|
||||||
|
2. **设置Rust工具链**
|
||||||
|
```bash
|
||||||
|
rustup default stable-x86_64-pc-windows-gnu
|
||||||
|
rustup target add x86_64-pc-windows-gnu
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **编译命令**
|
||||||
|
```bash
|
||||||
|
# 进入rust目录
|
||||||
|
cd rust
|
||||||
|
|
||||||
|
# 清理之前的构建
|
||||||
|
cargo clean
|
||||||
|
|
||||||
|
# 编译release版本
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **输出文件**
|
||||||
|
- 编译后的可执行文件:`target/release/guba.exe`
|
||||||
|
- 复制到项目根目录:`guba-rust.exe`
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
@@ -266,16 +284,6 @@ python .trae/skills/pyinstaller-one/clean.py
|
|||||||
## 开发扩展
|
## 开发扩展
|
||||||
|
|
||||||
### 模块结构
|
### 模块结构
|
||||||
- `main.py`: 主程序入口,负责组件初始化和信号连接
|
|
||||||
- `main_window.py`: GUI界面和用户交互处理
|
|
||||||
- `config_manager.py`: 配置文件管理和持久化
|
|
||||||
- `database.py`: SQLite数据库操作和数据管理
|
|
||||||
- `spider.py`: 网页爬取和数据提取
|
|
||||||
- `llm_analyzer.py`: 大语言模型情感分析
|
|
||||||
- `waveform_widget.py`: 股票数据波形图绘制
|
|
||||||
- `screenshot_manager.py`: 网页截图功能
|
|
||||||
|
|
||||||
### 二次开发
|
|
||||||
项目采用模块化设计,易于扩展新功能:
|
项目采用模块化设计,易于扩展新功能:
|
||||||
- 可添加新的爬取目标和解析规则
|
- 可添加新的爬取目标和解析规则
|
||||||
- 支持接入其他大模型API
|
- 支持接入其他大模型API
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Guba Developer"]
|
authors = ["Guba Developer"]
|
||||||
description = "股吧人气指示器 - 基于Rust的情感分析工具"
|
description = "股吧人气指示器 - 基于Rust的情感分析工具"
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
@@ -32,6 +33,7 @@ default-features = false
|
|||||||
features = ["default", "glow"]
|
features = ["default", "glow"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
@@ -40,3 +42,11 @@ default = []
|
|||||||
lto = true
|
lto = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
# 链接标志设置
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
rustflags = ["-C", "link-arg=-mwindows"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "guba"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|||||||
35
rust/build.rs
Normal file
35
rust/build.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
// 设置Windows子系统为windows(不显示控制台)
|
||||||
|
let mut res = winres::WindowsResource::new();
|
||||||
|
|
||||||
|
// 设置应用程序图标 - 使用项目根目录的图标
|
||||||
|
let icon_path = std::path::Path::new("../guba.ico");
|
||||||
|
if icon_path.exists() {
|
||||||
|
res.set_icon("../guba.ico");
|
||||||
|
println!("cargo:rerun-if-changed=../guba.ico");
|
||||||
|
} else {
|
||||||
|
println!("cargo:warning=图标文件未找到: ../guba.ico");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置文件属性
|
||||||
|
res.set_language(0x0804); // 中文(简体)
|
||||||
|
res.set("FileDescription", "股吧人气指示器");
|
||||||
|
res.set("ProductName", "股吧人气指示器");
|
||||||
|
res.set("OriginalFilename", "guba.exe");
|
||||||
|
res.set("InternalName", "guba");
|
||||||
|
res.set("CompanyName", "Guba Developer");
|
||||||
|
res.set("LegalCopyright", "Copyright (C) 2024");
|
||||||
|
|
||||||
|
// 关键:设置Windows子系统为windows(GUI程序,不显示控制台)
|
||||||
|
res.set("Subsystem", "windows");
|
||||||
|
|
||||||
|
res.compile()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn main() {
|
||||||
|
// 非Windows平台不需要特殊处理
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::config::LlmApiConfig;
|
use crate::config::LlmApiConfig;
|
||||||
use reqwest::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -52,7 +52,7 @@ impl LLMAnalyzer {
|
|||||||
let response = self.send_request(&prompt)?;
|
let response = self.send_request(&prompt)?;
|
||||||
|
|
||||||
if let Some(score) = response.score {
|
if let Some(score) = response.score {
|
||||||
let label = response.label.unwrap_or_else(|| self.get_label(score));
|
let label = response.label.clone().unwrap_or_else(|| self.get_label(score));
|
||||||
self.last_result = Some(response);
|
self.last_result = Some(response);
|
||||||
Ok((score, label))
|
Ok((score, label))
|
||||||
} else {
|
} else {
|
||||||
@@ -179,4 +179,87 @@ impl LLMAnalyzer {
|
|||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试API配置是否可用
|
||||||
|
pub fn test_connection(&self) -> Result<String, AnalyzerError> {
|
||||||
|
if self.config.api_key.is_empty() {
|
||||||
|
return Err(AnalyzerError::NoApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/models", self.config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", self.config.api_key))
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().unwrap_or_default();
|
||||||
|
return Err(AnalyzerError::ApiError(format!("HTTP {}: {}", status, body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析响应获取可用模型列表
|
||||||
|
let result: serde_json::Value = response.json()?;
|
||||||
|
|
||||||
|
if let Some(models) = result.get("data").and_then(|d| d.as_array()) {
|
||||||
|
let model_count = models.len();
|
||||||
|
let model_names: Vec<String> = models.iter()
|
||||||
|
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))
|
||||||
|
.take(5)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if model_names.is_empty() {
|
||||||
|
Ok(format!("连接成功!找到 {} 个模型", model_count))
|
||||||
|
} else {
|
||||||
|
Ok(format!("连接成功!找到 {} 个模型,包括: {}", model_count, model_names.join(", ")))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok("连接成功!API配置有效".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用临时配置测试API(用于配置窗口)
|
||||||
|
pub fn test_config(config: &LlmApiConfig) -> Result<String, AnalyzerError> {
|
||||||
|
if config.api_key.is_empty() {
|
||||||
|
return Err(AnalyzerError::NoApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", config.api_key))
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response.text().unwrap_or_default();
|
||||||
|
return Err(AnalyzerError::ApiError(format!("HTTP {}: {}", status, body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response.json()?;
|
||||||
|
|
||||||
|
if let Some(models) = result.get("data").and_then(|d| d.as_array()) {
|
||||||
|
let model_count = models.len();
|
||||||
|
let model_names: Vec<String> = models.iter()
|
||||||
|
.filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))
|
||||||
|
.take(5)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if model_names.is_empty() {
|
||||||
|
Ok(format!("连接成功!找到 {} 个模型", model_count))
|
||||||
|
} else {
|
||||||
|
Ok(format!("连接成功!找到 {} 个模型,包括: {}", model_count, model_names.join(", ")))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok("连接成功!API配置有效".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
343
rust/src/main.rs
343
rust/src/main.rs
@@ -4,53 +4,66 @@ mod spider;
|
|||||||
mod analyzer;
|
mod analyzer;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use config::{Config, ConfigManager};
|
use config::ConfigManager;
|
||||||
use database::DatabaseManager;
|
use database::DatabaseManager;
|
||||||
use spider::SpiderManager;
|
use spider::SpiderManager;
|
||||||
use analyzer::LLMAnalyzer;
|
use analyzer::LLMAnalyzer;
|
||||||
use ui::{AppState, draw_indicator, draw_waveform, get_score_label};
|
use ui::{AppState, draw_indicator, draw_waveform};
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
struct GubaApp {
|
struct GubaApp {
|
||||||
config_manager: ConfigManager,
|
config_manager: ConfigManager,
|
||||||
db: DatabaseManager,
|
db: Arc<DatabaseManager>,
|
||||||
spider: SpiderManager,
|
spider: Arc<SpiderManager>,
|
||||||
analyzer: LLMAnalyzer,
|
analyzer: Arc<Mutex<LLMAnalyzer>>,
|
||||||
state: AppState,
|
state: Arc<AppState>,
|
||||||
stock_data: Arc<parking_lot::Mutex<Vec<(String, f64)>>>,
|
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
|
||||||
config_open: bool,
|
config_open: bool,
|
||||||
|
temp_config: Option<config::Config>,
|
||||||
|
test_status: Arc<Mutex<String>>,
|
||||||
|
is_testing: Arc<Mutex<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GubaApp {
|
impl GubaApp {
|
||||||
fn new(cc: &eframe::CreationContext<'_>, config_manager: ConfigManager) -> Self {
|
fn new(cc: &eframe::CreationContext<'_>, config_manager: ConfigManager) -> Self {
|
||||||
|
// 设置中文字体
|
||||||
|
setup_chinese_fonts(&cc.egui_ctx);
|
||||||
|
|
||||||
let config = config_manager.get();
|
let config = config_manager.get();
|
||||||
|
|
||||||
let db = DatabaseManager::new(&config.database.path)
|
let db = Arc::new(DatabaseManager::new(&config.database.path)
|
||||||
.expect("Failed to initialize database");
|
.expect("Failed to initialize database"));
|
||||||
|
|
||||||
let spider = SpiderManager::new(config.spider.clone());
|
let spider = Arc::new(SpiderManager::new(config.spider.clone()));
|
||||||
|
|
||||||
let mut analyzer = LLMAnalyzer::new(config.llm_api.clone());
|
let analyzer = Arc::new(Mutex::new(LLMAnalyzer::new(config.llm_api.clone())));
|
||||||
|
|
||||||
let state = AppState::new();
|
let state = Arc::new(AppState::new());
|
||||||
let stock_data = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
let stock_data = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let test_status = Arc::new(Mutex::new("点击测试按钮验证API配置".to_string()));
|
||||||
|
let is_testing = Arc::new(Mutex::new(false));
|
||||||
|
|
||||||
let db_clone = Arc::new(database::DatabaseManager::new(&config.database.path)
|
// 启动后台线程
|
||||||
.expect("Failed to create database clone"));
|
let db_clone = db.clone();
|
||||||
let spider_clone = Arc::new(spider::SpiderManager::new(config.spider.clone()));
|
let spider_clone = spider.clone();
|
||||||
let mut analyzer_clone = analyzer::LLMAnalyzer::new(config.llm_api.clone());
|
let analyzer_clone = analyzer.clone();
|
||||||
|
let state_clone = state.clone();
|
||||||
let state_clone = Arc::new(ui::AppState::new());
|
|
||||||
let stock_data_clone = stock_data.clone();
|
let stock_data_clone = stock_data.clone();
|
||||||
|
let config_manager_clone = ConfigManager::new("config.json");
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
run_background_task(db_clone, spider_clone, &mut analyzer_clone, state_clone, stock_data_clone);
|
run_background_task(db_clone, spider_clone, analyzer_clone, state_clone, stock_data_clone, config_manager_clone);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 启动时自动开始运行
|
||||||
|
state.running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config_manager,
|
config_manager,
|
||||||
db,
|
db,
|
||||||
@@ -59,19 +72,104 @@ impl GubaApp {
|
|||||||
state,
|
state,
|
||||||
stock_data,
|
stock_data,
|
||||||
config_open: false,
|
config_open: false,
|
||||||
|
temp_config: None,
|
||||||
|
test_status,
|
||||||
|
is_testing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save_config(&mut self) {
|
||||||
|
if let Some(ref temp_config) = self.temp_config {
|
||||||
|
self.config_manager.update(|config| {
|
||||||
|
*config = temp_config.clone();
|
||||||
|
});
|
||||||
|
let _ = self.config_manager.save();
|
||||||
|
}
|
||||||
|
self.config_open = false;
|
||||||
|
self.temp_config = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_config(&mut self) {
|
||||||
|
self.config_open = false;
|
||||||
|
self.temp_config = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_api_config(&self, temp_config: &config::Config) {
|
||||||
|
let test_status = self.test_status.clone();
|
||||||
|
let is_testing = self.is_testing.clone();
|
||||||
|
let config = temp_config.llm_api.clone();
|
||||||
|
|
||||||
|
*is_testing.lock() = true;
|
||||||
|
*test_status.lock() = "正在测试API连接...".to_string();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
match LLMAnalyzer::test_config(&config) {
|
||||||
|
Ok(msg) => {
|
||||||
|
*test_status.lock() = format!("✅ {}", msg);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*test_status.lock() = format!("❌ 测试失败: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*is_testing.lock() = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_chinese_fonts(ctx: &egui::Context) {
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
|
||||||
|
// 尝试加载系统字体 - 微软雅黑
|
||||||
|
let font_paths = [
|
||||||
|
("C:/Windows/Fonts/msyh.ttc", "微软雅黑"),
|
||||||
|
("C:/Windows/Fonts/msyhbd.ttc", "微软雅黑粗体"),
|
||||||
|
("C:/Windows/Fonts/simhei.ttf", "黑体"),
|
||||||
|
("C:/Windows/Fonts/simsun.ttc", "宋体"),
|
||||||
|
("C:/Windows/Fonts/arialuni.ttf", "Arial Unicode"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, (path, _name)) in font_paths.iter().enumerate() {
|
||||||
|
if std::path::Path::new(path).exists() {
|
||||||
|
match std::fs::read(path) {
|
||||||
|
Ok(font_data) => {
|
||||||
|
let font_name = format!("chinese_font_{}", idx);
|
||||||
|
fonts.font_data.insert(
|
||||||
|
font_name.clone(),
|
||||||
|
egui::FontData::from_owned(font_data),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将中文字体添加到所有字体族的最前面
|
||||||
|
fonts.families
|
||||||
|
.entry(egui::FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.clone());
|
||||||
|
fonts.families
|
||||||
|
.entry(egui::FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name);
|
||||||
|
|
||||||
|
log::info!("成功加载字体: {}", path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("无法加载字体 {}: {}", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_background_task(
|
fn run_background_task(
|
||||||
db: Arc<DatabaseManager>,
|
db: Arc<DatabaseManager>,
|
||||||
spider: Arc<SpiderManager>,
|
spider: Arc<SpiderManager>,
|
||||||
analyzer: &mut LLMAnalyzer,
|
analyzer: Arc<Mutex<LLMAnalyzer>>,
|
||||||
state: Arc<ui::AppState>,
|
state: Arc<AppState>,
|
||||||
stock_data: Arc<parking_lot::Mutex<Vec<(String, f64)>>>,
|
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
|
||||||
|
_config_manager: ConfigManager,
|
||||||
) {
|
) {
|
||||||
let mut no_content_count = 0i32;
|
let mut no_content_count = 0i32;
|
||||||
let mut fetch_interval = 15u64;
|
let fetch_interval = 15u64;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if !state.running.load(Ordering::SeqCst) {
|
if !state.running.load(Ordering::SeqCst) {
|
||||||
@@ -113,15 +211,15 @@ fn run_background_task(
|
|||||||
|
|
||||||
match db.add_comments_batch(&comments) {
|
match db.add_comments_batch(&comments) {
|
||||||
Ok(new_ids) if !new_ids.is_empty() => {
|
Ok(new_ids) if !new_ids.is_empty() => {
|
||||||
let thresholds = {
|
for _id in &new_ids {
|
||||||
let config = ConfigManager::new("config.json");
|
|
||||||
config.get().ui.thresholds
|
|
||||||
};
|
|
||||||
|
|
||||||
for id in &new_ids {
|
|
||||||
if let Ok(unanalyzed) = db.get_unanalyzed_comments(1) {
|
if let Ok(unanalyzed) = db.get_unanalyzed_comments(1) {
|
||||||
if let Some(comment) = unanalyzed.first() {
|
if let Some(comment) = unanalyzed.first() {
|
||||||
match analyzer.analyze(&comment.content) {
|
let result = {
|
||||||
|
let mut analyzer_guard = analyzer.lock();
|
||||||
|
analyzer_guard.analyze(&comment.content)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
Ok((score, label)) => {
|
Ok((score, label)) => {
|
||||||
let _ = db.mark_analyzed(comment.id, score, &label);
|
let _ = db.mark_analyzed(comment.id, score, &label);
|
||||||
state.analysis_count.fetch_add(1, Ordering::SeqCst);
|
state.analysis_count.fetch_add(1, Ordering::SeqCst);
|
||||||
@@ -149,6 +247,7 @@ fn run_background_task(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取股票数据
|
||||||
if let Ok(data) = spider.fetch_sse_stock_data() {
|
if let Ok(data) = spider.fetch_sse_stock_data() {
|
||||||
if data.value > 0.0 {
|
if data.value > 0.0 {
|
||||||
let mut stocks = stock_data.lock();
|
let mut stocks = stock_data.lock();
|
||||||
@@ -166,11 +265,19 @@ fn run_background_task(
|
|||||||
|
|
||||||
impl eframe::App for GubaApp {
|
impl eframe::App for GubaApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
// 白色主题配色
|
||||||
ui.heading("股吧人气指示器");
|
let panel_frame = egui::Frame::central_panel(&ctx.style())
|
||||||
|
.fill(egui::Color32::from_rgb(250, 250, 252)) // 浅白背景
|
||||||
|
.rounding(10.0);
|
||||||
|
|
||||||
|
egui::CentralPanel::default().frame(panel_frame).show(ctx, |ui| {
|
||||||
|
// 标题使用深色
|
||||||
|
ui.heading(egui::RichText::new("股吧人气指示器").color(egui::Color32::from_rgb(33, 37, 41)).size(24.0));
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button(if self.state.running.load(Ordering::SeqCst) { "停止" } else { "开始" }).clicked() {
|
let running = self.state.running.load(Ordering::SeqCst);
|
||||||
let running = self.state.running.load(Ordering::SeqCst);
|
let btn_text = if running { "停止" } else { "开始" };
|
||||||
|
if ui.button(egui::RichText::new(btn_text).color(egui::Color32::WHITE)).clicked() {
|
||||||
self.state.running.store(!running, Ordering::SeqCst);
|
self.state.running.store(!running, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +287,9 @@ impl eframe::App for GubaApp {
|
|||||||
|
|
||||||
if ui.button("配置").clicked() {
|
if ui.button("配置").clicked() {
|
||||||
self.config_open = true;
|
self.config_open = true;
|
||||||
|
self.temp_config = Some(self.config_manager.get());
|
||||||
|
// 重置测试状态
|
||||||
|
*self.test_status.lock() = "点击测试按钮验证API配置".to_string();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,13 +301,13 @@ impl eframe::App for GubaApp {
|
|||||||
let warm = config.ui.thresholds.warm;
|
let warm = config.ui.thresholds.warm;
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("当前情绪:");
|
ui.label(egui::RichText::new("当前情绪:").color(egui::Color32::from_rgb(73, 80, 87)).size(16.0));
|
||||||
draw_indicator(ui, score, cold, warm);
|
draw_indicator(ui, score, cold, warm);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
ui.label("上证指数走势:");
|
ui.label(egui::RichText::new("上证指数走势:").color(egui::Color32::from_rgb(73, 80, 87)).size(16.0));
|
||||||
let stock_data = self.stock_data.lock();
|
let stock_data = self.stock_data.lock();
|
||||||
let data: Vec<(String, f64)> = stock_data.clone();
|
let data: Vec<(String, f64)> = stock_data.clone();
|
||||||
drop(stock_data);
|
drop(stock_data);
|
||||||
@@ -209,45 +319,133 @@ impl eframe::App for GubaApp {
|
|||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("状态: ");
|
ui.label(egui::RichText::new("状态: ").color(egui::Color32::from_rgb(108, 117, 125)));
|
||||||
let status = self.state.status_text.lock();
|
let status = self.state.status_text.lock();
|
||||||
ui.label(status.clone());
|
ui.label(egui::RichText::new(status.clone()).color(egui::Color32::from_rgb(73, 80, 87)));
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(format!("爬取次数: {}", self.state.fetch_count.load(Ordering::SeqCst)));
|
ui.label(egui::RichText::new(format!("爬取次数: {}", self.state.fetch_count.load(Ordering::SeqCst)))
|
||||||
ui.label(format!("分析次数: {}", self.state.analysis_count.load(Ordering::SeqCst)));
|
.color(egui::Color32::from_rgb(108, 117, 125)));
|
||||||
|
ui.label(egui::RichText::new(format!("分析次数: {}", self.state.analysis_count.load(Ordering::SeqCst)))
|
||||||
|
.color(egui::Color32::from_rgb(108, 117, 125)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if self.config_open {
|
// 配置窗口
|
||||||
egui::Window::new("配置").open(&mut self.config_open).show(ctx, |ui| {
|
let mut config_open = self.config_open;
|
||||||
let config = self.config_manager.get();
|
let mut should_save = false;
|
||||||
|
let mut should_cancel = false;
|
||||||
ui.group(|ui| {
|
let mut should_test = false;
|
||||||
ui.label("API配置:");
|
|
||||||
ui.text_edit_singleline(&mut self.config_manager.get().llm_api.api_key.clone());
|
if config_open {
|
||||||
});
|
if let Some(ref mut temp_config) = self.temp_config {
|
||||||
|
egui::Window::new("配置")
|
||||||
ui.group(|ui| {
|
.open(&mut config_open)
|
||||||
ui.label("爬虫配置:");
|
.show(ctx, |ui| {
|
||||||
ui.text_edit_singleline(&mut self.config_manager.get().spider.target_url.clone());
|
ui.group(|ui| {
|
||||||
});
|
ui.label(egui::RichText::new("API配置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.group(|ui| {
|
ui.label("Base URL:");
|
||||||
ui.label("阈值设置:");
|
ui.text_edit_singleline(&mut temp_config.llm_api.base_url);
|
||||||
ui.horizontal(|ui| {
|
});
|
||||||
ui.label("冷阈值:");
|
ui.horizontal(|ui| {
|
||||||
ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.cold).range(0..=100));
|
ui.label("API Key:");
|
||||||
ui.label("热阈值:");
|
ui.add(egui::TextEdit::singleline(&mut temp_config.llm_api.api_key).password(true));
|
||||||
ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.warm).range(0..=100));
|
});
|
||||||
});
|
ui.horizontal(|ui| {
|
||||||
});
|
ui.label("Model:");
|
||||||
|
ui.text_edit_singleline(&mut temp_config.llm_api.model);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("超时时间:");
|
||||||
|
ui.add(egui::DragValue::new(&mut temp_config.llm_api.timeout).range(10..=300));
|
||||||
|
ui.label("秒");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试按钮和状态显示
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let is_testing = *self.is_testing.lock();
|
||||||
|
if ui.button(if is_testing { "测试中..." } else { "测试API" }).clicked() && !is_testing {
|
||||||
|
should_test = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示测试状态
|
||||||
|
let test_status = self.test_status.lock();
|
||||||
|
let status_color = if test_status.starts_with("✅") {
|
||||||
|
egui::Color32::from_rgb(40, 167, 69) // 绿色
|
||||||
|
} else if test_status.starts_with("❌") {
|
||||||
|
egui::Color32::from_rgb(220, 53, 69) // 红色
|
||||||
|
} else {
|
||||||
|
egui::Color32::from_rgb(108, 117, 125) // 灰色
|
||||||
|
};
|
||||||
|
ui.label(egui::RichText::new(test_status.clone()).color(status_color).size(12.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label(egui::RichText::new("爬虫配置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("目标URL:");
|
||||||
|
ui.text_edit_singleline(&mut temp_config.spider.target_url);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("XPath:");
|
||||||
|
ui.text_edit_singleline(&mut temp_config.spider.xpath);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("刷新间隔:");
|
||||||
|
ui.add(egui::DragValue::new(&mut temp_config.spider.fetch_interval).range(10..=3600));
|
||||||
|
ui.label("秒");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label(egui::RichText::new("阈值设置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("冷阈值:");
|
||||||
|
ui.add(egui::DragValue::new(&mut temp_config.ui.thresholds.cold).range(0..=50));
|
||||||
|
ui.label("分");
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("热阈值:");
|
||||||
|
ui.add(egui::DragValue::new(&mut temp_config.ui.thresholds.warm).range(50..=100));
|
||||||
|
ui.label("分");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if ui.button("保存").clicked() {
|
ui.group(|ui| {
|
||||||
let _ = self.config_manager.save();
|
ui.label(egui::RichText::new("界面设置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0));
|
||||||
}
|
ui.horizontal(|ui| {
|
||||||
});
|
ui.label("透明度:");
|
||||||
|
ui.add(egui::Slider::new(&mut temp_config.ui.opacity, 0.3..=1.0));
|
||||||
|
});
|
||||||
|
ui.checkbox(&mut temp_config.ui.is_on_top, "窗口置顶");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("保存").clicked() {
|
||||||
|
should_save = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("取消").clicked() {
|
||||||
|
should_cancel = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_test {
|
||||||
|
if let Some(ref temp_config) = self.temp_config {
|
||||||
|
self.test_api_config(temp_config);
|
||||||
|
}
|
||||||
|
} else if should_save {
|
||||||
|
self.save_config();
|
||||||
|
} else if should_cancel {
|
||||||
|
self.cancel_config();
|
||||||
|
} else {
|
||||||
|
self.config_open = config_open;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,9 +460,12 @@ fn main() -> eframe::Result<()> {
|
|||||||
|
|
||||||
let options = eframe::NativeOptions {
|
let options = eframe::NativeOptions {
|
||||||
viewport: egui::ViewportBuilder::default()
|
viewport: egui::ViewportBuilder::default()
|
||||||
.with_inner_size([400.0, 600.0])
|
.with_inner_size([420.0, 650.0])
|
||||||
.with_min_inner_size([300.0, 400.0])
|
.with_min_inner_size([350.0, 500.0])
|
||||||
.with_resizable(true),
|
.with_resizable(true)
|
||||||
|
.with_always_on_top()
|
||||||
|
.with_transparent(false) // 白色背景不需要透明
|
||||||
|
.with_decorations(true),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
126
rust/src/ui.rs
126
rust/src/ui.rs
@@ -1,13 +1,15 @@
|
|||||||
use egui::{Color32, RichText, Stroke, Vec2};
|
use egui::{Color32, FontId, Stroke, Vec2};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI32};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub running: Arc<AtomicBool>,
|
pub running: Arc<AtomicBool>,
|
||||||
pub current_score: Arc<AtomicI32>,
|
pub current_score: Arc<AtomicI32>,
|
||||||
pub status_text: Arc<parking_lot::Mutex<String>>,
|
pub status_text: Arc<Mutex<String>>,
|
||||||
pub fetch_count: Arc<AtomicI32>,
|
pub fetch_count: Arc<AtomicI32>,
|
||||||
pub analysis_count: Arc<AtomicI32>,
|
pub analysis_count: Arc<AtomicI32>,
|
||||||
|
pub no_content_count: Arc<AtomicI32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -15,9 +17,10 @@ impl AppState {
|
|||||||
Self {
|
Self {
|
||||||
running: Arc::new(AtomicBool::new(false)),
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
current_score: Arc::new(AtomicI32::new(50)),
|
current_score: Arc::new(AtomicI32::new(50)),
|
||||||
status_text: Arc::new(parking_lot::Mutex::new("就绪".to_string())),
|
status_text: Arc::new(Mutex::new("就绪".to_string())),
|
||||||
fetch_count: Arc::new(AtomicI32::new(0)),
|
fetch_count: Arc::new(AtomicI32::new(0)),
|
||||||
analysis_count: Arc::new(AtomicI32::new(0)),
|
analysis_count: Arc::new(AtomicI32::new(0)),
|
||||||
|
no_content_count: Arc::new(AtomicI32::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,10 +33,13 @@ impl Default for AppState {
|
|||||||
|
|
||||||
pub fn get_score_color(score: i32) -> Color32 {
|
pub fn get_score_color(score: i32) -> Color32 {
|
||||||
match score {
|
match score {
|
||||||
0..=30 => Color32::from_rgb(0, 100, 200),
|
0..=30 => Color32::from_rgb(21, 101, 192), // 深蓝
|
||||||
31..=50 => Color32::from_rgb(100, 200, 100),
|
31..=38 => Color32::from_rgb(25, 118, 210), // 蓝色
|
||||||
51..=70 => Color32::from_rgb(255, 200, 0),
|
39..=44 => Color32::from_rgb(66, 165, 245), // 浅蓝
|
||||||
71..=100 => Color32::from_rgb(255, 50, 50),
|
45..=55 => Color32::from_rgb(102, 187, 106), // 绿色
|
||||||
|
56..=64 => Color32::from_rgb(255, 167, 38), // 橙色
|
||||||
|
65..=69 => Color32::from_rgb(251, 140, 0), // 深橙
|
||||||
|
70..=100 => Color32::from_rgb(229, 57, 53), // 红色
|
||||||
_ => Color32::GRAY,
|
_ => Color32::GRAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,54 +54,88 @@ pub fn get_score_label(score: i32, cold: i32, warm: i32) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_score_description(score: i32) -> &'static str {
|
||||||
|
match score {
|
||||||
|
0..=29 => "极度悲观",
|
||||||
|
30..=38 => "悲观",
|
||||||
|
39..=44 => "偏悲观",
|
||||||
|
45..=55 => "中立",
|
||||||
|
56..=64 => "偏乐观",
|
||||||
|
65..=69 => "乐观",
|
||||||
|
70..=100 => "极度乐观",
|
||||||
|
_ => "无法判断",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn draw_indicator(ui: &mut egui::Ui, score: i32, cold: i32, warm: i32) {
|
pub fn draw_indicator(ui: &mut egui::Ui, score: i32, cold: i32, warm: i32) {
|
||||||
let color = get_score_color(score);
|
let color = get_score_color(score);
|
||||||
let label = get_score_label(score, cold, warm);
|
let label = get_score_label(score, cold, warm);
|
||||||
let size = Vec2::new(120.0, 120.0);
|
let description = get_score_description(score);
|
||||||
|
let size = Vec2::new(140.0, 160.0);
|
||||||
|
|
||||||
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
|
let (rect, _response) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
|
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
let center = rect.center();
|
||||||
|
|
||||||
painter.circle_filled(rect.center(), 55.0, Color32::from_rgba_unmultiplied(30, 30, 30, 200));
|
// 外圈背景 - 白色主题
|
||||||
painter.circle_stroke(rect.center(), 55.0, Stroke::new(3.0, color));
|
painter.circle_filled(center, 65.0, Color32::from_rgb(240, 240, 245));
|
||||||
|
painter.circle_stroke(center, 65.0, Stroke::new(3.0, Color32::from_rgb(200, 200, 210)));
|
||||||
|
|
||||||
let inner_radius = 40.0;
|
// 内圈颜色
|
||||||
let angle = (score as f32 / 100.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
|
painter.circle_filled(center, 58.0, color);
|
||||||
let indicator_pos = rect.center() + Vec2::new(
|
|
||||||
angle.cos() * inner_radius,
|
// 发光效果
|
||||||
angle.sin() * inner_radius,
|
for i in 1..=3 {
|
||||||
);
|
let alpha = (60 / i) as u8;
|
||||||
painter.circle_filled(indicator_pos, 8.0, color);
|
let glow_color = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha);
|
||||||
|
painter.circle_filled(center, 58.0 - (i as f32) * 5.0, glow_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中心白色背景
|
||||||
|
painter.circle_filled(center, 45.0, Color32::from_rgb(255, 255, 255));
|
||||||
|
|
||||||
let text = RichText::new(format!("{}", score))
|
// 分数文本 - 深色
|
||||||
.heading()
|
let score_text = format!("{}", score);
|
||||||
.size(36.0)
|
|
||||||
.color(color);
|
|
||||||
painter.text(
|
painter.text(
|
||||||
rect.center() - Vec2::new(0.0, 10.0),
|
center - Vec2::new(0.0, 5.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
text,
|
score_text,
|
||||||
|
FontId::proportional(42.0),
|
||||||
|
color,
|
||||||
);
|
);
|
||||||
|
|
||||||
let label_text = RichText::new(label).size(16.0).color(Color32::WHITE);
|
// 标签文本 - 深色
|
||||||
painter.text(
|
painter.text(
|
||||||
rect.center() + Vec2::new(0.0, 25.0),
|
center + Vec2::new(0.0, 28.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
label_text,
|
label,
|
||||||
|
FontId::proportional(16.0),
|
||||||
|
Color32::from_rgb(50, 50, 55),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 描述文本(在指示灯下方)
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.label(egui::RichText::new(description).size(14.0).color(Color32::from_rgb(100, 100, 110)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_waveform(ui: &mut egui::Ui, data: &[(String, f64)], width: f32, height: f32) {
|
pub fn draw_waveform(ui: &mut egui::Ui, data: &[(String, f64)], _width: f32, _height: f32) {
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
|
|
||||||
painter.rect_filled(rect, 0.0, Color32::from_rgba_unmultiplied(20, 20, 30, 180));
|
// 白色背景
|
||||||
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::GRAY));
|
painter.rect_filled(rect, 8.0, Color32::from_rgb(255, 255, 255));
|
||||||
|
painter.rect_stroke(rect, 8.0, Stroke::new(1.0, Color32::from_rgb(220, 220, 230)));
|
||||||
|
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
let text = RichText::new("暂无数据").small().color(Color32::GRAY);
|
let text = "暂无数据";
|
||||||
painter.text(rect.center(), egui::Align2::CENTER_CENTER, text);
|
painter.text(
|
||||||
|
rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
text,
|
||||||
|
FontId::proportional(14.0),
|
||||||
|
Color32::from_rgb(150, 150, 160),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +144,22 @@ pub fn draw_waveform(ui: &mut egui::Ui, data: &[(String, f64)], width: f32, heig
|
|||||||
let max_val = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
let max_val = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||||
let range = if (max_val - min_val).abs() < 0.01 { 1.0 } else { max_val - min_val };
|
let range = if (max_val - min_val).abs() < 0.01 { 1.0 } else { max_val - min_val };
|
||||||
|
|
||||||
let padding = 10.0;
|
let padding = 15.0;
|
||||||
let draw_width = rect.width() - padding * 2.0;
|
let draw_width = rect.width() - padding * 2.0;
|
||||||
let draw_height = rect.height() - padding * 2.0;
|
let draw_height = rect.height() - padding * 2.0;
|
||||||
|
|
||||||
let step_x = if data.len() > 1 { draw_width / (data.len() - 1) as f32 } else { 0.0 };
|
let step_x = if data.len() > 1 { draw_width / (data.len() - 1) as f32 } else { 0.0 };
|
||||||
|
|
||||||
|
// 绘制网格线
|
||||||
|
for i in 0..=4 {
|
||||||
|
let y = rect.max.y - padding - (i as f32) * (draw_height / 4.0);
|
||||||
|
painter.line_segment(
|
||||||
|
[egui::pos2(rect.min.x + padding, y), egui::pos2(rect.max.x - padding, y)],
|
||||||
|
Stroke::new(1.0, Color32::from_rgba_unmultiplied(200, 200, 210, 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制数据线
|
||||||
for i in 0..data.len().saturating_sub(1) {
|
for i in 0..data.len().saturating_sub(1) {
|
||||||
let x1 = rect.min.x + padding + (i as f32) * step_x;
|
let x1 = rect.min.x + padding + (i as f32) * step_x;
|
||||||
let x2 = rect.min.x + padding + ((i + 1) as f32) * step_x;
|
let x2 = rect.min.x + padding + ((i + 1) as f32) * step_x;
|
||||||
@@ -119,7 +169,15 @@ pub fn draw_waveform(ui: &mut egui::Ui, data: &[(String, f64)], width: f32, heig
|
|||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
|
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
|
||||||
Stroke::new(2.0, Color32::from_rgb(0, 150, 255)),
|
Stroke::new(2.0, Color32::from_rgb(0, 123, 255)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绘制数据点
|
||||||
|
for i in 0..data.len() {
|
||||||
|
let x = rect.min.x + padding + (i as f32) * step_x;
|
||||||
|
let y = rect.max.y - padding - ((values[i] - min_val) / range * draw_height as f64) as f32;
|
||||||
|
|
||||||
|
painter.circle_filled(egui::pos2(x, y), 3.0, Color32::from_rgb(0, 150, 255));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user