feat(ui): 安全软件风格主题 + 三阶段进度/日志 + XLSX 支持
- 主面板:阶段1扫描全盘 → 阶段2抽样 → 阶段3抽检,每阶段独立进度条/已用时/分类型 chips - 日志:按类型着色(命中红/未命中绿/警告黄/阶段青) - 主题:暗绿底 + 鲜绿/青色强调,圆角胶囊按钮(material::security_dark) - 抽检:SampleMode 枚举支持按份数/百分比/全部;设置页 C 组动态切换 - 抽检:XLSX 检查器(zip + quick-xml 解析 sharedStrings 与 sheet) - 扫描:walker 进度回调(已访问、命中候选、当前目录) - 兼容:quick-xml 0.36 使用 reader.config_mut().trim_text() - 仓库:新增 .gitignore 忽略 venv/pyc/target/构建产物
This commit is contained in:
316
src/ui/home.rs
Normal file
316
src/ui/home.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
// 主面板:开始检测 + 扫描/抽样/抽检三阶段进度 + 实时日志
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::{App, RunState};
|
||||
use crate::scan::sampler::FileKind;
|
||||
use crate::ui::material;
|
||||
|
||||
/// 主面板
|
||||
pub fn draw(ui: &mut egui::Ui, app: &mut App) {
|
||||
// 1. 主控制
|
||||
material::group(ui, "主控制", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let can_start = matches!(app.state, RunState::Idle | RunState::Done | RunState::Cancelled | RunState::Error(_));
|
||||
let can_cancel = matches!(app.state, RunState::Scanning | RunState::Sampling | RunState::Inspecting | RunState::Reporting);
|
||||
|
||||
if ui.add_enabled(can_start, material::primary_button("▶ 开始检测")).clicked() {
|
||||
start_inspection(app);
|
||||
}
|
||||
if ui.add_enabled(can_cancel, material::danger_button("⏹ 取消")).clicked() {
|
||||
app.cancel_flag.store(true, Ordering::Relaxed);
|
||||
app.state = RunState::Cancelled;
|
||||
}
|
||||
if ui.button("📂 打开报告目录").clicked() {
|
||||
let _ = std::fs::create_dir_all(&app.config.report.output_dir);
|
||||
let _ = open_in_explorer(&app.config.report.output_dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 2. 状态卡片
|
||||
material::group(ui, "当前状态", |ui| {
|
||||
let state_str = match &app.state {
|
||||
RunState::Idle => "● 空闲".to_string(),
|
||||
RunState::Scanning => "🔍 阶段 1/3:扫描全盘候选文件……".to_string(),
|
||||
RunState::Sampling => "🎲 阶段 2/3:抽样决策……".to_string(),
|
||||
RunState::Inspecting => "🔬 阶段 3/3:抽检文件……".to_string(),
|
||||
RunState::Reporting => "📝 正在生成报告……".to_string(),
|
||||
RunState::Done => "✔ 已完成".to_string(),
|
||||
RunState::Cancelled => "⏹ 已取消".to_string(),
|
||||
RunState::Error(e) => format!("✘ 出错:{}", e),
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(&state_str).strong().size(16.0).color(material::PRIMARY_DARK));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let hits = app.hit_count.load(Ordering::Relaxed);
|
||||
let badge = egui::RichText::new(format!("✘ 命中 {} 项", hits))
|
||||
.strong().size(15.0)
|
||||
.color(if hits > 0 { material::DANGER } else { material::ON_SURFACE_DIM });
|
||||
ui.label(badge);
|
||||
});
|
||||
});
|
||||
if let Ok(step) = app.current_step.lock() {
|
||||
if !step.is_empty() {
|
||||
ui.label(egui::RichText::new(&*step).color(material::PRIMARY_DARK));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 3. 阶段 1:扫描进度
|
||||
material::group(ui, "阶段 1/3:扫描全盘候选文件", |ui| {
|
||||
let scanned = app.scan_scanned.load(Ordering::Relaxed);
|
||||
let found = app.scan_found.load(Ordering::Relaxed);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("已访问 {} 个文件", scanned)).strong().size(15.0));
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(format!("命中候选 {} 份", found)).strong().size(15.0).color(material::LIME));
|
||||
});
|
||||
// 不确定进度:扫描中时用动画效果
|
||||
let is_scanning = matches!(app.state, RunState::Scanning);
|
||||
let frac = if is_scanning { 0.0 } else { 1.0 };
|
||||
let pb = egui::ProgressBar::new(frac)
|
||||
.fill(material::PRIMARY)
|
||||
.desired_width(ui.available_width())
|
||||
.desired_height(18.0)
|
||||
.animate(is_scanning);
|
||||
ui.add(pb);
|
||||
|
||||
// 当前目录
|
||||
if let Ok(d) = app.scan_current_dir.lock() {
|
||||
if !d.is_empty() {
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("📁 正在扫描:");
|
||||
ui.label(egui::RichText::new(shorten_path(&d, 90)).monospace().color(material::ON_SURFACE));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 4. 阶段 2/3:抽样结果 + 抽检进度
|
||||
material::group(ui, "阶段 2/3:抽样结果 / 阶段 3/3:抽检", |ui| {
|
||||
let p = app.progress.load(Ordering::Relaxed);
|
||||
let t = app.total.load(Ordering::Relaxed);
|
||||
let frac = if t == 0 { 0.0 } else { p as f32 / t as f32 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(format!("已抽检 {}/{}", p, t)).strong().size(16.0));
|
||||
let elapsed_ms = app.elapsed_ms.load(Ordering::Relaxed);
|
||||
if elapsed_ms > 0 {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(format!("已用时 {}", format_duration(elapsed_ms))).color(material::ON_SURFACE_DIM));
|
||||
});
|
||||
}
|
||||
});
|
||||
let is_inspecting = matches!(app.state, RunState::Inspecting | RunState::Sampling);
|
||||
let pb = egui::ProgressBar::new(frac)
|
||||
.show_percentage()
|
||||
.fill(material::LIME)
|
||||
.desired_width(ui.available_width())
|
||||
.desired_height(20.0)
|
||||
.animate(is_inspecting);
|
||||
ui.add(pb);
|
||||
|
||||
// 类型分项进度(chips)
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let done_map: HashMap<FileKind, usize> = app.type_done.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
let total_map: HashMap<FileKind, usize> = app.type_total.lock().map(|g| g.clone()).unwrap_or_default();
|
||||
for k in FileKind::all() {
|
||||
let done = *done_map.get(&k).unwrap_or(&0);
|
||||
let total = *total_map.get(&k).unwrap_or(&0);
|
||||
if total == 0 && done == 0 { continue; }
|
||||
let color = kind_color(k);
|
||||
let label = format!("{} {}/{}", kind_label(k), done, total);
|
||||
let chip = egui::RichText::new(label).strong().size(13.0).color(material::BACKGROUND);
|
||||
let frame = egui::Frame::none()
|
||||
.fill(color)
|
||||
.rounding(egui::Rounding::same(10.0))
|
||||
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
|
||||
frame.show(ui, |ui| { ui.label(chip); });
|
||||
}
|
||||
});
|
||||
|
||||
// 当前正在处理
|
||||
if let Ok(cur) = app.current_file.lock() {
|
||||
if let Some(f) = cur.as_ref() {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("📄 正在处理:");
|
||||
ui.label(egui::RichText::new(shorten_path(f, 100)).monospace().color(material::PRIMARY_DARK));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(6.0);
|
||||
|
||||
// 5. 实时日志
|
||||
material::group(ui, "实时日志", |ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.stick_to_bottom(true)
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
|
||||
.auto_shrink([false, false])
|
||||
.max_height(380.0)
|
||||
.show(ui, |ui| {
|
||||
if let Ok(lines) = app.log_lines.lock() {
|
||||
for line in lines.iter() {
|
||||
let (color, _) = classify_log(line);
|
||||
ui.label(egui::RichText::new(line).monospace().color(color));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn kind_label(k: FileKind) -> &'static str {
|
||||
match k {
|
||||
FileKind::Doc => "📄 DOC",
|
||||
FileKind::Docx => "📝 DOCX",
|
||||
FileKind::Pdf => "📕 PDF",
|
||||
FileKind::Xlsx => "📊 XLSX",
|
||||
}
|
||||
}
|
||||
fn kind_color(k: FileKind) -> egui::Color32 {
|
||||
match k {
|
||||
FileKind::Doc => egui::Color32::from_rgb(0, 150, 200),
|
||||
FileKind::Docx => material::PRIMARY,
|
||||
FileKind::Pdf => egui::Color32::from_rgb(255, 100, 100),
|
||||
FileKind::Xlsx => material::LIME,
|
||||
}
|
||||
}
|
||||
|
||||
fn shorten_path(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars { return s.to_string(); }
|
||||
let keep = max_chars.saturating_sub(3) / 2;
|
||||
let head: String = s.chars().take(keep).collect();
|
||||
let tail: String = s.chars().rev().take(keep).collect::<String>().chars().rev().collect();
|
||||
format!("{}…{}", head, tail)
|
||||
}
|
||||
|
||||
fn format_duration(ms: u64) -> String {
|
||||
let s = ms / 1000;
|
||||
if s < 60 { format!("{} 秒", s) }
|
||||
else if s < 3600 { format!("{} 分 {} 秒", s / 60, s % 60) }
|
||||
else { format!("{} 时 {} 分", s / 3600, (s % 3600) / 60) }
|
||||
}
|
||||
|
||||
fn classify_log(line: &str) -> (egui::Color32, &'static str) {
|
||||
if line.starts_with("═══") {
|
||||
(material::LIME, "•")
|
||||
} else if line.contains("命中") {
|
||||
(material::DANGER, "✘")
|
||||
} else if line.contains("未命中") || line.contains(" ✔") {
|
||||
(material::SUCCESS, "✔")
|
||||
} else if line.starts_with("⚠") || line.contains("警告") {
|
||||
(material::WARNING, "⚠")
|
||||
} else if line.starts_with("✘") || line.contains("错误") || line.contains("失败") {
|
||||
(material::DANGER, "✘")
|
||||
} else if line.starts_with("→") {
|
||||
(material::PRIMARY_DARK, "→")
|
||||
} else if line.starts_with("🔍") || line.starts_with("🎲") || line.starts_with("📁") || line.starts_with("📝") || line.starts_with("🔬") {
|
||||
(material::PRIMARY_DARK, "•")
|
||||
} else if line.starts_with("✔") {
|
||||
(material::SUCCESS, "✔")
|
||||
} else {
|
||||
(material::ON_SURFACE, "•")
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动后台抽检任务
|
||||
fn start_inspection(app: &mut App) {
|
||||
app.state = RunState::Scanning;
|
||||
app.progress.store(0, Ordering::Relaxed);
|
||||
app.total.store(0, Ordering::Relaxed);
|
||||
app.hit_count.store(0, Ordering::Relaxed);
|
||||
app.cancel_flag.store(false, Ordering::Relaxed);
|
||||
app.elapsed_ms.store(0, Ordering::Relaxed);
|
||||
app.scan_scanned.store(0, Ordering::Relaxed);
|
||||
app.scan_found.store(0, Ordering::Relaxed);
|
||||
if let Ok(mut s) = app.scan_current_dir.lock() { s.clear(); }
|
||||
if let Ok(mut s) = app.current_step.lock() { s.clear(); }
|
||||
if let Ok(mut cur) = app.current_file.lock() { cur.take(); }
|
||||
if let Ok(mut lines) = app.log_lines.lock() { lines.clear(); }
|
||||
if let Ok(mut s) = app.samples.lock() { s.clear(); }
|
||||
if let Ok(mut d) = app.type_done.lock() { d.clear(); }
|
||||
if let Ok(mut t) = app.type_total.lock() { t.clear(); }
|
||||
|
||||
let progress = Arc::clone(&app.progress);
|
||||
let total = Arc::clone(&app.total);
|
||||
let hit_count = Arc::clone(&app.hit_count);
|
||||
let cancel = Arc::clone(&app.cancel_flag);
|
||||
let scan_scanned = Arc::clone(&app.scan_scanned);
|
||||
let scan_found = Arc::clone(&app.scan_found);
|
||||
let scan_dir = Arc::clone(&app.scan_current_dir);
|
||||
let cur_file = Arc::clone(&app.current_file);
|
||||
let cur_step = Arc::clone(&app.current_step);
|
||||
let elapsed_ms = Arc::clone(&app.elapsed_ms);
|
||||
let type_done = Arc::clone(&app.type_done);
|
||||
let type_total = Arc::clone(&app.type_total);
|
||||
let log_lines = Arc::clone(&app.log_lines);
|
||||
let samples = Arc::clone(&app.samples);
|
||||
let report_slot = Arc::clone(&app.report);
|
||||
let state_slot = Arc::new(std::sync::Mutex::new(RunState::Scanning));
|
||||
let state_slot_for_task = Arc::clone(&state_slot);
|
||||
let start_instant = Instant::now();
|
||||
let cfg = app.config.clone();
|
||||
|
||||
app.task_state = Some(state_slot.clone());
|
||||
app.task_log("开始抽检……");
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime");
|
||||
let outcome = rt.block_on(async move {
|
||||
crate::scan::runner::run(
|
||||
cfg,
|
||||
progress,
|
||||
total,
|
||||
hit_count,
|
||||
cancel,
|
||||
scan_scanned,
|
||||
scan_found,
|
||||
scan_dir,
|
||||
cur_file,
|
||||
cur_step,
|
||||
start_instant,
|
||||
elapsed_ms,
|
||||
type_done,
|
||||
type_total,
|
||||
log_lines,
|
||||
samples,
|
||||
report_slot,
|
||||
state_slot_for_task,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tracing::info!("抽检任务结束:{:?}", outcome);
|
||||
});
|
||||
}
|
||||
|
||||
fn open_in_explorer(path: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
205
src/ui/material.rs
Normal file
205
src/ui/material.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// Material/Security 风格:颜色 token + 主题 + 字体安装
|
||||
use eframe::egui;
|
||||
use eframe::egui::{FontData, FontDefinitions, FontFamily};
|
||||
|
||||
use crate::config::Theme;
|
||||
|
||||
// —— 主色:鲜亮绿(深色底上的主色,类似安全软件主色) ——
|
||||
pub const PRIMARY: egui::Color32 = egui::Color32::from_rgb(0, 230, 118); // #00E676 鲜绿
|
||||
pub const PRIMARY_DARK: egui::Color32 = egui::Color32::from_rgb(29, 233, 182); // #1DE9B6 薄荷
|
||||
pub const SECONDARY: egui::Color32 = egui::Color32::from_rgb(0, 176, 255); // #00B0FF 青蓝
|
||||
pub const SUCCESS: egui::Color32 = egui::Color32::from_rgb(0, 200, 83); // #00C853
|
||||
pub const WARNING: egui::Color32 = egui::Color32::from_rgb(255, 171, 0); // #FFAB00
|
||||
pub const DANGER: egui::Color32 = egui::Color32::from_rgb(255, 82, 82); // #FF5252
|
||||
pub const LIME: egui::Color32 = egui::Color32::from_rgb(118, 255, 3); // #76FF03
|
||||
pub const TEAL: egui::Color32 = egui::Color32::from_rgb(0, 191, 165); // #00BFA5
|
||||
// —— 暗色背景 ——
|
||||
pub const SURFACE: egui::Color32 = egui::Color32::from_rgb(8, 40, 22); // #082816 卡片底
|
||||
pub const BACKGROUND: egui::Color32 = egui::Color32::from_rgb(0, 26, 13); // #001A0D 主底
|
||||
pub const BACKGROUND_ALT: egui::Color32 = egui::Color32::from_rgb(0, 46, 26); // #002E1A 渐变次底
|
||||
pub const ON_SURFACE: egui::Color32 = egui::Color32::from_rgb(185, 246, 202); // #B9F6CA 浅绿文字
|
||||
pub const ON_SURFACE_DIM: egui::Color32 = egui::Color32::from_rgb(120, 180, 140); // 暗一档
|
||||
pub const CARD_BORDER: egui::Color32 = egui::Color32::from_rgb(0, 191, 165); // #00BFA5 卡片边
|
||||
|
||||
/// 安装中文字体(如有指定路径或同目录字体文件)
|
||||
pub fn install_fonts(ctx: &egui::Context, font_path: &Option<std::path::PathBuf>) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let mut added = false;
|
||||
|
||||
// 字体候选路径(顺序:用户指定 → exe 同目录 → 系统字体目录)
|
||||
let mut candidates: Vec<(std::path::PathBuf, usize)> = Vec::new();
|
||||
if let Some(p) = font_path.as_ref() {
|
||||
candidates.push((p.clone(), 0));
|
||||
}
|
||||
let exe = crate::utils::paths::exe_dir();
|
||||
for name in [
|
||||
"SourceHanSansSC-Regular.otf",
|
||||
"NotoSansCJKsc-Regular.otf",
|
||||
"msyh.ttc",
|
||||
"simhei.ttf",
|
||||
"simsun.ttc",
|
||||
] {
|
||||
candidates.push((exe.join(name), 0));
|
||||
}
|
||||
// 系统字体目录(C:\Windows\Fonts)
|
||||
let sys_fonts = std::path::PathBuf::from(r"C:\Windows\Fonts");
|
||||
for (name, idx) in [
|
||||
("msyh.ttc", 0usize), // 微软雅黑
|
||||
("msyhbd.ttc", 0), // 微软雅黑 Bold
|
||||
("simhei.ttf", 0), // 黑体
|
||||
("simsun.ttc", 0), // 宋体
|
||||
("simkai.ttf", 0), // 楷体
|
||||
] {
|
||||
candidates.push((sys_fonts.join(name), idx));
|
||||
}
|
||||
|
||||
for (p, idx) in &candidates {
|
||||
match std::fs::read(p) {
|
||||
Ok(data) => {
|
||||
tracing::info!("加载中文字体:{} (index={}, {} bytes)", p.display(), idx, data.len());
|
||||
let fd = if *idx == 0 {
|
||||
FontData::from_owned(data)
|
||||
} else {
|
||||
FontData {
|
||||
font: std::borrow::Cow::Owned(data),
|
||||
index: *idx as u32,
|
||||
tweak: Default::default(),
|
||||
}
|
||||
};
|
||||
fonts.font_data.insert("cn".into(), fd);
|
||||
added = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
if added {
|
||||
if let Some(p) = fonts.families.get_mut(&FontFamily::Proportional) {
|
||||
p.insert(0, "cn".into());
|
||||
}
|
||||
if let Some(p) = fonts.families.get_mut(&FontFamily::Monospace) {
|
||||
p.insert(0, "cn".into());
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("未找到中文字体文件,UI 中文可能显示为方块;可把字体文件放到 exe 同目录或在设置中指定");
|
||||
}
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// 应用主题
|
||||
pub fn apply_theme(ctx: &egui::Context, theme: Theme) {
|
||||
let visuals = match theme {
|
||||
Theme::Light => material_light(),
|
||||
Theme::Dark => security_dark(),
|
||||
Theme::Follow => security_dark(), // 默认走深色安全风格
|
||||
};
|
||||
ctx.set_visuals(visuals);
|
||||
}
|
||||
|
||||
/// Material 风格浅色主题(蓝主色 + 中性灰白底)
|
||||
fn material_light() -> egui::Visuals {
|
||||
let mut v = egui::Visuals::light();
|
||||
v.window_fill = egui::Color32::from_rgb(255, 255, 255);
|
||||
v.panel_fill = egui::Color32::from_rgb(250, 250, 250);
|
||||
v.extreme_bg_color = egui::Color32::from_rgb(240, 244, 250);
|
||||
v.faint_bg_color = egui::Color32::from_rgb(245, 247, 250);
|
||||
v.widgets.noninteractive.bg_fill = egui::Color32::from_rgb(245, 247, 250);
|
||||
v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 224, 230));
|
||||
v.widgets.inactive.bg_fill = egui::Color32::from_rgb(250, 250, 250);
|
||||
v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 224, 230));
|
||||
v.widgets.hovered.bg_fill = egui::Color32::from_rgb(235, 243, 255);
|
||||
v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, PRIMARY);
|
||||
v.widgets.active.bg_fill = egui::Color32::from_rgb(205, 230, 255);
|
||||
v.widgets.active.bg_stroke = egui::Stroke::new(1.5, PRIMARY_DARK);
|
||||
v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 70));
|
||||
v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(50, 50, 60));
|
||||
v.selection.bg_fill = egui::Color32::from_rgb(180, 215, 255);
|
||||
v.selection.stroke = egui::Stroke::new(1.0, PRIMARY);
|
||||
v.hyperlink_color = PRIMARY;
|
||||
v.widgets.noninteractive.rounding = egui::Rounding::same(6.0);
|
||||
v.widgets.inactive.rounding = egui::Rounding::same(6.0);
|
||||
v.widgets.hovered.rounding = egui::Rounding::same(6.0);
|
||||
v.widgets.active.rounding = egui::Rounding::same(6.0);
|
||||
v.window_rounding = egui::Rounding::same(8.0);
|
||||
v
|
||||
}
|
||||
|
||||
/// 安全软件风格深色主题:暗绿底 + 鲜绿/青色强调
|
||||
fn security_dark() -> egui::Visuals {
|
||||
let mut v = egui::Visuals::dark();
|
||||
// 整体色调:暗绿
|
||||
v.window_fill = BACKGROUND;
|
||||
v.panel_fill = BACKGROUND_ALT;
|
||||
v.extreme_bg_color = egui::Color32::from_rgb(0, 18, 8);
|
||||
v.faint_bg_color = SURFACE;
|
||||
|
||||
// 控件底色(卡片用 SURFACE,控件用略亮)
|
||||
v.widgets.noninteractive.bg_fill = SURFACE;
|
||||
v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, CARD_BORDER);
|
||||
v.widgets.inactive.bg_fill = egui::Color32::from_rgb(12, 50, 28);
|
||||
v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 150, 130));
|
||||
v.widgets.hovered.bg_fill = egui::Color32::from_rgb(20, 90, 50);
|
||||
v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, PRIMARY);
|
||||
v.widgets.active.bg_fill = egui::Color32::from_rgb(0, 120, 70);
|
||||
v.widgets.active.bg_stroke = egui::Stroke::new(1.5, LIME);
|
||||
|
||||
// 文字色
|
||||
v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, ON_SURFACE);
|
||||
v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, ON_SURFACE);
|
||||
v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||
v.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
|
||||
|
||||
// 文本默认色
|
||||
v.override_text_color = Some(ON_SURFACE);
|
||||
|
||||
// 选中色
|
||||
v.selection.bg_fill = egui::Color32::from_rgb(0, 150, 80);
|
||||
v.selection.stroke = egui::Stroke::new(1.0, LIME);
|
||||
|
||||
// 链接
|
||||
v.hyperlink_color = PRIMARY_DARK;
|
||||
|
||||
// 圆角
|
||||
v.widgets.noninteractive.rounding = egui::Rounding::same(8.0);
|
||||
v.widgets.inactive.rounding = egui::Rounding::same(8.0);
|
||||
v.widgets.hovered.rounding = egui::Rounding::same(8.0);
|
||||
v.widgets.active.rounding = egui::Rounding::same(8.0);
|
||||
|
||||
v.window_rounding = egui::Rounding::same(10.0);
|
||||
|
||||
v
|
||||
}
|
||||
|
||||
/// 创建一个安全软件风格主按钮(绿渐变 + 圆角胶囊)
|
||||
pub fn primary_button(text: &str) -> egui::Button<'_> {
|
||||
egui::Button::new(egui::RichText::new(text).color(BACKGROUND).strong().size(15.0))
|
||||
.fill(PRIMARY)
|
||||
.stroke(egui::Stroke::new(1.0, LIME))
|
||||
.rounding(egui::Rounding::same(18.0))
|
||||
.min_size(egui::vec2(120.0, 36.0))
|
||||
}
|
||||
|
||||
/// 创建一个危险按钮(红色)
|
||||
pub fn danger_button(text: &str) -> egui::Button<'_> {
|
||||
egui::Button::new(egui::RichText::new(text).color(egui::Color32::WHITE).strong())
|
||||
.fill(DANGER)
|
||||
.rounding(egui::Rounding::same(18.0))
|
||||
.min_size(egui::vec2(100.0, 36.0))
|
||||
}
|
||||
|
||||
/// 一个安全软件风格分组面板:暗底 + 青色边
|
||||
pub fn group<R>(ui: &mut egui::Ui, title: &str, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
egui::Frame::group(ui.style())
|
||||
.rounding(egui::Rounding::same(10.0))
|
||||
.inner_margin(egui::Margin::same(14.0))
|
||||
.fill(SURFACE)
|
||||
.stroke(egui::Stroke::new(1.0, CARD_BORDER))
|
||||
.show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(title).strong().size(15.0).color(PRIMARY_DARK));
|
||||
ui.add_space(6.0);
|
||||
add_contents(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
6
src/ui/mod.rs
Normal file
6
src/ui/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// UI 模块
|
||||
pub mod home;
|
||||
pub mod material;
|
||||
pub mod report;
|
||||
pub mod settings;
|
||||
pub mod widgets;
|
||||
40
src/ui/report.rs
Normal file
40
src/ui/report.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
// 报告查看页
|
||||
use eframe::egui;
|
||||
|
||||
use crate::report::model::Report;
|
||||
use crate::ui::material;
|
||||
|
||||
pub fn draw(ui: &mut egui::Ui, r: &Report) {
|
||||
material::group(ui, "本次抽检", |ui| {
|
||||
ui.label(format!("检查时间:{} ~ {}", r.started_at, r.finished_at));
|
||||
ui.label(format!("抽检:{},命中:{}", r.total, r.hit));
|
||||
ui.label(format!("机器:{},用户:{}", r.machine, r.user));
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
material::group(ui, "命中清单", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
egui::Grid::new("findings").striped(true).show(ui, |ui| {
|
||||
ui.strong("文件");
|
||||
ui.strong("类型");
|
||||
ui.strong("命中关键词");
|
||||
ui.strong("置信度");
|
||||
ui.strong("截图");
|
||||
ui.end_row();
|
||||
for f in &r.findings {
|
||||
ui.label(f.path.display().to_string());
|
||||
ui.label(&f.kind);
|
||||
ui.label(f.matched.join(", "));
|
||||
ui.label(format!("{:.2}", f.confidence));
|
||||
if let Some(s) = &f.screenshot {
|
||||
ui.label(s.display().to_string());
|
||||
} else {
|
||||
ui.label("-");
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
314
src/ui/settings.rs
Normal file
314
src/ui/settings.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
// 设置页:A-F 六分组
|
||||
use eframe::egui;
|
||||
|
||||
use crate::config::{AppConfig, KeywordSettings, LogLevel, ReportFormat, SampleMode, SampleStrategy, ScreenshotMode, Theme};
|
||||
use crate::ui::{material, widgets};
|
||||
|
||||
/// 绘制设置页
|
||||
pub fn draw(ui: &mut egui::Ui, cfg: &mut AppConfig) {
|
||||
egui::ScrollArea::vertical()
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
group_a_general(ui, &mut cfg.general);
|
||||
ui.add_space(8.0);
|
||||
group_b_scan(ui, &mut cfg.scan);
|
||||
ui.add_space(8.0);
|
||||
group_c_inspect(ui, &mut cfg.inspect);
|
||||
ui.add_space(8.0);
|
||||
group_d_viewer(ui, &mut cfg.viewer);
|
||||
ui.add_space(8.0);
|
||||
group_e_keyword(ui, &mut cfg.keyword);
|
||||
ui.add_space(8.0);
|
||||
group_f_report(ui, &mut cfg.report);
|
||||
ui.add_space(8.0);
|
||||
material::group(ui, "操作", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("💾 立即保存").clicked() {
|
||||
if let Err(e) = cfg.save() {
|
||||
tracing::warn!("保存失败:{}", e);
|
||||
}
|
||||
}
|
||||
if ui.button("🔄 恢复默认").clicked() {
|
||||
*cfg = AppConfig::default();
|
||||
}
|
||||
if ui.button("📂 打开配置文件").clicked() {
|
||||
let path = crate::config::config_path();
|
||||
let _ = std::fs::create_dir_all(path.parent().unwrap());
|
||||
let _ = open_in_explorer(&path);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(20.0); // 底部留白,避免被裁切
|
||||
});
|
||||
}
|
||||
|
||||
fn group_a_general(ui: &mut egui::Ui, g: &mut crate::config::GeneralSettings) {
|
||||
material::group(ui, "A. 常规", |ui| {
|
||||
ui.checkbox(&mut g.auto_run, "启动时自动开始检测");
|
||||
ui.checkbox(&mut g.start_minimized, "启动时最小化到托盘");
|
||||
ui.checkbox(&mut g.auto_start, "开机自启");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("主题:");
|
||||
egui::ComboBox::from_id_source("theme")
|
||||
.selected_text(match g.theme { Theme::Light => "Light", Theme::Dark => "Dark", Theme::Follow => "Follow System" })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut g.theme, Theme::Light, "Light");
|
||||
ui.selectable_value(&mut g.theme, Theme::Dark, "Dark");
|
||||
ui.selectable_value(&mut g.theme, Theme::Follow, "Follow System");
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("日志级别:");
|
||||
egui::ComboBox::from_id_source("log_level")
|
||||
.selected_text(format!("{:?}", g.log_level))
|
||||
.show_ui(ui, |ui| {
|
||||
for l in [LogLevel::Error, LogLevel::Warn, LogLevel::Info, LogLevel::Debug, LogLevel::Trace] {
|
||||
ui.selectable_value(&mut g.log_level, l, format!("{:?}", l));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("日志保留天数:");
|
||||
ui.add(egui::DragValue::new(&mut g.log_retention_days).clamp_range(1..=365));
|
||||
});
|
||||
ui.checkbox(&mut g.single_instance, "单实例锁");
|
||||
ui.checkbox(&mut g.clear_temp_on_start, "启动时清空临时目录");
|
||||
ui.checkbox(&mut g.auto_exit, "检查完自动退出");
|
||||
if g.auto_exit {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("倒计时(秒):");
|
||||
ui.add(egui::DragValue::new(&mut g.auto_exit_seconds).clamp_range(1..=600));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn group_b_scan(ui: &mut egui::Ui, s: &mut crate::config::ScanSettings) {
|
||||
material::group(ui, "B. 扫描范围(默认全盘 + 白名单 = 不扫)", |ui| {
|
||||
ui.label("白名单目录(不扫描):");
|
||||
// 先把 s.whitelist 转 Vec<String> 给 string_list_editor
|
||||
let mut wl_strs: Vec<String> = s.whitelist.iter().map(|p| p.display().to_string()).collect();
|
||||
widgets::string_list_editor(ui, &mut wl_strs, r"例如 C:\Windows");
|
||||
// 写回
|
||||
s.whitelist = wl_strs.iter().map(|x| std::path::PathBuf::from(x)).collect();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("➕ 系统目录").clicked() {
|
||||
s.whitelist.push(std::path::PathBuf::from(r"C:\Windows"));
|
||||
}
|
||||
if ui.button("➕ Program Files").clicked() {
|
||||
s.whitelist.push(std::path::PathBuf::from(r"C:\Program Files"));
|
||||
}
|
||||
if ui.button("➕ ProgramData").clicked() {
|
||||
s.whitelist.push(std::path::PathBuf::from(r"C:\ProgramData"));
|
||||
}
|
||||
if ui.button("➕ 回收站").clicked() {
|
||||
s.whitelist.push(std::path::PathBuf::from(r"C:\$Recycle.Bin"));
|
||||
}
|
||||
});
|
||||
// 把编辑框的字符串结果写回 s.whitelist
|
||||
// (简化:用 text 列表同步一次 —— 实际可用 table state;这里临时方案)
|
||||
// 提示:上面的 string_list_editor 改的是临时 Vec,需要保留后再写回。
|
||||
// 简单起见:使用一个固定 placeholder
|
||||
ui.checkbox(&mut s.include_hidden, "包含隐藏文件");
|
||||
ui.checkbox(&mut s.include_system, "包含系统文件");
|
||||
ui.checkbox(&mut s.follow_symlinks, "跟随符号链接");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("文件最小大小 KB:");
|
||||
ui.add(egui::DragValue::new(&mut s.min_size_kb).clamp_range(0..=1024 * 1024));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("最大遍历深度(0=无限):");
|
||||
ui.add(egui::DragValue::new(&mut s.max_depth).clamp_range(0..=1000));
|
||||
});
|
||||
ui.label("扩展名白名单(不含点,逗号分隔):");
|
||||
let mut ext_str = s.extensions.join(",");
|
||||
if ui.text_edit_singleline(&mut ext_str).changed() {
|
||||
s.extensions = ext_str.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("单次扫描超时(分钟,0=无限):");
|
||||
ui.add(egui::DragValue::new(&mut s.scan_timeout_minutes).clamp_range(0..=24 * 60));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn group_c_inspect(ui: &mut egui::Ui, i: &mut crate::config::InspectSettings) {
|
||||
material::group(ui, "C. 抽检(先扫全盘,再按下方配置抽样检查)", |ui| {
|
||||
// 抽样数量模式
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("抽样模式:");
|
||||
egui::ComboBox::from_id_source("sample_mode")
|
||||
.selected_text(match i.sample_mode {
|
||||
SampleMode::Count => "📦 按份数",
|
||||
SampleMode::Percent => "📊 按百分比",
|
||||
SampleMode::All => "🌐 全部",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut i.sample_mode, SampleMode::Count, "📦 按份数");
|
||||
ui.selectable_value(&mut i.sample_mode, SampleMode::Percent, "📊 按百分比");
|
||||
ui.selectable_value(&mut i.sample_mode, SampleMode::All, "🌐 全部");
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
match i.sample_mode {
|
||||
SampleMode::Count => {
|
||||
ui.label("抽检份数:");
|
||||
ui.add(egui::DragValue::new(&mut i.sample_count).clamp_range(1..=100_000));
|
||||
}
|
||||
SampleMode::Percent => {
|
||||
ui.label("百分比:");
|
||||
ui.add(egui::DragValue::new(&mut i.sample_percent).clamp_range(0.1..=100.0).speed(0.5));
|
||||
ui.label("%");
|
||||
}
|
||||
SampleMode::All => { ui.label("(将抽检全部候选文件)"); }
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("抽样策略:");
|
||||
egui::ComboBox::from_id_source("strategy")
|
||||
.selected_text(match i.strategy { SampleStrategy::Random => "完全随机", SampleStrategy::Stratified => "分层", SampleStrategy::Quota => "类型配额" })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut i.strategy, SampleStrategy::Random, "完全随机");
|
||||
ui.selectable_value(&mut i.strategy, SampleStrategy::Stratified, "分层");
|
||||
ui.selectable_value(&mut i.strategy, SampleStrategy::Quota, "类型配额");
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("DOC 配额:");
|
||||
ui.add(egui::DragValue::new(&mut i.doc_quota).clamp_range(0..=100_000));
|
||||
ui.label("DOCX 配额:");
|
||||
ui.add(egui::DragValue::new(&mut i.docx_quota).clamp_range(0..=100_000));
|
||||
ui.label("PDF 配额:");
|
||||
ui.add(egui::DragValue::new(&mut i.pdf_quota).clamp_range(0..=100_000));
|
||||
ui.label("XLSX 配额:");
|
||||
ui.add(egui::DragValue::new(&mut i.xlsx_quota).clamp_range(0..=100_000));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("单文件总超时(秒):");
|
||||
ui.add(egui::DragValue::new(&mut i.per_file_timeout_sec).clamp_range(10..=3600));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("同文件 N 天内不重复:");
|
||||
ui.add(egui::DragValue::new(&mut i.dedup_days).clamp_range(0..=365));
|
||||
});
|
||||
ui.checkbox(&mut i.skip_locked, "跳过锁定/无权限文件");
|
||||
ui.checkbox(&mut i.stop_on_first_hit, "命中后停止后续抽检");
|
||||
});
|
||||
}
|
||||
|
||||
fn group_d_viewer(ui: &mut egui::Ui, v: &mut crate::config::ViewerSettings) {
|
||||
material::group(ui, "D. 查看器与截图", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("截图模式:");
|
||||
egui::ComboBox::from_id_source("screenshot_mode")
|
||||
.selected_text(match v.screenshot_mode {
|
||||
ScreenshotMode::Manual => "Manual(Win+Shift+S + 手动框选)",
|
||||
ScreenshotMode::AutoPrintWindow => "Auto(PrintWindow 全自动)",
|
||||
ScreenshotMode::AutoWithFallback => "Auto + 手动兜底",
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::Manual, "Manual(Win+Shift+S + 手动框选)");
|
||||
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::AutoPrintWindow, "Auto(PrintWindow 全自动)");
|
||||
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::AutoWithFallback, "Auto + 手动兜底");
|
||||
});
|
||||
});
|
||||
widgets::file_picker(ui, ".doc 查看器(默认 exe 同目录 doclite.exe)", &mut v.doc_viewer, &["exe"]);
|
||||
ui.horizontal(|ui| { ui.label("启动参数模板:"); ui.text_edit_singleline(&mut v.doc_args); });
|
||||
widgets::file_picker(ui, ".pdf 查看器(默认 Windows 关联)", &mut v.pdf_viewer, &["exe"]);
|
||||
ui.horizontal(|ui| { ui.label("启动参数模板:"); ui.text_edit_singleline(&mut v.pdf_args); });
|
||||
ui.horizontal(|ui| { ui.label("截图前等待时间 ms:"); ui.add(egui::DragValue::new(&mut v.pre_capture_wait_ms).clamp_range(0..=30_000)); });
|
||||
ui.horizontal(|ui| { ui.label("手动截图超时秒:"); ui.add(egui::DragValue::new(&mut v.manual_capture_timeout_sec).clamp_range(5..=600)); });
|
||||
ui.horizontal(|ui| { ui.label("自动模式空位图判定阈值 (max_black_ratio):"); ui.add(egui::DragValue::new(&mut v.max_black_ratio).clamp_range(0.0..=1.0)); });
|
||||
ui.checkbox(&mut v.auto_close_after, "截图后自动关闭查看器");
|
||||
ui.horizontal(|ui| { ui.label("关闭等待 ms:"); ui.add(egui::DragValue::new(&mut v.close_wait_ms).clamp_range(0..=30_000)); });
|
||||
ui.horizontal(|ui| { ui.label("强杀超时 ms:"); ui.add(egui::DragValue::new(&mut v.kill_timeout_ms).clamp_range(0..=30_000)); });
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Umi-OCR").strong());
|
||||
ui.horizontal(|ui| { ui.label("HTTP 地址:"); ui.text_edit_singleline(&mut v.umi_ocr_url); });
|
||||
widgets::file_picker(ui, "Umi-OCR.exe 路径(默认 exe 同目录)", &mut v.umi_ocr_exe, &["exe"]);
|
||||
ui.horizontal(|ui| { ui.label("启动后等待秒:"); ui.add(egui::DragValue::new(&mut v.umi_ocr_startup_wait_sec).clamp_range(0..=60)); });
|
||||
ui.horizontal(|ui| { ui.label("调用超时秒:"); ui.add(egui::DragValue::new(&mut v.umi_ocr_call_timeout_sec).clamp_range(5..=600)); });
|
||||
ui.horizontal(|ui| { ui.label("OCR 语言:"); ui.text_edit_singleline(&mut v.ocr_language); });
|
||||
ui.checkbox(&mut v.ocr_cls, "启用文本方向校正");
|
||||
ui.horizontal(|ui| { ui.label("限制边长 px:"); ui.add(egui::DragValue::new(&mut v.ocr_limit_side_len).clamp_range(0..=99999)); });
|
||||
});
|
||||
}
|
||||
|
||||
fn group_e_keyword(ui: &mut egui::Ui, k: &mut KeywordSettings) {
|
||||
material::group(ui, "E. 关键词", |ui| {
|
||||
ui.label("全局关键词(每行一个):");
|
||||
widgets::string_list_editor(ui, &mut k.global, "新增关键词");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("📥 导入 txt").clicked() {
|
||||
if let Some(p) = rfd::FileDialog::new().add_filter("text", &["txt"]).pick_file() {
|
||||
if let Ok(s) = std::fs::read_to_string(&p) {
|
||||
k.global = s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("📤 导出 txt").clicked() {
|
||||
if let Some(p) = rfd::FileDialog::new().add_filter("text", &["txt"]).save_file() {
|
||||
let _ = std::fs::write(p, k.global.join("\n"));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut k.case_sensitive, "区分大小写");
|
||||
ui.checkbox(&mut k.use_regex, "使用正则");
|
||||
ui.checkbox(&mut k.whole_word, "整词匹配");
|
||||
ui.label("DOC 追加关键词:");
|
||||
widgets::string_list_editor(ui, &mut k.doc_extra, "新增");
|
||||
ui.label("DOCX 追加关键词:");
|
||||
widgets::string_list_editor(ui, &mut k.docx_extra, "新增");
|
||||
ui.label("PDF 追加关键词:");
|
||||
widgets::string_list_editor(ui, &mut k.pdf_extra, "新增");
|
||||
ui.label("XLSX 追加关键词:");
|
||||
widgets::string_list_editor(ui, &mut k.xlsx_extra, "新增");
|
||||
ui.horizontal(|ui| { ui.label("最低置信度:"); ui.add(egui::DragValue::new(&mut k.min_confidence).clamp_range(0.0..=1.0)); });
|
||||
ui.label("误报白名单指纹(每行一个,可清除):");
|
||||
widgets::string_list_editor(ui, &mut k.false_positive_fingerprints, "新增");
|
||||
});
|
||||
}
|
||||
|
||||
fn group_f_report(ui: &mut egui::Ui, r: &mut crate::config::ReportSettings) {
|
||||
material::group(ui, "F. 报告", |ui| {
|
||||
widgets::folder_picker(ui, "输出目录:", &mut Some(r.output_dir.clone()));
|
||||
ui.label("输出格式:");
|
||||
ui.horizontal(|ui| {
|
||||
// 用三个 bool 缓冲切换 Vec<ReportFormat> 的成员
|
||||
let mut has_html = r.formats.contains(&ReportFormat::Html);
|
||||
let mut has_json = r.formats.contains(&ReportFormat::Json);
|
||||
let mut has_png = r.formats.contains(&ReportFormat::Png);
|
||||
if ui.checkbox(&mut has_html, "HTML").changed() {
|
||||
toggle_format(&mut r.formats, ReportFormat::Html, has_html);
|
||||
}
|
||||
if ui.checkbox(&mut has_json, "JSON").changed() {
|
||||
toggle_format(&mut r.formats, ReportFormat::Json, has_json);
|
||||
}
|
||||
if ui.checkbox(&mut has_png, "PNG 组图").changed() {
|
||||
toggle_format(&mut r.formats, ReportFormat::Png, has_png);
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| { ui.label("文件名前缀:"); ui.text_edit_singleline(&mut r.file_prefix); });
|
||||
ui.checkbox(&mut r.include_screenshots, "包含截图");
|
||||
ui.checkbox(&mut r.highlight_sensitive, "敏感词高亮");
|
||||
ui.horizontal(|ui| { ui.label("截图最大边长 px:"); ui.add(egui::DragValue::new(&mut r.max_screenshot_side).clamp_range(320..=8192)); });
|
||||
ui.horizontal(|ui| { ui.label("历史保留条数:"); ui.add(egui::DragValue::new(&mut r.history_keep).clamp_range(1..=1000)); });
|
||||
ui.checkbox(&mut r.auto_open, "检查完自动打开报告");
|
||||
ui.checkbox(&mut r.copy_summary_to_clipboard, "复制摘要到剪贴板");
|
||||
});
|
||||
}
|
||||
|
||||
fn open_in_explorer(path: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(windows)]
|
||||
{ std::process::Command::new("explorer").arg(path).spawn().map(|_| ()) }
|
||||
#[cfg(not(windows))]
|
||||
{ let _ = path; Ok(()) }
|
||||
}
|
||||
|
||||
/// 切换 formats 列表中的某一项
|
||||
fn toggle_format(list: &mut Vec<ReportFormat>, fmt: ReportFormat, on: bool) {
|
||||
let has = list.contains(&fmt);
|
||||
if on && !has { list.push(fmt); }
|
||||
if !on && has { list.retain(|f| *f != fmt); }
|
||||
}
|
||||
68
src/ui/widgets.rs
Normal file
68
src/ui/widgets.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
// 通用 widget:路径选择器、列表编辑器等
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
/// 文件夹选择按钮:点击后调用 native picker;选完后把路径写入 `target`
|
||||
pub fn folder_picker(ui: &mut egui::Ui, label: &str, target: &mut Option<PathBuf>) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let display = target.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "<未选择>".to_string());
|
||||
ui.label(egui::RichText::new(&display).monospace());
|
||||
if ui.button("📁").clicked() {
|
||||
if let Some(p) = rfd::FileDialog::new().pick_folder() {
|
||||
*target = Some(p);
|
||||
}
|
||||
}
|
||||
if ui.button("清除").clicked() {
|
||||
*target = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 文件选择按钮
|
||||
pub fn file_picker(ui: &mut egui::Ui, label: &str, target: &mut Option<PathBuf>, extensions: &[&str]) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let display = target.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "<未选择>".to_string());
|
||||
ui.label(egui::RichText::new(&display).monospace());
|
||||
if ui.button("📄").clicked() {
|
||||
let mut d = rfd::FileDialog::new();
|
||||
for ext in extensions {
|
||||
// 把 &&str 解引用为 &str,rfd 才会接受
|
||||
d = d.add_filter(*ext, &[*ext]);
|
||||
}
|
||||
if let Some(p) = d.pick_file() {
|
||||
*target = Some(p);
|
||||
}
|
||||
}
|
||||
if ui.button("清除").clicked() {
|
||||
*target = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 列表编辑器:每一项是 String,下方一个添加按钮、一个删除按钮
|
||||
pub fn string_list_editor(ui: &mut egui::Ui, items: &mut Vec<String>, hint: &str) {
|
||||
let mut to_remove: Option<usize> = None;
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{}.", i + 1));
|
||||
ui.text_edit_singleline(item);
|
||||
if ui.button("✕").clicked() {
|
||||
to_remove = Some(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(i) = to_remove {
|
||||
items.remove(i);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("➕ 添加").clicked() {
|
||||
items.push(hint.to_string());
|
||||
}
|
||||
if ui.button("🗑 清空").clicked() {
|
||||
items.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user