Update Rust version: fix console window, add API test, update README

This commit is contained in:
2026-04-07 17:27:38 +08:00
parent e065c41d6b
commit 3ae0eaa9c1
7 changed files with 612 additions and 212 deletions

View File

@@ -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"

35
rust/build.rs Normal file
View 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子系统为windowsGUI程序不显示控制台
res.set("Subsystem", "windows");
res.compile()
}
#[cfg(not(windows))]
fn main() {
// 非Windows平台不需要特殊处理
}

View File

@@ -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<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())
}
}
}

View File

@@ -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<parking_lot::Mutex<Vec<(String, f64)>>>,
db: Arc<DatabaseManager>,
spider: Arc<SpiderManager>,
analyzer: Arc<Mutex<LLMAnalyzer>>,
state: Arc<AppState>,
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
config_open: bool,
temp_config: Option<config::Config>,
test_status: Arc<Mutex<String>>,
is_testing: Arc<Mutex<bool>>,
}
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<DatabaseManager>,
spider: Arc<SpiderManager>,
analyzer: &mut LLMAnalyzer,
state: Arc<ui::AppState>,
stock_data: Arc<parking_lot::Mutex<Vec<(String, f64)>>>,
analyzer: Arc<Mutex<LLMAnalyzer>>,
state: Arc<AppState>,
stock_data: Arc<Mutex<Vec<(String, f64)>>>,
_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()
};

View File

@@ -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<AtomicBool>,
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 analysis_count: Arc<AtomicI32>,
pub no_content_count: Arc<AtomicI32>,
}
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));
}
}