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:
2026-06-10 12:20:25 +08:00
parent 31161d9a5f
commit 7e256c426f
43 changed files with 8529 additions and 59 deletions

316
src/ui/home.rs Normal file
View 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
View 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
View 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
View 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
View 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 => "ManualWin+Shift+S + 手动框选)",
ScreenshotMode::AutoPrintWindow => "AutoPrintWindow 全自动)",
ScreenshotMode::AutoWithFallback => "Auto + 手动兜底",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::Manual, "ManualWin+Shift+S + 手动框选)");
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::AutoPrintWindow, "AutoPrintWindow 全自动)");
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
View 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 解引用为 &strrfd 才会接受
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();
}
});
}