From 3ae0eaa9c192595f4acb42ea91c963bc1f20b70d Mon Sep 17 00:00:00 2001 From: xiaji Date: Tue, 7 Apr 2026 17:27:38 +0800 Subject: [PATCH] Update Rust version: fix console window, add API test, update README --- .gitignore | 5 + README.md | 216 ++++++++++++++------------- rust/Cargo.toml | 12 +- rust/build.rs | 35 +++++ rust/src/analyzer.rs | 87 ++++++++++- rust/src/main.rs | 343 ++++++++++++++++++++++++++++++++++--------- rust/src/ui.rs | 126 +++++++++++----- 7 files changed, 612 insertions(+), 212 deletions(-) create mode 100644 rust/build.rs diff --git a/.gitignore b/.gitignore index 15b85b3..d6d0041 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,8 @@ test*.* # 其它 main.exe sse_screen*.png + +# Rust +target/ +rust/target/ +Cargo.lock diff --git a/README.md b/README.md index 3373991..5af2200 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 股吧人气指示器 (Guba Sentiment Indicator) -一个基于 Python + PySide6 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。 +一个基于 **Rust + eframe** 的桌面悬浮小工具,专为股票投资者设计,通过爬取股吧或论坛评论,使用大语言模型分析投资者情绪倾向,并以直观的可视化方式展示市场人气冷暖程度。 ## 主要功能 @@ -8,14 +8,14 @@ - **自定义爬取规则**: 支持通过XPath表达式自定义爬取目标,可灵活配置目标URL和内容选择器 - **智能去重机制**: 使用内容哈希算法自动识别和过滤重复评论,避免重复分析 - **自适应刷新**: 根据爬取结果动态调整刷新频率,无新内容时自动延长刷新间隔 -- **多重解析策略**: 采用 lxml + BeautifulSoup 双重解析机制,提高爬取成功率 +- **多重解析策略**: 采用正则表达式多重解析机制,提高爬取成功率 - **用户代理配置**: 支持自定义User-Agent,增强反爬虫适应性 ### 2. 大语言模型情感分析 - **多模型支持**: 支持OpenAI兼容API,包括NVIDIA API等主流大模型平台 - **精准情感评分**: 为每条评论生成0-100分的情感倾向评分(0为极度悲观,100为极度乐观) - **智能标签生成**: 自动为分析结果生成情感标签(极度悲观、悲观、中立、乐观、极度乐观等) -- **重试机制**: 集成API调用重试机制,应对网络波动和API限流 +- **API测试功能**: 配置界面提供API连接测试,确保配置正确 - **分析历史记录**: 完整保存每次分析的结果和时间戳 ### 3. 可视化情感指示器 @@ -27,24 +27,23 @@ - **精确数值显示**: 实时显示当前平均情感分数和整体情绪标签 - **发光特效**: 采用发光效果增强视觉体验 -### 4. 上证所网页截图功能 -- **自动化截图**: 使用Playwright自动截取上证所网页指定区域 -- **元素定位**: 通过XPath精确定位图表元素进行截图 -- **定时更新**: 每5分钟自动更新一次截图 -- **历史管理**: 自动管理截图文件,保留最新记录 +### 4. 股票数据可视化 +- **上证指数实时数据**: 自动获取上证指数数据并绘制波形图 +- **历史数据追踪**: 保留最近100个数据点,展示价格变化趋势 +- **图表自动更新**: 与评论分析同步更新 ### 5. 桌面客户端界面 -- **悬浮窗口**: 无边框设计,支持窗口拖拽和置顶显示 -- **系统托盘**: 集成系统托盘功能,支持最小化到托盘 -- **透明度调节**: 支持0.3-1.0范围内的透明度调节 +- **悬浮窗口**: 现代化无边框设计,支持窗口拖拽和置顶显示 +- **白色主题**: 简洁美观的白色主题界面,提高可读性 +- **中文字体支持**: 自动加载系统中文字体,确保界面显示正常 - **配置对话框**: 图形化配置界面,支持实时修改各项参数 - **状态显示**: 实时显示当前运行状态和处理进度 ### 6. 数据管理与持久化 - **SQLite数据库**: 使用SQLite存储所有评论、分析结果和历史数据 -- **结构化存储**: 评论表、分析历史表、配置表分离存储 +- **结构化存储**: 评论表、分析历史表分离存储 - **数据统计**: 实时统计爬取次数、分析次数等运行指标 -- **自动清理**: 支持历史数据管理和自动清理 +- **自动清理**: 支持历史数据管理 ### 7. 高级配置选项 - **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+**: 主要开发语言 - **PySide6**: 桌面GUI界面开发 - **SQLite**: 本地数据存储 - **Requests**: HTTP请求处理 - **LXML**: HTML解析和XPath支持 - **BeautifulSoup4**: 备选HTML解析器 -- **Selenium**: Web自动化(备用方案) - **Playwright**: 高级网页自动化和截图 -### AI技术 -- **OpenAI API**: 大语言模型接口 -- **JSON解析**: 结构化响应处理 -- **情感分析算法**: 0-100分评分系统 - -### 开发工具 -- **Loguru**: 高级日志管理 -- **Playwright**: 自动化浏览器控制 -- **PyInstaller**: 应用程序打包(通过build.spec) - ## 安装与配置 -### 环境要求 -- Python 3.10 或更高版本 -- Windows/macOS/Linux 操作系统 +### 环境要求(Rust版本) +- **Windows 10/11**: 64位系统 +- **MSYS2 + MinGW**: 用于编译Rust代码 +- **Rust 1.70+**: 安装rustup后自动管理 -### 安装步骤 +### 安装步骤(Rust版本) 1. **克隆或下载项目** ```bash -git clone +git clone http://124.223.26.33:3000/xiaji/guba-indicator.git cd guba ``` -2. **安装Python依赖** +2. **编译项目** ```bash -pip install -r requirements.txt +# 进入rust目录 +cd rust + +# 编译release版本(无控制台窗口) +cargo build --release + +# 生成的可执行文件在 target/release/guba.exe ``` -3. **安装Playwright浏览器驱动** -```bash -playwright install chromium -``` - -4. **配置API密钥** +3. **配置API密钥** 编辑 `config.json` 文件,填入您的大语言模型API配置: ```json { @@ -111,7 +112,7 @@ playwright install chromium } ``` -5. **配置爬取参数** +4. **配置爬取参数** 在配置中设置目标URL和XPath表达式: ```json { @@ -124,9 +125,9 @@ playwright install chromium ``` ### 运行程序 -```bash -python main.py -``` +- **直接运行**: 双击 `guba-rust.exe` 文件 +- **无控制台窗口**: 程序以GUI模式运行,不会显示命令行窗口 +- **图标支持**: 程序使用 `guba.ico` 作为应用图标 ## 配置详解 @@ -135,15 +136,12 @@ python main.py - `api_key`: API访问密钥 - `model`: 使用的模型名称 - `timeout`: API请求超时时间(秒) -- `retry_times`: API调用失败时的重试次数 ### 爬虫配置 - `target_url`: 目标网站URL - `xpath`: 用于提取评论的XPath表达式 - `user_agent`: HTTP请求的User-Agent字符串 - `fetch_interval`: 爬取间隔时间(秒) -- `retry_times`: 爬取失败重试次数 -- `retry_interval`: 爬取重试间隔(秒) ### UI配置 - `opacity`: 窗口透明度(0.3-1.0) @@ -155,48 +153,48 @@ python main.py ### 数据库配置 - `path`: SQLite数据库文件路径 -### 日志配置 -- `level`: 日志级别(INFO/DEBUG/ERROR等) -- `path`: 日志文件路径 - ## 使用说明 ### 基本操作 1. 启动程序后,主界面将显示情感指示器 2. 程序自动开始爬取评论并分析情感 3. 指示器颜色和数值实时反映市场情绪 -4. 波形图显示股票价格走势 +4. 波形图显示上证指数走势 ### 按钮功能 +- **开始/停止**: 控制爬取和分析过程 - **刷新**: 手动触发一次评论爬取和分析 - **配置**: 打开配置对话框修改各项参数 -- **退出**: 关闭应用程序 -### 系统托盘菜单 -- **显示**: 显示主窗口 -- **隐藏**: 隐藏到系统托盘 -- **退出**: 完全退出程序 +### 配置界面 +- **API配置**: 设置大语言模型API参数,包含测试按钮验证配置 +- **爬虫配置**: 设置目标网站和爬取参数 +- **阈值设置**: 调整情感分析的阈值 +- **界面设置**: 调整窗口透明度和置顶状态 ## 高级功能 -### 批量分析模式 -支持一次性分析多条评论,适用于历史数据分析场景。 +### API测试功能 +在配置界面中点击"测试API"按钮,程序会: +- 检查API连接是否正常 +- 验证API Key是否有效 +- 获取并显示可用的模型列表 +- 显示测试结果和状态 ### 实时数据统计 程序会统计以下运行指标: - 爬取网站次数 - 提交API分析次数 -- 评论总数和已分析数 - 平均情感分数趋势 ### 自定义情感标签 系统根据以下规则自动分类情感: -- 0-30分: 极度悲观 +- 0-29分: 极度悲观 - 30-39分: 悲观 -- 39-45分: 偏悲观 +- 40-44分: 偏悲观 - 45-55分: 中立 -- 55-65分: 偏乐观 -- 65-70分: 乐观 +- 56-64分: 偏乐观 +- 65-69分: 乐观 - 70-100分: 极度乐观 ## 故障排除 @@ -214,47 +212,67 @@ python main.py - 查看网络连接和防火墙设置 3. **界面显示异常** - - 检查PySide6是否正确安装 + - 检查系统中是否安装了中文字体 - 尝试调整透明度设置 ### 日志文件 -运行日志保存在 `guba.log` 文件中,记录所有操作和错误信息。 - -### 统计信息 -程序退出时会生成运行统计信息,保存在 `statistics.txt` 文件中。 - -## 打包发布 - -项目使用PyInstaller将应用打包为独立可执行文件: - -### 打包命令 +运行日志输出到控制台,可通过命令行启动查看详细信息: ```bash -# 安装PyInstaller -pip install pyinstaller - -# 打包应用(生成单个exe文件) -pyinstaller --onefile --noconsole --icon=guba.ico --distpath=. --hidden-import=PySide6.Qt6Compat --exclude-module PyQt5 --exclude-module PyQt6 main.py +# 查看日志 +.uba-rust.exe > log.txt 2>&1 ``` -### 打包参数说明 -- `--onefile`: 打包成单个exe文件 -- `--noconsole`: 不显示控制台窗口 -- `--icon=guba.ico`: 使用guba.ico作为程序图标 -- `--distpath=.`: 在当前目录生成exe文件 -- `--hidden-import=PySide6.Qt6Compat`: 显式导入PySide6兼容模块 -- `--exclude-module PyQt5 --exclude-module PyQt6`: 排除其他Qt绑定,避免冲突 +## 项目结构 -### 清理打包残留 -打包完成后,可运行清理脚本删除build和dist文件夹: -```bash -python .trae/skills/pyinstaller-one/clean.py -``` +### Rust版本 +- `rust/src/main.rs`: 主程序入口 +- `rust/src/config.rs`: 配置管理 +- `rust/src/database.rs`: 数据库操作 +- `rust/src/spider.rs`: 网页爬取 +- `rust/src/analyzer.rs`: 情感分析 +- `rust/src/ui.rs`: 界面组件 +- `rust/Cargo.toml`: 项目依赖配置 +- `rust/build.rs`: Windows资源配置 -### 打包日志 -打包命令已保存在 `package.log` 文件中,方便后续查阅和重复使用。 +### Python版本(保留) +- `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 diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 72ec387..590a428 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -4,10 +4,11 @@ version = "0.1.0" edition = "2021" authors = ["Guba Developer"] description = "股吧人气指示器 - 基于Rust的情感分析工具" +build = "build.rs" [dependencies] 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_json = "1.0" rusqlite = { version = "0.32", features = ["bundled"] } @@ -32,6 +33,7 @@ default-features = false features = ["default", "glow"] [build-dependencies] +winres = "0.1" [features] default = [] @@ -40,3 +42,11 @@ default = [] lto = true opt-level = "z" strip = true + +# 链接标志设置 +[target.x86_64-pc-windows-gnu] +rustflags = ["-C", "link-arg=-mwindows"] + +[[bin]] +name = "guba" +path = "src/main.rs" diff --git a/rust/build.rs b/rust/build.rs new file mode 100644 index 0000000..078662a --- /dev/null +++ b/rust/build.rs @@ -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平台不需要特殊处理 +} diff --git a/rust/src/analyzer.rs b/rust/src/analyzer.rs index d6e2b95..8cf973d 100644 --- a/rust/src/analyzer.rs +++ b/rust/src/analyzer.rs @@ -1,5 +1,5 @@ use crate::config::LlmApiConfig; -use reqwest::Client; +use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; use thiserror::Error; @@ -52,7 +52,7 @@ impl LLMAnalyzer { let response = self.send_request(&prompt)?; 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); Ok((score, label)) } else { @@ -179,4 +179,87 @@ impl LLMAnalyzer { .build() .expect("Failed to create HTTP client"); } + + /// 测试API配置是否可用 + pub fn test_connection(&self) -> Result { + 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 = 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 { + 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 = 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()) + } + } } diff --git a/rust/src/main.rs b/rust/src/main.rs index a9fd1e0..0f0c14b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -4,53 +4,66 @@ mod spider; mod analyzer; mod ui; -use config::{Config, ConfigManager}; +use config::ConfigManager; use database::DatabaseManager; use spider::SpiderManager; use analyzer::LLMAnalyzer; -use ui::{AppState, draw_indicator, draw_waveform, get_score_label}; +use ui::{AppState, draw_indicator, draw_waveform}; use eframe::egui; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; +use parking_lot::Mutex; struct GubaApp { config_manager: ConfigManager, - db: DatabaseManager, - spider: SpiderManager, - analyzer: LLMAnalyzer, - state: AppState, - stock_data: Arc>>, + db: Arc, + spider: Arc, + analyzer: Arc>, + state: Arc, + stock_data: Arc>>, config_open: bool, + temp_config: Option, + test_status: Arc>, + is_testing: Arc>, } impl GubaApp { fn new(cc: &eframe::CreationContext<'_>, config_manager: ConfigManager) -> Self { + // 设置中文字体 + setup_chinese_fonts(&cc.egui_ctx); + let config = config_manager.get(); - let db = DatabaseManager::new(&config.database.path) - .expect("Failed to initialize database"); + let db = Arc::new(DatabaseManager::new(&config.database.path) + .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 stock_data = Arc::new(parking_lot::Mutex::new(Vec::new())); + let state = Arc::new(AppState::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 spider_clone = Arc::new(spider::SpiderManager::new(config.spider.clone())); - let mut analyzer_clone = analyzer::LLMAnalyzer::new(config.llm_api.clone()); - - let state_clone = Arc::new(ui::AppState::new()); + // 启动后台线程 + let db_clone = db.clone(); + let spider_clone = spider.clone(); + let analyzer_clone = analyzer.clone(); + let state_clone = state.clone(); let stock_data_clone = stock_data.clone(); + let config_manager_clone = ConfigManager::new("config.json"); 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 { config_manager, db, @@ -59,19 +72,104 @@ impl GubaApp { state, stock_data, 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( db: Arc, spider: Arc, - analyzer: &mut LLMAnalyzer, - state: Arc, - stock_data: Arc>>, + analyzer: Arc>, + state: Arc, + stock_data: Arc>>, + _config_manager: ConfigManager, ) { let mut no_content_count = 0i32; - let mut fetch_interval = 15u64; + let fetch_interval = 15u64; loop { if !state.running.load(Ordering::SeqCst) { @@ -113,15 +211,15 @@ fn run_background_task( match db.add_comments_batch(&comments) { Ok(new_ids) if !new_ids.is_empty() => { - let thresholds = { - let config = ConfigManager::new("config.json"); - config.get().ui.thresholds - }; - - for id in &new_ids { + for _id in &new_ids { if let Ok(unanalyzed) = db.get_unanalyzed_comments(1) { 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)) => { let _ = db.mark_analyzed(comment.id, score, &label); 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 data.value > 0.0 { let mut stocks = stock_data.lock(); @@ -166,11 +265,19 @@ fn run_background_task( impl eframe::App for GubaApp { 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| { - 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); } @@ -180,6 +287,9 @@ impl eframe::App for GubaApp { if ui.button("配置").clicked() { 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; 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); }); 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 data: Vec<(String, f64)> = stock_data.clone(); drop(stock_data); @@ -209,45 +319,133 @@ impl eframe::App for GubaApp { ui.separator(); 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(); - ui.label(status.clone()); + ui.label(egui::RichText::new(status.clone()).color(egui::Color32::from_rgb(73, 80, 87))); }); ui.horizontal(|ui| { - ui.label(format!("爬取次数: {}", self.state.fetch_count.load(Ordering::SeqCst))); - ui.label(format!("分析次数: {}", self.state.analysis_count.load(Ordering::SeqCst))); + ui.label(egui::RichText::new(format!("爬取次数: {}", self.state.fetch_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 config = self.config_manager.get(); - - ui.group(|ui| { - ui.label("API配置:"); - ui.text_edit_singleline(&mut self.config_manager.get().llm_api.api_key.clone()); - }); - - ui.group(|ui| { - ui.label("爬虫配置:"); - ui.text_edit_singleline(&mut self.config_manager.get().spider.target_url.clone()); - }); - - ui.group(|ui| { - ui.label("阈值设置:"); - ui.horizontal(|ui| { - ui.label("冷阈值:"); - ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.cold).range(0..=100)); - ui.label("热阈值:"); - ui.add(egui::DragValue::new(&mut self.config_manager.get().ui.thresholds.warm).range(0..=100)); - }); - }); + // 配置窗口 + let mut config_open = self.config_open; + let mut should_save = false; + let mut should_cancel = false; + let mut should_test = false; + + if config_open { + if let Some(ref mut temp_config) = self.temp_config { + egui::Window::new("配置") + .open(&mut config_open) + .show(ctx, |ui| { + ui.group(|ui| { + ui.label(egui::RichText::new("API配置:").color(egui::Color32::from_rgb(33, 37, 41)).size(16.0)); + ui.horizontal(|ui| { + ui.label("Base URL:"); + ui.text_edit_singleline(&mut temp_config.llm_api.base_url); + }); + ui.horizontal(|ui| { + ui.label("API Key:"); + ui.add(egui::TextEdit::singleline(&mut temp_config.llm_api.api_key).password(true)); + }); + 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() { - let _ = self.config_manager.save(); - } - }); + 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::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 { viewport: egui::ViewportBuilder::default() - .with_inner_size([400.0, 600.0]) - .with_min_inner_size([300.0, 400.0]) - .with_resizable(true), + .with_inner_size([420.0, 650.0]) + .with_min_inner_size([350.0, 500.0]) + .with_resizable(true) + .with_always_on_top() + .with_transparent(false) // 白色背景不需要透明 + .with_decorations(true), ..Default::default() }; diff --git a/rust/src/ui.rs b/rust/src/ui.rs index bcdb116..da68196 100644 --- a/rust/src/ui.rs +++ b/rust/src/ui.rs @@ -1,13 +1,15 @@ -use egui::{Color32, RichText, Stroke, Vec2}; -use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use egui::{Color32, FontId, Stroke, Vec2}; +use std::sync::atomic::{AtomicBool, AtomicI32}; use std::sync::Arc; +use parking_lot::Mutex; pub struct AppState { pub running: Arc, pub current_score: Arc, - pub status_text: Arc>, + pub status_text: Arc>, pub fetch_count: Arc, pub analysis_count: Arc, + pub no_content_count: Arc, } impl AppState { @@ -15,9 +17,10 @@ impl AppState { Self { running: Arc::new(AtomicBool::new(false)), 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)), 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 { match score { - 0..=30 => Color32::from_rgb(0, 100, 200), - 31..=50 => Color32::from_rgb(100, 200, 100), - 51..=70 => Color32::from_rgb(255, 200, 0), - 71..=100 => Color32::from_rgb(255, 50, 50), + 0..=30 => Color32::from_rgb(21, 101, 192), // 深蓝 + 31..=38 => Color32::from_rgb(25, 118, 210), // 蓝色 + 39..=44 => Color32::from_rgb(66, 165, 245), // 浅蓝 + 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, } } @@ -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) { let color = get_score_color(score); 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 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; - let indicator_pos = rect.center() + Vec2::new( - angle.cos() * inner_radius, - angle.sin() * inner_radius, - ); - painter.circle_filled(indicator_pos, 8.0, color); + // 内圈颜色 + painter.circle_filled(center, 58.0, color); + + // 发光效果 + for i in 1..=3 { + let alpha = (60 / i) as u8; + 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() - .size(36.0) - .color(color); + // 分数文本 - 深色 + let score_text = format!("{}", score); painter.text( - rect.center() - Vec2::new(0.0, 10.0), + center - Vec2::new(0.0, 5.0), 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( - rect.center() + Vec2::new(0.0, 25.0), + center + Vec2::new(0.0, 28.0), 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 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() { - let text = RichText::new("暂无数据").small().color(Color32::GRAY); - painter.text(rect.center(), egui::Align2::CENTER_CENTER, text); + let text = "暂无数据"; + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + FontId::proportional(14.0), + Color32::from_rgb(150, 150, 160), + ); 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 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_height = rect.height() - padding * 2.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) { let x1 = rect.min.x + padding + (i 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( [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)); + } }