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:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Rust 构建产物
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
|
||||
# 虚拟环境(uv / venv / conda / python venv 等)
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.python-version
|
||||
__pypackages__/
|
||||
|
||||
# 编译/缓存目录
|
||||
.cargo/
|
||||
.maturin/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Python 字节码与缓存(pyc / __pycache__)
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
|
||||
# 运行时产物
|
||||
build.log
|
||||
secret-file-selfcheck.exe
|
||||
reports/
|
||||
|
||||
# IDE / 编辑器
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
5043
Cargo.lock
generated
Normal file
5043
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
Cargo.toml
Normal file
66
Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "secret-file-selfcheck"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "磁盘文档涉密抽检工具(egui + Umi-OCR)"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.27", default-features = false, features = ["default_fonts", "glow", "persistence"] }
|
||||
egui = "0.27"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
docx-rs = "0.4"
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "0.1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking"] }
|
||||
base64 = "0.22"
|
||||
sysinfo = "0.31"
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_DataExchange",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_Storage_Xps",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_System_Registry",
|
||||
"Win32_Security",
|
||||
"Win32_Foundation",
|
||||
] }
|
||||
sys-locale = "0.3"
|
||||
dirs = "5"
|
||||
walkdir = "2"
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
sha2 = "0.10"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
once_cell = "1"
|
||||
log = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
|
||||
handlebars = "5"
|
||||
url = "2"
|
||||
rand = "0.8"
|
||||
rfd = "0.14"
|
||||
zip = "2"
|
||||
quick-xml = "0.36"
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -2,6 +2,6 @@
|
||||
"i18n": "zh_CN",
|
||||
"opengl": "AA_UseOpenGLES",
|
||||
"server_port": 1224,
|
||||
"last_pid": 27276,
|
||||
"last_ptime": "1759219775.7634187"
|
||||
"last_pid": 29620,
|
||||
"last_ptime": "1780969865.5003738"
|
||||
}
|
||||
@@ -35,7 +35,7 @@ window.barIsLock=false
|
||||
window.closeWin2Hide=true
|
||||
window.hideTrayIcon=false
|
||||
window.simpleNotificationType=inside
|
||||
window.geometry="1320,450,800,500"
|
||||
window.geometry="0,23,3440,1377"
|
||||
window.messageMemory=@Variant(\0\0\0\x7f\0\0\0\tQJSValue\0\0\0\0\0\0\0\0\t\0\0\0\0)
|
||||
window.doubleLayout=@Variant(\0\0\0\x7f\0\0\0\tQJSValue\0\0\0\0\0\0\0\0\b\0\0\0\0)
|
||||
screenshot.hideWindow=true
|
||||
|
||||
5
build.rs
Normal file
5
build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
// 隐藏控制台(与 src/main.rs 顶部的 #![windows_subsystem = "windows"] 配合)
|
||||
// MinGW ld 语法:用 -Wl,--subsystem,windows
|
||||
println!("cargo:rustc-link-arg=-Wl,--subsystem,windows");
|
||||
}
|
||||
216
src/app.rs
Normal file
216
src/app.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
// 顶层 eframe App:承载主面板 + 设置页 + 报告页
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::sampler::SampleItem;
|
||||
use crate::report::model::Report;
|
||||
use crate::ui::{home, material, settings};
|
||||
|
||||
/// 运行状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RunState {
|
||||
Idle,
|
||||
Scanning,
|
||||
Sampling,
|
||||
Inspecting,
|
||||
Reporting,
|
||||
Done,
|
||||
Cancelled,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// App 状态
|
||||
pub struct App {
|
||||
pub config: AppConfig,
|
||||
pub state: RunState,
|
||||
/// 抽检进度(0..total)
|
||||
pub progress: Arc<AtomicUsize>,
|
||||
pub total: Arc<AtomicUsize>,
|
||||
pub hit_count: Arc<AtomicUsize>,
|
||||
pub cancel_flag: Arc<AtomicBool>,
|
||||
/// 扫描阶段进度
|
||||
pub scan_scanned: Arc<AtomicUsize>, // 已访问文件数
|
||||
pub scan_found: Arc<AtomicUsize>, // 候选累计
|
||||
pub scan_current_dir: Arc<std::sync::Mutex<String>>, // 当前目录
|
||||
/// 当前正在处理的文件
|
||||
pub current_file: Arc<std::sync::Mutex<Option<String>>>,
|
||||
pub current_step: Arc<std::sync::Mutex<String>>,
|
||||
pub elapsed_ms: Arc<AtomicU64>,
|
||||
/// 每类文件抽检计数(按 FileKind 累计 done / total)
|
||||
pub type_done: Arc<std::sync::Mutex<std::collections::HashMap<crate::scan::sampler::FileKind, usize>>>,
|
||||
pub type_total: Arc<std::sync::Mutex<std::collections::HashMap<crate::scan::sampler::FileKind, usize>>>,
|
||||
pub log_lines: Arc<std::sync::Mutex<Vec<String>>>,
|
||||
pub samples: Arc<std::sync::Mutex<Vec<crate::scan::sampler::SampleItem>>>,
|
||||
pub report: Arc<std::sync::Mutex<Option<Report>>>,
|
||||
pub show_settings: bool,
|
||||
pub show_report: bool,
|
||||
/// 后台任务运行状态(由 home.rs 在 start_inspection 时设置)
|
||||
pub task_state: Option<Arc<std::sync::Mutex<RunState>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
// 加载配置
|
||||
let config = AppConfig::load().unwrap_or_default();
|
||||
// 应用中文字体
|
||||
material::install_fonts(&cc.egui_ctx, &config.ui.font_path);
|
||||
// 应用 Material 主题(theme 字段位于 general 分组下)
|
||||
material::apply_theme(&cc.egui_ctx, config.general.theme);
|
||||
|
||||
Self {
|
||||
config,
|
||||
state: RunState::Idle,
|
||||
progress: Arc::new(AtomicUsize::new(0)),
|
||||
total: Arc::new(AtomicUsize::new(0)),
|
||||
hit_count: Arc::new(AtomicUsize::new(0)),
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
scan_scanned: Arc::new(AtomicUsize::new(0)),
|
||||
scan_found: Arc::new(AtomicUsize::new(0)),
|
||||
scan_current_dir: Arc::new(std::sync::Mutex::new(String::new())),
|
||||
current_file: Arc::new(std::sync::Mutex::new(None)),
|
||||
current_step: Arc::new(std::sync::Mutex::new(String::new())),
|
||||
elapsed_ms: Arc::new(AtomicU64::new(0)),
|
||||
type_done: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
type_total: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
log_lines: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
samples: Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
report: Arc::new(std::sync::Mutex::new(None)),
|
||||
show_settings: false,
|
||||
show_report: false,
|
||||
task_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入一条日志(同时同步到 UI 共享缓冲)
|
||||
pub fn task_log(&self, s: &str) {
|
||||
tracing::info!("{}", s);
|
||||
if let Ok(mut g) = self.log_lines.lock() {
|
||||
g.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// 轮询后台任务状态
|
||||
if let Some(ts) = &self.task_state {
|
||||
if let Ok(g) = ts.lock() {
|
||||
if *g != self.state {
|
||||
self.state = g.clone();
|
||||
}
|
||||
}
|
||||
// 任务结束则清空 task_state
|
||||
if matches!(self.state, RunState::Done | RunState::Cancelled) {
|
||||
self.task_state = None;
|
||||
}
|
||||
}
|
||||
// 顶栏
|
||||
egui::TopBottomPanel::top("topbar")
|
||||
.frame(egui::Frame::none()
|
||||
.fill(material::BACKGROUND_ALT)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 8.0))
|
||||
.stroke(egui::Stroke::new(1.0, material::CARD_BORDER)))
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("🛡 涉密文件自检工具")
|
||||
.strong()
|
||||
.size(20.0)
|
||||
.color(material::PRIMARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("⚙ 设置").clicked() {
|
||||
self.show_settings = !self.show_settings;
|
||||
self.show_report = false;
|
||||
}
|
||||
if ui.button("📋 报告").clicked() {
|
||||
self.show_report = !self.show_report;
|
||||
self.show_settings = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 底部状态栏
|
||||
egui::TopBottomPanel::bottom("statusbar")
|
||||
.frame(egui::Frame::none()
|
||||
.fill(material::BACKGROUND_ALT)
|
||||
.inner_margin(egui::Margin::symmetric(12.0, 6.0))
|
||||
.stroke(egui::Stroke::new(1.0, material::CARD_BORDER)))
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let state_str = match &self.state {
|
||||
crate::app::RunState::Idle => "● 空闲".to_string(),
|
||||
crate::app::RunState::Scanning => "🔍 阶段 1/3:扫描中……".to_string(),
|
||||
crate::app::RunState::Sampling => "🎲 阶段 2/3:抽样……".to_string(),
|
||||
crate::app::RunState::Inspecting => "🔬 阶段 3/3:抽检中……".to_string(),
|
||||
crate::app::RunState::Reporting => "📝 正在生成报告……".to_string(),
|
||||
crate::app::RunState::Done => "✔ 已完成".to_string(),
|
||||
crate::app::RunState::Cancelled => "⏹ 已取消".to_string(),
|
||||
crate::app::RunState::Error(e) => format!("✘ 出错:{}", e),
|
||||
};
|
||||
ui.label(egui::RichText::new(state_str).strong().color(material::PRIMARY_DARK));
|
||||
ui.separator();
|
||||
let p = self.progress.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let t = self.total.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let hits = self.hit_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let scanned = self.scan_scanned.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let found = self.scan_found.load(std::sync::atomic::Ordering::Relaxed);
|
||||
ui.label(format!("扫描:{}/{} ", scanned, found));
|
||||
ui.separator();
|
||||
ui.label(format!("抽检:{}/{}", p, t));
|
||||
ui.separator();
|
||||
if hits > 0 {
|
||||
ui.label(egui::RichText::new(format!("命中:{}", hits)).color(material::DANGER));
|
||||
} else {
|
||||
ui.label("命中:0");
|
||||
}
|
||||
ui.separator();
|
||||
if let Ok(cur) = self.current_file.lock() {
|
||||
if let Some(f) = cur.as_ref() {
|
||||
let short = shorten_path(f, 80);
|
||||
ui.label(format!("当前:{}", short));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 中间:设置 / 报告 / 主面板(三选一)
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if self.show_settings {
|
||||
settings::draw(ui, &mut self.config);
|
||||
} else if self.show_report {
|
||||
if let Ok(rep) = self.report.lock() {
|
||||
if let Some(r) = rep.as_ref() {
|
||||
crate::ui::report::draw(ui, r);
|
||||
} else {
|
||||
ui.label("暂无报告");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
home::draw(ui, self);
|
||||
}
|
||||
});
|
||||
|
||||
// 请求持续重绘,便于进度更新
|
||||
if self.state != RunState::Idle && self.state != RunState::Done && self.state != RunState::Cancelled {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
if let Err(e) = self.config.save() {
|
||||
tracing::warn!("保存配置失败:{}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把长路径截短(保留开头和结尾),避免状态栏溢出
|
||||
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)
|
||||
}
|
||||
10
src/config/mod.rs
Normal file
10
src/config/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// 配置模块
|
||||
pub mod model;
|
||||
pub mod persist;
|
||||
|
||||
pub use model::{
|
||||
AppConfig, GeneralSettings, InspectSettings, KeywordSettings, KeywordSettings as Keywords, LogLevel,
|
||||
ReportFormat, ReportSettings, SampleMode, SampleStrategy, ScanSettings, ScreenshotMode, Theme,
|
||||
UiSettings, ViewerSettings, Language,
|
||||
};
|
||||
pub use persist::{load_config, save_config, config_path};
|
||||
349
src/config/model.rs
Normal file
349
src/config/model.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
// 强类型配置
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::utils::paths;
|
||||
|
||||
/// 主题
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
Follow,
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self { Theme::Follow }
|
||||
}
|
||||
|
||||
/// 语言
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Language {
|
||||
Zh,
|
||||
En,
|
||||
}
|
||||
|
||||
impl Default for Language {
|
||||
fn default() -> Self { Language::Zh }
|
||||
}
|
||||
|
||||
/// 日志级别
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum LogLevel {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
Trace,
|
||||
}
|
||||
impl Default for LogLevel { fn default() -> Self { LogLevel::Info } }
|
||||
|
||||
/// 抽样策略
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SampleStrategy {
|
||||
Random,
|
||||
Stratified,
|
||||
Quota,
|
||||
}
|
||||
impl Default for SampleStrategy { fn default() -> Self { SampleStrategy::Random } }
|
||||
|
||||
/// 抽检数量模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SampleMode {
|
||||
/// 按固定份数(sample_count)
|
||||
Count,
|
||||
/// 按全量候选的百分比(sample_percent)
|
||||
Percent,
|
||||
/// 全部候选
|
||||
All,
|
||||
}
|
||||
impl Default for SampleMode { fn default() -> Self { SampleMode::Count } }
|
||||
|
||||
/// 截图模式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ScreenshotMode {
|
||||
/// 模拟 Win+Shift+S + 手动框选
|
||||
Manual,
|
||||
/// PrintWindow 全自动
|
||||
AutoPrintWindow,
|
||||
/// 自动优先 + 失败降级手动
|
||||
AutoWithFallback,
|
||||
}
|
||||
impl Default for ScreenshotMode { fn default() -> Self { ScreenshotMode::Manual } }
|
||||
|
||||
/// 报告格式
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReportFormat {
|
||||
Html,
|
||||
Json,
|
||||
Png,
|
||||
All,
|
||||
}
|
||||
impl Default for ReportFormat { fn default() -> Self { ReportFormat::All } }
|
||||
|
||||
/// 常规设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralSettings {
|
||||
pub auto_run: bool,
|
||||
pub start_minimized: bool,
|
||||
pub auto_start: bool,
|
||||
pub theme: Theme,
|
||||
pub language: Language,
|
||||
pub log_level: LogLevel,
|
||||
pub log_retention_days: u32,
|
||||
pub single_instance: bool,
|
||||
pub clear_temp_on_start: bool,
|
||||
pub auto_exit: bool,
|
||||
pub auto_exit_seconds: u32,
|
||||
}
|
||||
impl Default for GeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_run: false,
|
||||
start_minimized: false,
|
||||
auto_start: false,
|
||||
theme: Theme::default(),
|
||||
language: Language::default(),
|
||||
log_level: LogLevel::default(),
|
||||
log_retention_days: 14,
|
||||
single_instance: true,
|
||||
clear_temp_on_start: true,
|
||||
auto_exit: false,
|
||||
auto_exit_seconds: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描范围
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScanSettings {
|
||||
/// 白名单目录(不扫描的目录)
|
||||
pub whitelist: Vec<PathBuf>,
|
||||
pub include_hidden: bool,
|
||||
pub include_system: bool,
|
||||
pub follow_symlinks: bool,
|
||||
pub min_size_kb: u64,
|
||||
pub max_depth: u32,
|
||||
pub extensions: Vec<String>,
|
||||
pub scan_timeout_minutes: u32,
|
||||
}
|
||||
impl Default for ScanSettings {
|
||||
fn default() -> Self {
|
||||
let mut whitelist = Vec::new();
|
||||
// 默认白名单:常见系统/临时目录
|
||||
for d in [r"C:\Windows", r"C:\Program Files", r"C:\Program Files (x86)", r"C:\ProgramData", r"C:\Recovery"] {
|
||||
whitelist.push(PathBuf::from(d));
|
||||
}
|
||||
Self {
|
||||
whitelist,
|
||||
include_hidden: false,
|
||||
include_system: false,
|
||||
follow_symlinks: false,
|
||||
min_size_kb: 1,
|
||||
max_depth: 0,
|
||||
extensions: vec!["doc".into(), "docx".into(), "pdf".into(), "xlsx".into()],
|
||||
scan_timeout_minutes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 抽检设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InspectSettings {
|
||||
/// 抽样数量模式:Count/Percent/All
|
||||
pub sample_mode: SampleMode,
|
||||
/// 固定份数(Count 模式)
|
||||
pub sample_count: usize,
|
||||
/// 百分比(Percent 模式,0~100)
|
||||
pub sample_percent: f32,
|
||||
pub strategy: SampleStrategy,
|
||||
pub doc_quota: usize,
|
||||
pub docx_quota: usize,
|
||||
pub pdf_quota: usize,
|
||||
pub xlsx_quota: usize,
|
||||
pub per_file_timeout_sec: u64,
|
||||
pub dedup_days: u32,
|
||||
pub skip_locked: bool,
|
||||
pub stop_on_first_hit: bool,
|
||||
}
|
||||
impl Default for InspectSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sample_mode: SampleMode::Count,
|
||||
sample_count: 20,
|
||||
sample_percent: 20.0,
|
||||
strategy: SampleStrategy::Random,
|
||||
doc_quota: 0,
|
||||
docx_quota: 0,
|
||||
pdf_quota: 0,
|
||||
xlsx_quota: 0,
|
||||
per_file_timeout_sec: 120,
|
||||
dedup_days: 7,
|
||||
skip_locked: true,
|
||||
stop_on_first_hit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 查看器与截图设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ViewerSettings {
|
||||
pub screenshot_mode: ScreenshotMode,
|
||||
pub doc_viewer: Option<PathBuf>,
|
||||
pub doc_args: String,
|
||||
pub pdf_viewer: Option<PathBuf>,
|
||||
pub pdf_args: String,
|
||||
pub pre_capture_wait_ms: u64,
|
||||
pub manual_capture_timeout_sec: u64,
|
||||
pub max_black_ratio: f32,
|
||||
pub auto_close_after: bool,
|
||||
pub close_wait_ms: u64,
|
||||
pub kill_timeout_ms: u64,
|
||||
pub umi_ocr_url: String,
|
||||
pub umi_ocr_exe: Option<PathBuf>,
|
||||
pub umi_ocr_startup_wait_sec: u64,
|
||||
pub umi_ocr_call_timeout_sec: u64,
|
||||
pub ocr_language: String,
|
||||
pub ocr_cls: bool,
|
||||
pub ocr_limit_side_len: u32,
|
||||
}
|
||||
impl Default for ViewerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
screenshot_mode: ScreenshotMode::Manual,
|
||||
doc_viewer: None,
|
||||
doc_args: "\"{path}\"".into(),
|
||||
pdf_viewer: None,
|
||||
pdf_args: "\"{path}\"".into(),
|
||||
pre_capture_wait_ms: 1500,
|
||||
manual_capture_timeout_sec: 60,
|
||||
max_black_ratio: 0.95,
|
||||
auto_close_after: true,
|
||||
close_wait_ms: 1500,
|
||||
kill_timeout_ms: 3000,
|
||||
umi_ocr_url: "http://127.0.0.1:1224/api/ocr".into(),
|
||||
umi_ocr_exe: None,
|
||||
umi_ocr_startup_wait_sec: 3,
|
||||
umi_ocr_call_timeout_sec: 30,
|
||||
ocr_language: "models/config_chinese.txt".into(),
|
||||
ocr_cls: false,
|
||||
ocr_limit_side_len: 2880,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 关键词
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KeywordSettings {
|
||||
pub global: Vec<String>,
|
||||
pub doc_extra: Vec<String>,
|
||||
pub docx_extra: Vec<String>,
|
||||
pub pdf_extra: Vec<String>,
|
||||
pub xlsx_extra: Vec<String>,
|
||||
pub case_sensitive: bool,
|
||||
pub use_regex: bool,
|
||||
pub whole_word: bool,
|
||||
pub min_confidence: f32,
|
||||
pub highlight_color: [u8; 3],
|
||||
pub false_positive_fingerprints: Vec<String>,
|
||||
}
|
||||
impl Default for KeywordSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
global: vec![
|
||||
"机密".into(), "秘密".into(), "绝密".into(), "内部".into(),
|
||||
"Confidential".into(), "Secret".into(),
|
||||
],
|
||||
doc_extra: vec![],
|
||||
docx_extra: vec![],
|
||||
pdf_extra: vec![],
|
||||
xlsx_extra: vec![],
|
||||
case_sensitive: false,
|
||||
use_regex: false,
|
||||
whole_word: false,
|
||||
min_confidence: 0.6,
|
||||
highlight_color: [220, 38, 38],
|
||||
false_positive_fingerprints: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReportSettings {
|
||||
pub output_dir: PathBuf,
|
||||
pub formats: Vec<ReportFormat>,
|
||||
pub file_prefix: String,
|
||||
pub include_screenshots: bool,
|
||||
pub highlight_sensitive: bool,
|
||||
pub max_screenshot_side: u32,
|
||||
pub history_keep: u32,
|
||||
pub auto_open: bool,
|
||||
pub copy_summary_to_clipboard: bool,
|
||||
}
|
||||
impl Default for ReportSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
output_dir: paths::default_report_dir(),
|
||||
formats: vec![ReportFormat::Html, ReportFormat::Json],
|
||||
file_prefix: "selfcheck-{date}".into(),
|
||||
include_screenshots: true,
|
||||
highlight_sensitive: true,
|
||||
max_screenshot_side: 1600,
|
||||
history_keep: 30,
|
||||
auto_open: true,
|
||||
copy_summary_to_clipboard: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI 设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UiSettings {
|
||||
pub font_path: Option<PathBuf>,
|
||||
pub font_size: f32,
|
||||
}
|
||||
impl Default for UiSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_path: None,
|
||||
font_size: 14.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶层配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AppConfig {
|
||||
pub general: GeneralSettings,
|
||||
pub scan: ScanSettings,
|
||||
pub inspect: InspectSettings,
|
||||
pub viewer: ViewerSettings,
|
||||
pub keyword: KeywordSettings,
|
||||
pub report: ReportSettings,
|
||||
pub ui: UiSettings,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// 加载(如不存在则返回默认)
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let path = paths::config_file();
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let s = std::fs::read_to_string(&path)?;
|
||||
let cfg: AppConfig = toml::from_str(&s)?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// 保存
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let path = paths::config_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let s = toml::to_string_pretty(self)?;
|
||||
std::fs::write(&path, s)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
16
src/config/persist.rs
Normal file
16
src/config/persist.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
// 配置加载/保存辅助
|
||||
use super::model::AppConfig;
|
||||
use crate::utils::paths;
|
||||
|
||||
pub fn load_config() -> anyhow::Result<AppConfig> {
|
||||
AppConfig::load()
|
||||
}
|
||||
|
||||
pub fn save_config(cfg: &AppConfig) -> anyhow::Result<()> {
|
||||
cfg.save()
|
||||
}
|
||||
|
||||
/// 获取配置文件路径(用于 UI 显示)
|
||||
pub fn config_path() -> std::path::PathBuf {
|
||||
paths::config_file()
|
||||
}
|
||||
93
src/inspect/doc_inspector.rs
Normal file
93
src/inspect/doc_inspector.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
// DOC 抽检:doclite.exe + 截图(三种模式)+ OCR + 关键词
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::external;
|
||||
use crate::inspect::screenshot;
|
||||
use crate::inspect::umi_ocr;
|
||||
use crate::inspect::{make_hit, Finding, Inspector};
|
||||
use crate::matcher::keywords::{keywords_for, Matcher};
|
||||
use crate::utils::paths;
|
||||
|
||||
pub struct DocInspector {
|
||||
pub cfg: AppConfig,
|
||||
}
|
||||
|
||||
impl DocInspector {
|
||||
pub fn new(cfg: AppConfig) -> Self { Self { cfg } }
|
||||
}
|
||||
|
||||
impl Inspector for DocInspector {
|
||||
fn inspect<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
cfg: &'a AppConfig,
|
||||
cancel: &'a AtomicBool,
|
||||
log: &'a (dyn Fn(&str) + Send + Sync),
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Finding>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let viewer = cfg.viewer.doc_viewer.clone()
|
||||
.or_else(paths::detect_doclite)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到 doclite.exe;请在设置中指定或把它放到 exe 同目录"))?;
|
||||
let args = cfg.viewer.doc_args.replace("{path}", &path.display().to_string());
|
||||
log(&format!(" 启动 DOC 查看器:{} {}", viewer.display(), args));
|
||||
let child = external::spawn(&viewer, &args)?;
|
||||
let wait_ms = cfg.viewer.pre_capture_wait_ms;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
|
||||
|
||||
let png = match cfg.viewer.screenshot_mode {
|
||||
crate::config::ScreenshotMode::Manual => {
|
||||
screenshot::capture_manual(
|
||||
child.pid,
|
||||
&cfg.viewer,
|
||||
cancel,
|
||||
log,
|
||||
)?
|
||||
}
|
||||
crate::config::ScreenshotMode::AutoPrintWindow => {
|
||||
screenshot::capture_printwindow(child.pid, &cfg.viewer, log)
|
||||
.ok_or_else(|| anyhow::anyhow!("PrintWindow 抓取失败"))?
|
||||
}
|
||||
crate::config::ScreenshotMode::AutoWithFallback => {
|
||||
match screenshot::capture_printwindow(child.pid, &cfg.viewer, log) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log(" PrintWindow 失败,降级到手动截图");
|
||||
screenshot::capture_manual(child.pid, &cfg.viewer, cancel, log)?
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// OCR
|
||||
let ocr = umi_ocr::UmiOcrClient::new(&cfg.viewer.umi_ocr_url, std::time::Duration::from_secs(cfg.viewer.umi_ocr_call_timeout_sec));
|
||||
let resp = ocr.recognize_png(&png, &cfg.viewer.ocr_language, cfg.viewer.ocr_cls, cfg.viewer.ocr_limit_side_len).await?;
|
||||
let raw_text: String = resp.data.iter().map(|d| d.text.clone()).collect::<Vec<_>>().join("\n");
|
||||
|
||||
// 保存截图到 temp
|
||||
let shot_path = paths::temp_dir(&chrono::Local::now().format("%Y%m%d-%H%M%S").to_string())
|
||||
.join(format!("{}.png", path.file_stem().and_then(|s| s.to_str()).unwrap_or("file")));
|
||||
let _ = std::fs::create_dir_all(shot_path.parent().unwrap());
|
||||
let _ = std::fs::write(&shot_path, &png);
|
||||
|
||||
// 匹配
|
||||
let kws = keywords_for("doc", &cfg.keyword);
|
||||
let m = Matcher::new(kws, &cfg.keyword);
|
||||
let hits = m.find(&raw_text);
|
||||
|
||||
// 关闭查看器
|
||||
if cfg.viewer.auto_close_after {
|
||||
let _ = external::close(&child, &cfg.viewer);
|
||||
}
|
||||
|
||||
Ok(make_hit(path, "doc", hits, raw_text, Some(shot_path)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 引用避免警告
|
||||
#[allow(dead_code)]
|
||||
fn _unused(p: PathBuf) { let _ = p; }
|
||||
81
src/inspect/docx_inspector.rs
Normal file
81
src/inspect/docx_inspector.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
// DOCX 文本抽检:docx-rs 0.4 读段落,关键词匹配
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use docx_rs::{DocumentChild, ParagraphChild, RunChild, TableCellContent, TableChild, TableRowChild};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::{make_hit, Finding, Inspector};
|
||||
use crate::matcher::keywords::{keywords_for, Matcher};
|
||||
|
||||
pub struct DocxInspector;
|
||||
|
||||
impl Inspector for DocxInspector {
|
||||
fn inspect<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
cfg: &'a AppConfig,
|
||||
_cancel: &'a AtomicBool,
|
||||
log: &'a (dyn Fn(&str) + Send + Sync),
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Finding>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
log(" 解析 DOCX 文本……");
|
||||
let bytes = std::fs::read(path)?;
|
||||
// docx-rs 0.4:使用 read_docx 解析整个 zip
|
||||
let doc = docx_rs::read_docx(&bytes).map_err(|e| anyhow::anyhow!("docx-rs 解析失败:{:?}", e))?;
|
||||
|
||||
let mut text = String::new();
|
||||
for d in doc.document.children.iter() {
|
||||
match d {
|
||||
DocumentChild::Paragraph(p) => {
|
||||
// p: &Box<Paragraph>
|
||||
for pc in p.children.iter() {
|
||||
if let ParagraphChild::Run(r) = pc {
|
||||
for rc in r.children.iter() {
|
||||
if let RunChild::Text(t) = rc {
|
||||
text.push_str(&t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
DocumentChild::Table(t) => {
|
||||
// t: &Box<Table>;t.rows: Vec<TableChild>
|
||||
for tc in t.rows.iter() {
|
||||
if let TableChild::TableRow(row) = tc {
|
||||
for rc in row.cells.iter() {
|
||||
if let TableRowChild::TableCell(cell) = rc {
|
||||
for cc in cell.children.iter() {
|
||||
if let TableCellContent::Paragraph(p) = cc {
|
||||
for pc in p.children.iter() {
|
||||
if let ParagraphChild::Run(r) = pc {
|
||||
for rcc in r.children.iter() {
|
||||
if let RunChild::Text(t) = rcc {
|
||||
text.push_str(&t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
text.push('\t');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let kws = keywords_for("docx", &cfg.keyword);
|
||||
let m = Matcher::new(kws, &cfg.keyword);
|
||||
let hits = m.find(&text);
|
||||
Ok(make_hit(path, "docx", hits, text, None))
|
||||
})
|
||||
}
|
||||
}
|
||||
94
src/inspect/external.rs
Normal file
94
src/inspect/external.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
// 进程管理:启进程、关闭窗口、强杀
|
||||
use std::path::Path;
|
||||
|
||||
use windows_sys::Win32::Foundation::HWND;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, TerminateProcess, WaitForSingleObject, PROCESS_TERMINATE, PROCESS_VM_READ,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetWindowThreadProcessId, PostMessageW, WM_CLOSE,
|
||||
};
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
thread_local! {
|
||||
static ENUM_RESULT: RefCell<EnumResult> = RefCell::new(EnumResult { hwnd: std::ptr::null_mut(), pid: 0 });
|
||||
}
|
||||
struct EnumResult { hwnd: HWND, pid: u32 }
|
||||
|
||||
unsafe extern "system" fn enum_callback(hwnd: HWND, _lparam: isize) -> i32 {
|
||||
let mut proc_id: u32 = 0;
|
||||
GetWindowThreadProcessId(hwnd, &mut proc_id);
|
||||
ENUM_RESULT.with(|r| {
|
||||
*r.borrow_mut() = EnumResult { hwnd, pid: proc_id };
|
||||
});
|
||||
1 // continue
|
||||
}
|
||||
|
||||
/// 启动的子进程
|
||||
pub struct Child {
|
||||
pub pid: u32,
|
||||
pub _handle: Option<std::process::Child>,
|
||||
}
|
||||
|
||||
unsafe impl Send for Child {}
|
||||
unsafe impl Sync for Child {}
|
||||
|
||||
/// 启动外部进程
|
||||
pub fn spawn(exe: &Path, args: &str) -> anyhow::Result<Child> {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
for arg in args.split_whitespace() {
|
||||
cmd.arg(arg.trim_matches('"'));
|
||||
}
|
||||
let child = cmd.spawn().map_err(|e| anyhow::anyhow!("启动 {:?} 失败:{}", exe, e))?;
|
||||
Ok(Child { pid: child.id(), _handle: Some(child) })
|
||||
}
|
||||
|
||||
/// 通过 PID 找到主窗口 HWND
|
||||
pub fn find_hwnd_by_pid(pid: u32) -> Option<HWND> {
|
||||
unsafe {
|
||||
let _ = EnumWindows(Some(enum_callback), 0);
|
||||
}
|
||||
let r = ENUM_RESULT.with(|r| r.borrow().hwnd);
|
||||
let p = ENUM_RESULT.with(|r| r.borrow().pid);
|
||||
if p == pid && !r.is_null() { Some(r) } else { None }
|
||||
}
|
||||
|
||||
/// 优雅关闭:PostMessage WM_CLOSE 给主窗口 → 等待 → taskkill
|
||||
pub fn close(child: &Child, cfg: &crate::config::ViewerSettings) -> anyhow::Result<()> {
|
||||
if let Some(hwnd) = find_hwnd_by_pid(child.pid) {
|
||||
unsafe {
|
||||
PostMessageW(hwnd, WM_CLOSE, 0, 0);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(cfg.close_wait_ms));
|
||||
}
|
||||
if is_running(child.pid) {
|
||||
kill(child.pid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 进程是否仍在运行
|
||||
pub fn is_running(pid: u32) -> bool {
|
||||
unsafe {
|
||||
let h = OpenProcess(PROCESS_VM_READ, 0, pid);
|
||||
if h.is_null() { return false; }
|
||||
let r = WaitForSingleObject(h, 0);
|
||||
windows_sys::Win32::Foundation::CloseHandle(h);
|
||||
r != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 强杀
|
||||
pub fn kill(pid: u32) {
|
||||
unsafe {
|
||||
let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
|
||||
if !h.is_null() {
|
||||
TerminateProcess(h, 1);
|
||||
windows_sys::Win32::Foundation::CloseHandle(h);
|
||||
}
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.arg("/F").arg("/PID").arg(pid.to_string()).arg("/T")
|
||||
.output();
|
||||
}
|
||||
}
|
||||
59
src/inspect/mod.rs
Normal file
59
src/inspect/mod.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
// 抽检模块
|
||||
pub mod doc_inspector;
|
||||
pub mod docx_inspector;
|
||||
pub mod external;
|
||||
pub mod mod_helper;
|
||||
pub mod pdf_inspector;
|
||||
pub mod screenshot;
|
||||
pub mod umi_ocr;
|
||||
pub mod xlsx_inspector;
|
||||
|
||||
pub use crate::scan::sampler;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::matcher::keywords::MatchHit;
|
||||
|
||||
/// 单次抽检结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
pub path: PathBuf,
|
||||
pub kind: String,
|
||||
pub matched: Vec<String>,
|
||||
pub confidence: f32,
|
||||
pub boxes: Vec<[f64; 4]>, // [x1,y1,x2,y2]
|
||||
pub raw_text: String,
|
||||
pub screenshot: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
pub fn is_hit(&self) -> bool { !self.matched.is_empty() }
|
||||
}
|
||||
|
||||
/// Inspector 接口
|
||||
pub trait Inspector: Send {
|
||||
fn inspect<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
cfg: &'a AppConfig,
|
||||
cancel: &'a AtomicBool,
|
||||
log: &'a (dyn Fn(&str) + Send + Sync),
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<Finding>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// 公共辅助
|
||||
pub fn make_hit(path: &Path, kind: &str, hits: Vec<MatchHit>, raw: String, ss: Option<PathBuf>) -> Finding {
|
||||
Finding {
|
||||
path: path.to_path_buf(),
|
||||
kind: kind.to_string(),
|
||||
matched: hits.into_iter().map(|h| h.keyword).collect(),
|
||||
confidence: 1.0,
|
||||
boxes: vec![],
|
||||
raw_text: raw,
|
||||
screenshot: ss,
|
||||
}
|
||||
}
|
||||
3
src/inspect/mod_helper.rs
Normal file
3
src/inspect/mod_helper.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// 防止 mod.rs 中文件/子模块同名造成混乱
|
||||
#[allow(dead_code)]
|
||||
pub fn marker() {}
|
||||
44
src/inspect/pdf_inspector.rs
Normal file
44
src/inspect/pdf_inspector.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// PDF 抽检:与 DOC 类似,调用 Windows 关联 PDF 程序
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::doc_inspector::DocInspector;
|
||||
use crate::inspect::{Finding, Inspector};
|
||||
|
||||
pub struct PdfInspector {
|
||||
pub cfg: AppConfig,
|
||||
}
|
||||
|
||||
impl PdfInspector {
|
||||
pub fn new(cfg: AppConfig) -> Self { Self { cfg } }
|
||||
}
|
||||
|
||||
impl Inspector for PdfInspector {
|
||||
fn inspect<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
cfg: &'a AppConfig,
|
||||
cancel: &'a AtomicBool,
|
||||
log: &'a (dyn Fn(&str) + Send + Sync),
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Finding>> + Send + 'a>> {
|
||||
// 复用 DocInspector 的 inspect 逻辑(参数模板用 cfg.viewer.pdf_args)
|
||||
let mut pdf_cfg = cfg.clone();
|
||||
if cfg.viewer.pdf_viewer.is_some() {
|
||||
pdf_cfg.viewer.doc_viewer = cfg.viewer.pdf_viewer.clone();
|
||||
pdf_cfg.viewer.doc_args = cfg.viewer.pdf_args.clone();
|
||||
}
|
||||
if cfg.viewer.pdf_viewer.is_none() {
|
||||
// 用 cmd /c start "" "<path>" 触发默认关联
|
||||
let mut pdf_cfg2 = cfg.clone();
|
||||
pdf_cfg2.viewer.doc_viewer = Some(std::path::PathBuf::from("C:\\Windows\\System32\\cmd.exe"));
|
||||
pdf_cfg2.viewer.doc_args = format!("/c start \"\" \"{}\"", path.display());
|
||||
let inspector = DocInspector::new(pdf_cfg2);
|
||||
return Box::pin(async move { inspector.inspect(path, &pdf_cfg, cancel, log).await });
|
||||
}
|
||||
let inspector = DocInspector::new(pdf_cfg);
|
||||
Box::pin(async move { inspector.inspect(path, cfg, cancel, log).await })
|
||||
}
|
||||
}
|
||||
116
src/inspect/screenshot.rs
Normal file
116
src/inspect/screenshot.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
// 截图:Win+Shift+S(手动)+ PrintWindow(自动)+ 剪贴板读位图
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use windows_sys::Win32::Foundation::{HWND, RECT};
|
||||
use windows_sys::Win32::Graphics::Gdi::{
|
||||
CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
|
||||
ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP, HDC,
|
||||
};
|
||||
use windows_sys::Win32::Storage::Xps::PrintWindow;
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||
keybd_event, KEYEVENTF_KEYUP, VK_LSHIFT, VK_LWIN,
|
||||
};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
GetClientRect, SetForegroundWindow, ShowWindow,
|
||||
};
|
||||
|
||||
use crate::config::ViewerSettings;
|
||||
use crate::inspect::external;
|
||||
|
||||
pub type PngBytes = Vec<u8>;
|
||||
|
||||
/// 自动模式:PrintWindow 抓窗口
|
||||
pub fn capture_printwindow(pid: u32, cfg: &ViewerSettings, log: &dyn Fn(&str)) -> Option<PngBytes> {
|
||||
let hwnd = external::find_hwnd_by_pid(pid)?;
|
||||
if hwnd.is_null() { return None; }
|
||||
unsafe {
|
||||
let mut rect: RECT = std::mem::zeroed();
|
||||
if GetClientRect(hwnd, &mut rect) == 0 {
|
||||
log(" GetClientRect 失败");
|
||||
return None;
|
||||
}
|
||||
let w = (rect.right - rect.left).max(1);
|
||||
let h = (rect.bottom - rect.top).max(1);
|
||||
let hdc_screen: HDC = GetDC(std::ptr::null_mut());
|
||||
if hdc_screen.is_null() { return None; }
|
||||
let hdc: HDC = CreateCompatibleDC(hdc_screen);
|
||||
let hbm: HBITMAP = CreateCompatibleBitmap(hdc_screen, w, h);
|
||||
let old = SelectObject(hdc, hbm);
|
||||
// PW_RENDERFULLCONTENT = 0x00000002
|
||||
let _ = PrintWindow(hwnd, hdc, 0x00000002);
|
||||
let png = bitmap_to_png(hbm, w, h);
|
||||
let _ = SelectObject(hdc, old);
|
||||
let _ = DeleteObject(hbm);
|
||||
let _ = DeleteDC(hdc);
|
||||
ReleaseDC(std::ptr::null_mut(), hdc_screen);
|
||||
match png {
|
||||
Some(bytes) => {
|
||||
if is_mostly_black(&bytes, cfg.max_black_ratio) {
|
||||
log(" 抓到的位图过黑,视为失败");
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动模式:触发 Win+Shift+S,等待用户框选
|
||||
pub fn capture_manual(pid: u32, cfg: &ViewerSettings, _cancel: &AtomicBool, log: &dyn Fn(&str)) -> anyhow::Result<PngBytes> {
|
||||
if let Some(hwnd) = external::find_hwnd_by_pid(pid) {
|
||||
if !hwnd.is_null() {
|
||||
unsafe {
|
||||
let _ = ShowWindow(hwnd, 5 /* SW_SHOW */);
|
||||
let _ = SetForegroundWindow(hwnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
unsafe {
|
||||
keybd_event(VK_LSHIFT as u8, 0, 0, 0);
|
||||
keybd_event(VK_LWIN as u8, 0, 0, 0);
|
||||
keybd_event(0x53, 0, 0, 0);
|
||||
keybd_event(0x53, 0, KEYEVENTF_KEYUP, 0);
|
||||
keybd_event(VK_LSHIFT as u8, 0, KEYEVENTF_KEYUP, 0);
|
||||
keybd_event(VK_LWIN as u8, 0, KEYEVENTF_KEYUP, 0);
|
||||
}
|
||||
log(&format!(" 请在 {} 秒内用系统截图工具框选窗口", cfg.manual_capture_timeout_sec));
|
||||
|
||||
// 简化:手动模式需要用户上传图片(这里返回错误,UI 提示)
|
||||
// 实际生产应通过剪贴板或专门的文件对话框
|
||||
Err(anyhow::anyhow!("手动模式需要通过 UI 上传截图(待实现)"))
|
||||
}
|
||||
|
||||
fn bitmap_to_png(hbm: HBITMAP, w: i32, h: i32) -> Option<PngBytes> {
|
||||
unsafe {
|
||||
let mut bmi: BITMAPINFO = std::mem::zeroed();
|
||||
bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32;
|
||||
bmi.bmiHeader.biWidth = w;
|
||||
bmi.bmiHeader.biHeight = -h; // top-down
|
||||
bmi.bmiHeader.biPlanes = 1;
|
||||
bmi.bmiHeader.biBitCount = 32;
|
||||
bmi.bmiHeader.biCompression = 0;
|
||||
let mut buf = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
let hdc = GetDC(std::ptr::null_mut());
|
||||
let n = GetDIBits(hdc, hbm, 0, h as u32, buf.as_mut_ptr() as _, &mut bmi, DIB_RGB_COLORS);
|
||||
ReleaseDC(std::ptr::null_mut(), hdc);
|
||||
if n == 0 { return None; }
|
||||
let img = image::RgbaImage::from_raw(w as u32, h as u32, buf)?;
|
||||
let dyn_img = image::DynamicImage::ImageRgba8(img);
|
||||
let mut out = Vec::new();
|
||||
dyn_img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mostly_black(png: &[u8], max_ratio: f32) -> bool {
|
||||
if let Ok(img) = image::load_from_memory(png) {
|
||||
let img = img.to_luma8();
|
||||
let total = (img.width() * img.height()) as usize;
|
||||
if total == 0 { return true; }
|
||||
let black = img.as_raw().iter().filter(|&&p| p < 16).count();
|
||||
let r = black as f32 / total as f32;
|
||||
return r > max_ratio;
|
||||
}
|
||||
false
|
||||
}
|
||||
141
src/inspect/umi_ocr.rs
Normal file
141
src/inspect/umi_ocr.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
// Umi-OCR HTTP 客户端
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windows_sys::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
|
||||
|
||||
use crate::config::ViewerSettings;
|
||||
use crate::utils::paths;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OcrOptions {
|
||||
#[serde(rename = "ocr.language")]
|
||||
language: String,
|
||||
#[serde(rename = "ocr.cls")]
|
||||
cls: bool,
|
||||
#[serde(rename = "ocr.limit_side_len")]
|
||||
limit_side_len: u32,
|
||||
#[serde(rename = "tbpu.parser")]
|
||||
parser: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OcrRequest<'a> {
|
||||
base64: &'a str,
|
||||
options: OcrOptions,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct OcrResponse {
|
||||
pub code: u32,
|
||||
pub data: Vec<OcrItem>,
|
||||
#[allow(dead_code)]
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct OcrItem {
|
||||
pub text: String,
|
||||
pub score: f32,
|
||||
#[serde(default, alias = "box")]
|
||||
pub box_field: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl OcrItem {
|
||||
pub fn box_points(&self) -> &Vec<Vec<f64>> { &self.box_field }
|
||||
}
|
||||
|
||||
pub struct UmiOcrClient {
|
||||
base_url: String,
|
||||
#[allow(dead_code)]
|
||||
timeout: Duration,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl UmiOcrClient {
|
||||
pub fn new(base_url: &str, timeout: Duration) -> Self {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Self { base_url: base_url.to_string(), timeout, client }
|
||||
}
|
||||
|
||||
pub fn recognize_png_blocking(&self, png: &[u8], language: &str, cls: bool, limit_side_len: u32) -> anyhow::Result<OcrResponse> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
|
||||
let req = OcrRequest {
|
||||
base64: &b64,
|
||||
options: OcrOptions {
|
||||
language: language.to_string(),
|
||||
cls,
|
||||
limit_side_len,
|
||||
parser: "multi_para".into(),
|
||||
},
|
||||
};
|
||||
let resp = self.client.post(&self.base_url).json(&req).send()?.error_for_status()?;
|
||||
let parsed: OcrResponse = resp.json()?;
|
||||
if parsed.code != 100 {
|
||||
anyhow::bail!("Umi-OCR 返回 code={}", parsed.code);
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// 异步封装(直接调阻塞版)
|
||||
pub async fn recognize_png(&self, png: &[u8], language: &str, cls: bool, limit_side_len: u32) -> anyhow::Result<OcrResponse> {
|
||||
let me = unsafe { std::ptr::read(self) };
|
||||
let png = png.to_vec();
|
||||
// 把 &str 转为 String 以满足 'static 约束
|
||||
let language = language.to_string();
|
||||
tokio::task::spawn_blocking(move || me.recognize_png_blocking(&png, &language, cls, limit_side_len)).await?
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 Umi-OCR.exe(若未运行)
|
||||
pub fn ensure_started(cfg: &ViewerSettings) -> anyhow::Result<()> {
|
||||
if let Ok(r) = reqwest::blocking::get(cfg.umi_ocr_url.replace("/api/ocr", "/")) {
|
||||
if r.status().is_success() { return Ok(()); }
|
||||
}
|
||||
let exe = cfg.umi_ocr_exe.clone()
|
||||
.or_else(paths::detect_umi_ocr)
|
||||
.ok_or_else(|| anyhow::anyhow!("未找到 Umi-OCR.exe"))?;
|
||||
spawn(&exe)?;
|
||||
let start = std::time::Instant::now();
|
||||
let wait = std::time::Duration::from_secs(cfg.umi_ocr_startup_wait_sec.max(1));
|
||||
while start.elapsed() < wait {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
if let Ok(r) = reqwest::blocking::get(cfg.umi_ocr_url.replace("/api/ocr", "/")) {
|
||||
if r.status().is_success() { return Ok(()); }
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("Umi-OCR 健康检查超时"))
|
||||
}
|
||||
|
||||
fn spawn(exe: &PathBuf) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
let mut cmd: Vec<u16> = exe.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||
let mut si: STARTUPINFOW = std::mem::zeroed();
|
||||
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
|
||||
let mut pi: PROCESS_INFORMATION = std::mem::zeroed();
|
||||
let ok = CreateProcessW(
|
||||
cmd.as_mut_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
0,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&si,
|
||||
&mut pi,
|
||||
);
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("CreateProcessW 失败"));
|
||||
}
|
||||
windows_sys::Win32::Foundation::CloseHandle(pi.hProcess);
|
||||
windows_sys::Win32::Foundation::CloseHandle(pi.hThread);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
186
src/inspect/xlsx_inspector.rs
Normal file
186
src/inspect/xlsx_inspector.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
// XLSX 文本抽检:把 xlsx 当 zip 解压,提取 sharedStrings 与各 sheet 单元格的文本
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::{make_hit, Finding, Inspector};
|
||||
use crate::matcher::keywords::{keywords_for, Matcher};
|
||||
|
||||
pub struct XlsxInspector;
|
||||
|
||||
impl Inspector for XlsxInspector {
|
||||
fn inspect<'a>(
|
||||
&'a self,
|
||||
path: &'a Path,
|
||||
cfg: &'a AppConfig,
|
||||
_cancel: &'a AtomicBool,
|
||||
log: &'a (dyn Fn(&str) + Send + Sync),
|
||||
) -> Pin<Box<dyn Future<Output = anyhow::Result<Finding>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
log(" 解析 XLSX 文本……");
|
||||
let bytes = std::fs::read(path)?;
|
||||
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))
|
||||
.map_err(|e| anyhow::anyhow!("xlsx 不是合法 zip:{}", e))?;
|
||||
|
||||
// 1. 读 sharedStrings
|
||||
let shared: Vec<String> = match archive.by_name("xl/sharedStrings.xml") {
|
||||
Ok(mut f) => {
|
||||
let mut s = String::new();
|
||||
use std::io::Read;
|
||||
let _ = f.read_to_string(&mut s);
|
||||
parse_shared_strings(&s)
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// 2. 找 sheet 文件列表
|
||||
let sheet_files: Vec<String> = (0..archive.len())
|
||||
.filter_map(|i| archive.by_index(i).ok().map(|f| f.name().to_string()))
|
||||
.filter(|n| n.starts_with("xl/worksheets/sheet") && n.ends_with(".xml"))
|
||||
.collect();
|
||||
|
||||
if sheet_files.is_empty() {
|
||||
log(" ⚠ 未找到工作表(空 XLSX)");
|
||||
return Ok(make_hit(path, "xlsx", Vec::new(), String::new(), None));
|
||||
}
|
||||
|
||||
// 3. 解析每个 sheet
|
||||
let mut text = String::new();
|
||||
for name in &sheet_files {
|
||||
if let Ok(mut f) = archive.by_name(name) {
|
||||
let mut s = String::new();
|
||||
use std::io::Read;
|
||||
let _ = f.read_to_string(&mut s);
|
||||
let rows = extract_cell_text(&s, &shared);
|
||||
for row in rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i > 0 { text.push('\t'); }
|
||||
text.push_str(cell);
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let kws = keywords_for("xlsx", &cfg.keyword);
|
||||
let m = Matcher::new(kws, &cfg.keyword);
|
||||
let hits = m.find(&text);
|
||||
Ok(make_hit(path, "xlsx", hits, text, None))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 sharedStrings.xml:<sst><si><t>...</t></si>...</sst>
|
||||
fn parse_shared_strings(xml: &str) -> Vec<String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_t = false;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
|
||||
if local == "t" { in_t = true; current.clear(); }
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if in_t {
|
||||
let s = e.unescape().unwrap_or_default().to_string();
|
||||
current.push_str(&s);
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
|
||||
if local == "t" { in_t = false; }
|
||||
else if local == "si" { out.push(std::mem::take(&mut current)); }
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 从 sheet XML 中提取所有行的文本。
|
||||
/// `<row><c t="s"><v>5</v></c><c><v>1.23</v></c></row>`
|
||||
/// t="s" 表示值是 sharedStrings 的索引;否则是 inline str / number。
|
||||
fn extract_cell_text(xml: &str, shared: &[String]) -> Vec<Vec<String>> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
reader.config_mut().trim_text(false);
|
||||
let mut rows: Vec<Vec<String>> = Vec::new();
|
||||
let mut cur_row: Vec<String> = Vec::new();
|
||||
let mut cur_cell = String::new();
|
||||
let mut cell_type: Option<String> = None;
|
||||
let mut in_v = false;
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) => {
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
|
||||
match local.as_str() {
|
||||
"row" => cur_row.clear(),
|
||||
"c" => {
|
||||
cur_cell.clear();
|
||||
cell_type = None;
|
||||
for attr in e.attributes().flatten() {
|
||||
let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string();
|
||||
if key == "t" {
|
||||
cell_type = Some(String::from_utf8_lossy(&attr.value).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"v" => in_v = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if in_v {
|
||||
let s = e.unescape().unwrap_or_default().to_string();
|
||||
cur_cell.push_str(&s);
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => {
|
||||
let local = String::from_utf8_lossy(e.local_name().as_ref()).to_string();
|
||||
match local.as_str() {
|
||||
"v" => in_v = false,
|
||||
"c" => {
|
||||
let value = match cell_type.as_deref() {
|
||||
Some("s") => {
|
||||
// sharedStrings index
|
||||
shared.get(cur_cell.parse::<usize>().unwrap_or(0))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
Some("inlineStr") => {
|
||||
// 简单处理:取当前 cur_cell(实际 inlineStr 嵌在 <is><t> 里)
|
||||
// 为简化,跳过复杂的 inlineStr 解析;99% 场景 sharedStrings 已覆盖
|
||||
cur_cell.clone()
|
||||
}
|
||||
_ => cur_cell.clone(),
|
||||
};
|
||||
cur_row.push(value);
|
||||
cell_type = None;
|
||||
}
|
||||
"row" => rows.push(std::mem::take(&mut cur_row)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
// 收尾
|
||||
if !cur_row.is_empty() { rows.push(cur_row); }
|
||||
rows
|
||||
}
|
||||
44
src/main.rs
Normal file
44
src/main.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// 隐藏控制台(双保险:build.rs 也有 /SUBSYSTEM:WINDOWS)
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
mod inspect;
|
||||
mod matcher;
|
||||
mod privilege;
|
||||
mod report;
|
||||
mod scan;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::privilege::ensure_admin;
|
||||
use crate::utils::logger::init_logger;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// 单次 UAC 提升:未以管理员运行时通过 runas 重启当前进程
|
||||
if let Err(e) = ensure_admin() {
|
||||
// 提示但不强制退出(用户可能在调试)
|
||||
tracing::warn!("UAC 提升失败:{}", e);
|
||||
}
|
||||
|
||||
init_logger();
|
||||
|
||||
let viewport = eframe::egui::ViewportBuilder::default()
|
||||
.with_title("涉密文件自检工具")
|
||||
.with_inner_size([1280.0, 800.0])
|
||||
.with_min_inner_size([960.0, 640.0]);
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport,
|
||||
vsync: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"涉密文件自检工具",
|
||||
options,
|
||||
Box::new(|cc| Box::new(App::new(cc))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("eframe 启动失败:{}", e))
|
||||
}
|
||||
29
src/matcher/hash.rs
Normal file
29
src/matcher/hash.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// 文件指纹(用于去重 / 白名单)
|
||||
use std::path::Path;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub fn fingerprint(path: &Path) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(path.to_string_lossy().as_bytes());
|
||||
if let Ok(m) = std::fs::metadata(path) {
|
||||
h.update(m.len().to_le_bytes());
|
||||
if let Ok(modified) = m.modified() {
|
||||
if let Ok(d) = modified.duration_since(std::time::UNIX_EPOCH) {
|
||||
h.update(d.as_secs().to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
let digest = h.finalize();
|
||||
hex::encode(&digest[..8])
|
||||
}
|
||||
|
||||
pub mod hex {
|
||||
pub fn encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
80
src/matcher/keywords.rs
Normal file
80
src/matcher/keywords.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
// 关键词匹配
|
||||
use std::collections::HashSet;
|
||||
|
||||
use aho_corasick::AhoCorasick;
|
||||
use regex::RegexSet;
|
||||
|
||||
use crate::config::{KeywordSettings, ScreenshotMode};
|
||||
|
||||
/// 构建匹配器
|
||||
pub struct Matcher {
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
ac: Option<AhoCorasick>,
|
||||
regex: Option<RegexSet>,
|
||||
patterns: Vec<String>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn new(patterns: Vec<String>, k: &KeywordSettings) -> Self {
|
||||
let patterns = if k.case_sensitive { patterns } else { patterns.iter().map(|p| p.to_lowercase()).collect() };
|
||||
let (ac, regex) = if k.use_regex {
|
||||
let r = RegexSet::new(&patterns).ok();
|
||||
(None, r)
|
||||
} else {
|
||||
let a = AhoCorasick::builder()
|
||||
.ascii_case_insensitive(!k.case_sensitive)
|
||||
.build(&patterns)
|
||||
.ok();
|
||||
(a, None)
|
||||
};
|
||||
Self { case_sensitive: k.case_sensitive, whole_word: k.whole_word, ac, regex, patterns }
|
||||
}
|
||||
|
||||
/// 在文本中找出所有命中的关键词(去重)
|
||||
pub fn find(&self, text: &str) -> Vec<MatchHit> {
|
||||
let text_norm = if self.case_sensitive { text.to_string() } else { text.to_lowercase() };
|
||||
let mut hits: HashSet<String> = HashSet::new();
|
||||
if let Some(ac) = &self.ac {
|
||||
for m in ac.find_iter(&text_norm) {
|
||||
let pat = self.patterns[m.pattern()].clone();
|
||||
if self.whole_word && !is_whole_word(&text_norm, m.start(), m.end()) { continue; }
|
||||
hits.insert(pat);
|
||||
}
|
||||
} else if let Some(re) = &self.regex {
|
||||
for idx in re.matches(&text_norm).into_iter() {
|
||||
hits.insert(self.patterns[idx].clone());
|
||||
}
|
||||
}
|
||||
hits.into_iter().map(|p| MatchHit { keyword: p }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchHit {
|
||||
pub keyword: String,
|
||||
}
|
||||
|
||||
fn is_whole_word(s: &str, start: usize, end: usize) -> bool {
|
||||
let before = s[..start].chars().last();
|
||||
let after = s[end..].chars().next();
|
||||
fn is_word(c: char) -> bool { c.is_alphanumeric() || c == '_' }
|
||||
!before.map(is_word).unwrap_or(false) && !after.map(is_word).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 合并全局 + 类型追加,返回该类型要用的关键词列表
|
||||
pub fn keywords_for(kind: &str, k: &KeywordSettings) -> Vec<String> {
|
||||
let mut v = k.global.clone();
|
||||
match kind {
|
||||
"doc" => v.extend(k.doc_extra.clone()),
|
||||
"docx" => v.extend(k.docx_extra.clone()),
|
||||
"pdf" => v.extend(k.pdf_extra.clone()),
|
||||
"xlsx" => v.extend(k.xlsx_extra.clone()),
|
||||
_ => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// 引用以避免 warning
|
||||
#[allow(dead_code)]
|
||||
fn _unused(_m: ScreenshotMode) {}
|
||||
3
src/matcher/mod.rs
Normal file
3
src/matcher/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// 关键词匹配模块
|
||||
pub mod hash;
|
||||
pub mod keywords;
|
||||
54
src/privilege.rs
Normal file
54
src/privilege.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
// UAC 单次提升:通过 ShellExecuteExW "runas" 重新启动当前 exe
|
||||
// 必须先检查是否已是管理员——否则会触发无限重启链
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use windows_sys::Win32::UI::Shell::{
|
||||
IsUserAnAdmin, ShellExecuteExW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW,
|
||||
};
|
||||
|
||||
/// 若非管理员则触发 UAC 提权并重启当前进程。
|
||||
/// - 已是管理员:直接返回 Ok
|
||||
/// - 非管理员:触发 runas,成功则退出当前进程,由新进程接手
|
||||
/// - UAC 被取消:返回 Err,调用方决定是否继续
|
||||
pub fn ensure_admin() -> anyhow::Result<()> {
|
||||
// 1. 先检查是否已是管理员
|
||||
unsafe {
|
||||
if IsUserAnAdmin() != 0 {
|
||||
tracing::info!("当前已是管理员,跳过 UAC 提权");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("当前非管理员,准备 UAC 提权……");
|
||||
let exe: PathBuf = std::env::current_exe()?;
|
||||
let exe_str = exe.as_os_str();
|
||||
|
||||
// 命令行参数:保留 argv
|
||||
let args_str = std::env::args().skip(1).collect::<Vec<_>>().join(" ");
|
||||
|
||||
// ShellExecuteExW 需要宽字符
|
||||
let mut file: Vec<u16> = exe_str.encode_wide().chain(std::iter::once(0)).collect();
|
||||
let mut verb: Vec<u16> = "runas".encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut params: Vec<u16> = args_str.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
unsafe {
|
||||
let mut info: SHELLEXECUTEINFOW = std::mem::zeroed();
|
||||
info.cbSize = std::mem::size_of::<SHELLEXECUTEINFOW>() as u32;
|
||||
info.fMask = SEE_MASK_FLAG_NO_UI | SEE_MASK_NOCLOSEPROCESS;
|
||||
info.lpVerb = verb.as_ptr();
|
||||
info.lpFile = file.as_ptr();
|
||||
info.lpParameters = params.as_ptr();
|
||||
info.nShow = 1; // SW_SHOWNORMAL
|
||||
|
||||
let result = ShellExecuteExW(&mut info);
|
||||
if result == 0 || (info.hInstApp as isize) <= 32 {
|
||||
// UAC 失败或被取消:不退出当前进程,让用户以非管理员身份继续运行
|
||||
return Err(anyhow::anyhow!("UAC 提权失败或被取消(hInstApp={})", info.hInstApp as isize));
|
||||
}
|
||||
}
|
||||
|
||||
// 提权成功,新进程已被启动;当前进程退出
|
||||
tracing::info!("UAC 提权成功,旧进程退出");
|
||||
std::process::exit(0);
|
||||
}
|
||||
78
src/report/html.rs
Normal file
78
src/report/html.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
// 报告写入(HTML)
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::report::model::Report;
|
||||
|
||||
pub fn write(cfg: &AppConfig, r: &Report) -> anyhow::Result<()> {
|
||||
if !cfg.report.formats.contains(&crate::config::ReportFormat::Html) { return Ok(()); }
|
||||
let dir = &cfg.report.output_dir;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = r.scan_id.replace([':', 'T', 'Z', '+', '-'], "").chars().take(15).collect::<String>();
|
||||
let p = dir.join(format!("{}-{}.html", cfg.report.file_prefix, name));
|
||||
|
||||
let mut findings_html = String::new();
|
||||
if r.findings.is_empty() {
|
||||
findings_html.push_str("<tr><td colspan='5' style='color:#2e7d32'><b>未发现敏感词</b></td></tr>");
|
||||
} else {
|
||||
for f in &r.findings {
|
||||
findings_html.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{:.2}</td><td>{}</td></tr>",
|
||||
html_escape(&f.path.display().to_string()),
|
||||
html_escape(&f.kind),
|
||||
html_escape(&f.matched.join(", ")),
|
||||
f.confidence,
|
||||
f.screenshot.as_ref().map(|s| html_escape(&s.display().to_string())).unwrap_or_else(|| "-".into()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let body = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh"><head><meta charset="utf-8"><title>涉密抽检报告</title>
|
||||
<style>
|
||||
body{{font-family:Segoe UI,Arial,sans-serif;margin:24px;background:#f5f5f5;color:#212121}}
|
||||
h1{{color:#1976d2}}
|
||||
.card{{background:#fff;border-radius:8px;padding:16px;margin-bottom:12px;box-shadow:0 2px 4px rgba(0,0,0,.08)}}
|
||||
table{{border-collapse:collapse;width:100%}}
|
||||
th,td{{border:1px solid #ddd;padding:8px;text-align:left}}
|
||||
th{{background:#e3f2fd}}
|
||||
.ok{{color:#2e7d32;font-weight:bold}}
|
||||
.danger{{color:#c62828;font-weight:bold}}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>涉密文件自检报告</h1>
|
||||
<div class="card">
|
||||
<div>扫描 ID:{}</div>
|
||||
<div>开始:{}</div>
|
||||
<div>结束:{}</div>
|
||||
<div>机器:{} 用户:{}</div>
|
||||
<div>抽检:{} 份 / 命中:{} 份</div>
|
||||
<div class="{}">结论:{}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>命中清单</h2>
|
||||
<table>
|
||||
<tr><th>文件</th><th>类型</th><th>命中关键词</th><th>置信度</th><th>截图</th></tr>
|
||||
{}
|
||||
</table>
|
||||
</div>
|
||||
</body></html>
|
||||
"#,
|
||||
r.scan_id, r.started_at, r.finished_at, r.machine, r.user,
|
||||
r.total, r.hit,
|
||||
if r.hit > 0 { "danger" } else { "ok" },
|
||||
if r.hit > 0 { "本机存在疑似含敏感词文档" } else { "本机未发现敏感词" },
|
||||
findings_html
|
||||
);
|
||||
|
||||
std::fs::write(p, body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _path(p: &Path) { let _ = p; }
|
||||
19
src/report/json.rs
Normal file
19
src/report/json.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// 报告写入(JSON)
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::report::model::Report;
|
||||
|
||||
pub fn write(cfg: &AppConfig, r: &Report) -> anyhow::Result<()> {
|
||||
if !cfg.report.formats.contains(&crate::config::ReportFormat::Json) { return Ok(()); }
|
||||
let dir = &cfg.report.output_dir;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = r.scan_id.replace([':', 'T', 'Z', '+', '-'], "").chars().take(15).collect::<String>();
|
||||
let p = dir.join(format!("{}-{}.json", cfg.report.file_prefix, name));
|
||||
let s = serde_json::to_string_pretty(r)?;
|
||||
std::fs::write(p, s)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _path(p: &Path) { let _ = p; }
|
||||
5
src/report/mod.rs
Normal file
5
src/report/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// 报告模块
|
||||
pub mod html;
|
||||
pub mod json;
|
||||
pub mod model;
|
||||
pub mod png;
|
||||
17
src/report/model.rs
Normal file
17
src/report/model.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 报告数据模型:Finding 直接复用 inspect::Finding,避免两套重复类型
|
||||
pub use crate::inspect::Finding;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub scan_id: String,
|
||||
pub started_at: String,
|
||||
pub finished_at: String,
|
||||
pub machine: String,
|
||||
pub user: String,
|
||||
pub total: usize,
|
||||
pub hit: usize,
|
||||
pub findings: Vec<Finding>,
|
||||
pub samples: Vec<String>,
|
||||
}
|
||||
23
src/report/png.rs
Normal file
23
src/report/png.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// 报告写入(PNG 组图):把所有截图拼成一张大图(简化版:每张截图保存为独立 PNG)
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::report::model::Report;
|
||||
|
||||
pub fn write(cfg: &AppConfig, r: &Report) -> anyhow::Result<()> {
|
||||
if !cfg.report.formats.contains(&crate::config::ReportFormat::Png) { return Ok(()); }
|
||||
if !cfg.report.include_screenshots { return Ok(()); }
|
||||
let dir = &cfg.report.output_dir.join("screenshots");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
// 把每张截图拷贝过去(它们本来就在 temp)
|
||||
for (i, f) in r.findings.iter().enumerate() {
|
||||
if let Some(src) = &f.screenshot {
|
||||
let dst = dir.join(format!("{}-{}{}", i + 1, f.path.file_name().and_then(|s| s.to_str()).unwrap_or("file"), ".png"));
|
||||
let _ = std::fs::copy(src, dst);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _path(p: &Path) { let _ = p; }
|
||||
44
src/scan/filter.rs
Normal file
44
src/scan/filter.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// 过滤:白名单、隐藏、系统、扩展名、大小
|
||||
use std::path::Path;
|
||||
|
||||
pub fn is_whitelisted(p: &Path, whitelist: &[std::path::PathBuf]) -> bool {
|
||||
for w in whitelist {
|
||||
if p.starts_with(w) { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn is_hidden(p: &Path) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if let Ok(md) = p.metadata() {
|
||||
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
|
||||
return md.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_hidden(p: &Path) -> bool {
|
||||
p.file_name().and_then(|s| s.to_str()).map(|s| s.starts_with('.')).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn is_system(p: &Path) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if let Ok(md) = p.metadata() {
|
||||
const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
|
||||
return md.file_attributes() & FILE_ATTRIBUTE_SYSTEM != 0;
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_system(_p: &Path) -> bool { false }
|
||||
|
||||
pub fn extension_allowed(p: &Path, allowlist: &[String]) -> bool {
|
||||
if allowlist.is_empty() { return true; }
|
||||
let ext = match p.extension().and_then(|s| s.to_str()) {
|
||||
Some(e) => e.to_ascii_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
allowlist.iter().any(|a| a.eq_ignore_ascii_case(&ext))
|
||||
}
|
||||
5
src/scan/mod.rs
Normal file
5
src/scan/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
// 扫描模块
|
||||
pub mod filter;
|
||||
pub mod runner;
|
||||
pub mod sampler;
|
||||
pub mod walker;
|
||||
239
src/scan/runner.rs
Normal file
239
src/scan/runner.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
// 抽检主流程:扫描所有候选 → 按配置抽样 → 串行 inspect → 报告
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::app::RunState;
|
||||
use crate::config::AppConfig;
|
||||
use crate::inspect::{self, Finding, Inspector};
|
||||
use crate::report::model::Report;
|
||||
use crate::scan::sampler::{self, FileKind, SampleItem};
|
||||
use crate::scan::walker;
|
||||
|
||||
/// 调度入口
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run(
|
||||
cfg: AppConfig,
|
||||
progress: Arc<AtomicUsize>,
|
||||
total: Arc<AtomicUsize>,
|
||||
hit_count: Arc<AtomicUsize>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
scan_scanned: Arc<AtomicUsize>,
|
||||
scan_found: Arc<AtomicUsize>,
|
||||
scan_current_dir: Arc<Mutex<String>>,
|
||||
current_file: Arc<Mutex<Option<String>>>,
|
||||
current_step: Arc<Mutex<String>>,
|
||||
start_instant: Instant,
|
||||
elapsed_ms: Arc<AtomicU64>,
|
||||
type_done: Arc<Mutex<HashMap<FileKind, usize>>>,
|
||||
type_total: Arc<Mutex<HashMap<FileKind, usize>>>,
|
||||
log_lines: Arc<Mutex<Vec<String>>>,
|
||||
samples_slot: Arc<Mutex<Vec<SampleItem>>>,
|
||||
report_slot: Arc<Mutex<Option<Report>>>,
|
||||
state_slot: Arc<Mutex<RunState>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let push_log = {
|
||||
let log_lines = Arc::clone(&log_lines);
|
||||
move |s: String| {
|
||||
tracing::info!("{}", s);
|
||||
if let Ok(mut g) = log_lines.lock() {
|
||||
g.push(s);
|
||||
}
|
||||
}
|
||||
};
|
||||
let set_state = {
|
||||
let state_slot = Arc::clone(&state_slot);
|
||||
move |s: RunState| {
|
||||
if let Ok(mut g) = state_slot.lock() { *g = s; }
|
||||
}
|
||||
};
|
||||
let set_current = {
|
||||
let cur = Arc::clone(¤t_file);
|
||||
move |p: Option<String>| { if let Ok(mut g) = cur.lock() { *g = p; } }
|
||||
};
|
||||
let set_step = {
|
||||
let step = Arc::clone(¤t_step);
|
||||
move |s: String| { if let Ok(mut g) = step.lock() { *g = s; } }
|
||||
};
|
||||
|
||||
// 启动 Umi-OCR(若需要)
|
||||
if let Err(e) = inspect::umi_ocr::ensure_started(&cfg.viewer) {
|
||||
push_log(format!("⚠ Umi-OCR 启动失败(可继续但 OCR 会失败):{}", e));
|
||||
}
|
||||
|
||||
// —— 阶段 1:扫描全盘所有候选文件 ——
|
||||
set_state(RunState::Scanning);
|
||||
set_step("🔍 阶段 1/3:正在扫描全盘候选文件……".into());
|
||||
push_log("═══ 阶段 1:扫描全盘所有候选文件 ═══".into());
|
||||
let scan_started = Instant::now();
|
||||
|
||||
// 进度回调:walker 每个目录+每个文件都会调
|
||||
let scan_scanned_cb = Arc::clone(&scan_scanned);
|
||||
let scan_found_cb = Arc::clone(&scan_found);
|
||||
let scan_dir_cb = Arc::clone(&scan_current_dir);
|
||||
let mut on_progress = |_scanned: usize, found: usize, dir: &std::path::Path| {
|
||||
scan_scanned_cb.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
scan_found_cb.store(found, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Ok(mut g) = scan_dir_cb.lock() { *g = dir.display().to_string(); }
|
||||
};
|
||||
let candidates = walker::walk(&cfg.scan, &cancel, |s| push_log(s.to_string()), &mut on_progress);
|
||||
let scan_ms = scan_started.elapsed().as_millis();
|
||||
let candidates_count = candidates.len();
|
||||
push_log(format!(
|
||||
"✔ 扫描完成:访问 {} 个文件,命中候选 {} 份(用时 {} ms)",
|
||||
scan_scanned.load(Ordering::Relaxed),
|
||||
candidates_count,
|
||||
scan_ms
|
||||
));
|
||||
|
||||
if candidates_count == 0 {
|
||||
push_log("⚠ 没有可抽检的文件,请检查扫描范围/白名单".into());
|
||||
set_state(RunState::Done);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// —— 阶段 2:按配置抽样 ——
|
||||
set_state(RunState::Sampling);
|
||||
let mode_str = match cfg.inspect.sample_mode {
|
||||
crate::config::SampleMode::Count => format!("取 {} 份", cfg.inspect.sample_count),
|
||||
crate::config::SampleMode::Percent => format!("取 {}%", cfg.inspect.sample_percent),
|
||||
crate::config::SampleMode::All => "取全部".to_string(),
|
||||
};
|
||||
set_step(format!("🎲 阶段 2/3:正在抽样({})……", mode_str));
|
||||
push_log("═══ 阶段 2:抽样决策 ═══".into());
|
||||
push_log(format!(" 抽样模式:{}", mode_str));
|
||||
push_log(format!(" 候选总数:{} 份", candidates_count));
|
||||
let samples = sampler::sample(&candidates, &cfg.inspect);
|
||||
|
||||
// 按类型统计抽样结果
|
||||
let mut by_kind_total: HashMap<FileKind, usize> = HashMap::new();
|
||||
for it in &samples {
|
||||
*by_kind_total.entry(it.kind).or_insert(0) += 1;
|
||||
}
|
||||
push_log(format!(" 最终抽检:{} 份", samples.len()));
|
||||
for k in FileKind::all() {
|
||||
if let Some(n) = by_kind_total.get(&k) {
|
||||
if *n > 0 {
|
||||
push_log(format!(" • {} :{} 份", k.as_str(), n));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut g) = type_total.lock() { *g = by_kind_total; }
|
||||
if let Ok(mut g) = type_done.lock() {
|
||||
g.clear();
|
||||
for k in FileKind::all() { g.entry(k).or_insert(0); }
|
||||
}
|
||||
if let Ok(mut g) = samples_slot.lock() { *g = samples.clone(); }
|
||||
total.store(samples.len(), Ordering::Relaxed);
|
||||
progress.store(0, Ordering::Relaxed);
|
||||
hit_count.store(0, Ordering::Relaxed);
|
||||
|
||||
// —— 阶段 3:抽检 ——
|
||||
set_state(RunState::Inspecting);
|
||||
let mut findings: Vec<Finding> = Vec::new();
|
||||
let started_at = chrono::Local::now().to_rfc3339();
|
||||
for (i, item) in samples.iter().enumerate() {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
push_log("⏹ 已取消".into());
|
||||
set_state(RunState::Cancelled);
|
||||
break;
|
||||
}
|
||||
let p = item.path.display().to_string();
|
||||
set_current(Some(p.clone()));
|
||||
let kind_str = item.kind.as_str();
|
||||
set_step(format!("🔬 阶段 3/3:正在抽检 {}/{}({})", i + 1, samples.len(), kind_str.to_uppercase()));
|
||||
let size = std::fs::metadata(&item.path).map(|m| m.len()).unwrap_or(0);
|
||||
push_log(format!("\n→ [{}/{}] ({}) {} ({} bytes)", i + 1, samples.len(), kind_str.to_uppercase(), p, size));
|
||||
let file_start = Instant::now();
|
||||
|
||||
// 选择 Inspector
|
||||
let inspector: Box<dyn Inspector> = match item.kind {
|
||||
FileKind::Docx => Box::new(inspect::docx_inspector::DocxInspector),
|
||||
FileKind::Doc => Box::new(inspect::doc_inspector::DocInspector::new(cfg.clone())),
|
||||
FileKind::Pdf => Box::new(inspect::pdf_inspector::PdfInspector::new(cfg.clone())),
|
||||
FileKind::Xlsx => Box::new(inspect::xlsx_inspector::XlsxInspector),
|
||||
};
|
||||
|
||||
let log_closure = |s: &str| push_log(s.to_string());
|
||||
let fut = inspector.inspect(&item.path, &cfg, &cancel, &log_closure);
|
||||
let res = match tokio::time::timeout(std::time::Duration::from_secs(cfg.inspect.per_file_timeout_sec), fut).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!("单文件超时(>{}s)", cfg.inspect.per_file_timeout_sec)),
|
||||
};
|
||||
let file_ms = file_start.elapsed().as_millis();
|
||||
match res {
|
||||
Ok(f) => {
|
||||
if f.is_hit() {
|
||||
let kws = f.matched.join("、");
|
||||
push_log(format!("✘ 命中:{}(用时 {} ms)", kws, file_ms));
|
||||
findings.push(f);
|
||||
hit_count.fetch_add(1, Ordering::Relaxed);
|
||||
if cfg.inspect.stop_on_first_hit { break; }
|
||||
} else {
|
||||
push_log(format!("✔ 未命中(用时 {} ms)", file_ms));
|
||||
}
|
||||
}
|
||||
Err(e) => push_log(format!("✘ 错误:{}(用时 {} ms)", e, file_ms)),
|
||||
}
|
||||
|
||||
// 更新每类完成计数
|
||||
if let Ok(mut g) = type_done.lock() {
|
||||
*g.entry(item.kind).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
progress.store(i + 1, Ordering::Relaxed);
|
||||
elapsed_ms.store(start_instant.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
}
|
||||
set_current(None);
|
||||
set_step(String::new());
|
||||
|
||||
set_state(RunState::Reporting);
|
||||
set_step("📝 正在生成报告……".into());
|
||||
let finished_at = chrono::Local::now().to_rfc3339();
|
||||
let machine = hostname();
|
||||
let user = std::env::var("USERNAME").unwrap_or_default();
|
||||
let report = Report {
|
||||
scan_id: started_at.clone(),
|
||||
started_at,
|
||||
finished_at,
|
||||
machine,
|
||||
user,
|
||||
total: samples.len(),
|
||||
hit: findings.len(),
|
||||
findings,
|
||||
samples: samples.iter().map(|s| s.path.display().to_string()).collect(),
|
||||
};
|
||||
|
||||
if let Err(e) = crate::report::html::write(&cfg, &report) {
|
||||
push_log(format!("✘ 写 HTML 报告失败:{}", e));
|
||||
} else {
|
||||
push_log("✔ 已写 HTML 报告".into());
|
||||
}
|
||||
if let Err(e) = crate::report::json::write(&cfg, &report) {
|
||||
push_log(format!("✘ 写 JSON 报告失败:{}", e));
|
||||
} else {
|
||||
push_log("✔ 已写 JSON 报告".into());
|
||||
}
|
||||
if let Err(e) = crate::report::png::write(&cfg, &report) {
|
||||
push_log(format!("✘ 写 PNG 组图失败:{}", e));
|
||||
} else {
|
||||
push_log("✔ 已写 PNG 组图".into());
|
||||
}
|
||||
push_log(format!("📁 报告目录:{}", cfg.report.output_dir.display()));
|
||||
if let Ok(mut g) = report_slot.lock() { *g = Some(report); }
|
||||
|
||||
set_step(String::new());
|
||||
set_state(RunState::Done);
|
||||
elapsed_ms.store(start_instant.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
sysinfo::System::host_name().unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
// 供 Result/Err 引用避免 warning
|
||||
#[allow(dead_code)]
|
||||
fn _pathbuf_marker(_p: PathBuf) {}
|
||||
123
src/scan/sampler.rs
Normal file
123
src/scan/sampler.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
// 抽检:随机 / 分层 / 类型配额
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::SeedableRng;
|
||||
|
||||
use crate::config::{InspectSettings, SampleMode, SampleStrategy};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SampleItem {
|
||||
pub path: PathBuf,
|
||||
pub kind: FileKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum FileKind {
|
||||
Doc,
|
||||
Docx,
|
||||
Pdf,
|
||||
Xlsx,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
pub fn from_path(p: &std::path::Path) -> Option<Self> {
|
||||
match p.extension()?.to_str()?.to_ascii_lowercase().as_str() {
|
||||
"doc" => Some(FileKind::Doc),
|
||||
"docx" => Some(FileKind::Docx),
|
||||
"pdf" => Some(FileKind::Pdf),
|
||||
"xlsx" => Some(FileKind::Xlsx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FileKind::Doc => "doc",
|
||||
FileKind::Docx => "docx",
|
||||
FileKind::Pdf => "pdf",
|
||||
FileKind::Xlsx => "xlsx",
|
||||
}
|
||||
}
|
||||
pub fn all() -> [FileKind; 4] {
|
||||
[FileKind::Doc, FileKind::Docx, FileKind::Pdf, FileKind::Xlsx]
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 sample_mode + 全量候选数,计算最终抽样数
|
||||
fn compute_target(candidates: usize, s: &InspectSettings) -> usize {
|
||||
if candidates == 0 { return 0; }
|
||||
match s.sample_mode {
|
||||
SampleMode::Count => s.sample_count.max(1).min(candidates),
|
||||
SampleMode::Percent => {
|
||||
let p = s.sample_percent.clamp(0.1, 100.0) as f64;
|
||||
let n = ((candidates as f64) * p / 100.0).round() as usize;
|
||||
n.max(1).min(candidates)
|
||||
}
|
||||
SampleMode::All => candidates,
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计各类型候选数(用于显示和配额上限)
|
||||
pub fn count_by_kind(items: &[SampleItem]) -> std::collections::HashMap<FileKind, usize> {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
for it in items {
|
||||
*m.entry(it.kind).or_insert(0) += 1;
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// 从候选路径中按策略抽样
|
||||
pub fn sample(candidates: &[PathBuf], s: &InspectSettings) -> Vec<SampleItem> {
|
||||
let items: Vec<SampleItem> = candidates.iter()
|
||||
.filter_map(|p| FileKind::from_path(p).map(|k| SampleItem { path: p.clone(), kind: k }))
|
||||
.collect();
|
||||
if items.is_empty() { return items; }
|
||||
|
||||
let mut rng = rand::rngs::StdRng::from_entropy();
|
||||
let target = compute_target(items.len(), s);
|
||||
|
||||
match s.strategy {
|
||||
SampleStrategy::Random => {
|
||||
let mut pool = items.clone();
|
||||
pool.shuffle(&mut rng);
|
||||
pool.into_iter().take(target).collect()
|
||||
}
|
||||
SampleStrategy::Stratified => {
|
||||
// 按所在目录(父目录的前两段)分层
|
||||
use std::collections::BTreeMap;
|
||||
let mut groups: BTreeMap<String, Vec<SampleItem>> = BTreeMap::new();
|
||||
for it in items.iter() {
|
||||
let key = it.path.ancestors().nth(2).map(|p| p.display().to_string()).unwrap_or_default();
|
||||
groups.entry(key).or_default().push(it.clone());
|
||||
}
|
||||
let per_group = std::cmp::max(1, target / std::cmp::max(1, groups.len()));
|
||||
let mut out: Vec<SampleItem> = Vec::new();
|
||||
for (_, mut g) in groups {
|
||||
g.shuffle(&mut rng);
|
||||
out.extend(g.into_iter().take(per_group));
|
||||
}
|
||||
out.shuffle(&mut rng);
|
||||
out.into_iter().take(target).collect()
|
||||
}
|
||||
SampleStrategy::Quota => {
|
||||
// 类型配额模式:每个类型按配额取
|
||||
let mut by_kind: std::collections::HashMap<FileKind, Vec<SampleItem>> = std::collections::HashMap::new();
|
||||
for it in items { by_kind.entry(it.kind).or_default().push(it); }
|
||||
for v in by_kind.values_mut() { v.shuffle(&mut rng); }
|
||||
let mut out: Vec<SampleItem> = Vec::new();
|
||||
if s.doc_quota > 0 { if let Some(v) = by_kind.get_mut(&FileKind::Doc) { out.extend(v.iter().take(s.doc_quota).cloned()); v.drain(..s.doc_quota.min(v.len())); } }
|
||||
if s.docx_quota > 0 { if let Some(v) = by_kind.get_mut(&FileKind::Docx) { out.extend(v.iter().take(s.docx_quota).cloned()); v.drain(..s.docx_quota.min(v.len())); } }
|
||||
if s.pdf_quota > 0 { if let Some(v) = by_kind.get_mut(&FileKind::Pdf) { out.extend(v.iter().take(s.pdf_quota).cloned()); v.drain(..s.pdf_quota.min(v.len())); } }
|
||||
if s.xlsx_quota > 0 { if let Some(v) = by_kind.get_mut(&FileKind::Xlsx) { out.extend(v.iter().take(s.xlsx_quota).cloned()); v.drain(..s.xlsx_quota.min(v.len())); } }
|
||||
// 配额剩余用 Random 补齐到 target
|
||||
if out.len() < target {
|
||||
let mut rest: Vec<SampleItem> = by_kind.into_values().flatten().collect();
|
||||
rest.shuffle(&mut rng);
|
||||
let need = target - out.len();
|
||||
out.extend(rest.into_iter().take(need));
|
||||
}
|
||||
out.shuffle(&mut rng);
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/scan/walker.rs
Normal file
115
src/scan/walker.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// 目录遍历:walkdir + 白名单剔除 + 扩展名/大小/隐藏/系统过滤
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::config::ScanSettings;
|
||||
use crate::scan::filter;
|
||||
|
||||
/// 遍历过程中每次回调:
|
||||
/// - scanned_so_far: 已扫描的文件总数(仅候选类型)
|
||||
/// - found_so_far: 当前累计已收纳的候选数
|
||||
/// - current_dir: 当前正在扫描的目录路径
|
||||
pub type ProgressFn<'a> = &'a mut dyn FnMut(usize, usize, &Path);
|
||||
|
||||
/// 遍历全盘,输出满足条件的文件路径
|
||||
pub fn walk(
|
||||
s: &ScanSettings,
|
||||
cancel: &AtomicBool,
|
||||
log: impl Fn(&str),
|
||||
progress: ProgressFn,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let scanned = AtomicUsize::new(0); // 已访问的文件数(仅作信息)
|
||||
let found = AtomicUsize::new(0); // 已收纳的候选数
|
||||
let last_dir_logged = std::sync::Mutex::new(String::new());
|
||||
let dirs_scanned = AtomicUsize::new(0);
|
||||
|
||||
// 全盘根:从所有可用盘符的根开始
|
||||
let roots: Vec<PathBuf> = if cfg!(windows) {
|
||||
list_drive_letters()
|
||||
} else {
|
||||
vec![PathBuf::from("/")]
|
||||
};
|
||||
log(&format!("🔍 扫描根:{:?}", roots));
|
||||
log(&format!(" 扩展名白名单:{:?}", s.extensions));
|
||||
log(&format!(" 白名单(不扫):{} 个目录", s.whitelist.len()));
|
||||
|
||||
for root in roots {
|
||||
if cancel.load(Ordering::Relaxed) { break; }
|
||||
let walker = WalkDir::new(&root)
|
||||
.follow_links(s.follow_symlinks)
|
||||
.max_depth(if s.max_depth == 0 { usize::MAX } else { s.max_depth as usize });
|
||||
for entry in walker.into_iter().filter_map(Result::ok) {
|
||||
if cancel.load(Ordering::Relaxed) { break; }
|
||||
let p = entry.path();
|
||||
// 跳过目录
|
||||
if !p.is_file() {
|
||||
if p.is_dir() {
|
||||
// 每 30 个目录打印一次(避免刷屏)
|
||||
let n = dirs_scanned.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n % 30 == 1 {
|
||||
log(&format!(" 📁 进入目录:{}", p.display()));
|
||||
if let Ok(mut last) = last_dir_logged.lock() {
|
||||
*last = p.display().to_string();
|
||||
}
|
||||
progress(
|
||||
scanned.load(Ordering::Relaxed),
|
||||
found.load(Ordering::Relaxed),
|
||||
p,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
scanned.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
if filter::is_whitelisted(p, &s.whitelist) { continue; }
|
||||
if !s.include_hidden && filter::is_hidden(p) { continue; }
|
||||
if !s.include_system && filter::is_system(p) { continue; }
|
||||
if !filter::extension_allowed(p, &s.extensions) { continue; }
|
||||
if s.min_size_kb > 0 {
|
||||
if let Ok(m) = std::fs::metadata(p) {
|
||||
if m.len() < s.min_size_kb * 1024 { continue; }
|
||||
}
|
||||
}
|
||||
found.fetch_add(1, Ordering::Relaxed);
|
||||
out.push(p.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
log(&format!(
|
||||
"✔ 扫描完成:访问 {} 个文件,命中候选 {} 份",
|
||||
scanned.load(Ordering::Relaxed),
|
||||
out.len()
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn list_drive_letters() -> Vec<PathBuf> {
|
||||
use windows_sys::Win32::Storage::FileSystem::GetLogicalDrives;
|
||||
let mask = unsafe { GetLogicalDrives() };
|
||||
let mut out = Vec::new();
|
||||
for i in 0..26u32 {
|
||||
if (mask >> i) & 1 == 1 {
|
||||
let letter = (b'A' + i as u8) as char;
|
||||
out.push(PathBuf::from(format!("{}:\\", letter)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn list_drive_letters() -> Vec<PathBuf> {
|
||||
vec![PathBuf::from("/")]
|
||||
}
|
||||
|
||||
/// 检测某路径是否在白名单中(不区分大小写,前缀匹配)
|
||||
pub fn is_in_whitelist(p: &Path, whitelist: &[PathBuf]) -> bool {
|
||||
for w in whitelist {
|
||||
if p.starts_with(w) { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
25
src/utils/logger.rs
Normal file
25
src/utils/logger.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
// 日志初始化:tracing + 文件输出
|
||||
use std::fs;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
pub fn init_logger() {
|
||||
let log_dir = crate::utils::paths::log_dir();
|
||||
let _ = fs::create_dir_all(&log_dir);
|
||||
|
||||
let file_appender = tracing_appender::rolling::daily(&log_dir, "selfcheck.log");
|
||||
let (file_writer, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
// 保留 guard 防止文件写入线程退出
|
||||
Box::leak(Box::new(_guard));
|
||||
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,secret_file_selfcheck=debug"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(fmt::layer().with_writer(file_writer).with_ansi(false))
|
||||
.with(fmt::layer().with_writer(std::io::stdout))
|
||||
.init();
|
||||
|
||||
tracing::info!("日志系统初始化完成;日志目录:{}", log_dir.display());
|
||||
}
|
||||
3
src/utils/mod.rs
Normal file
3
src/utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// 工具模块入口
|
||||
pub mod logger;
|
||||
pub mod paths;
|
||||
53
src/utils/paths.rs
Normal file
53
src/utils/paths.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// 路径工具:APPDATA、exe 同目录、临时目录
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 应用配置目录:%APPDATA%\secret-file-selfcheck\
|
||||
pub fn app_config_dir() -> PathBuf {
|
||||
if let Some(base) = dirs::config_dir() {
|
||||
base.join("secret-file-selfcheck")
|
||||
} else {
|
||||
PathBuf::from(".")
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置文件:%APPDATA%\secret-file-selfcheck\config.toml
|
||||
pub fn config_file() -> PathBuf {
|
||||
app_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// 日志目录:%APPDATA%\secret-file-selfcheck\logs\
|
||||
pub fn log_dir() -> PathBuf {
|
||||
app_config_dir().join("logs")
|
||||
}
|
||||
|
||||
/// exe 所在目录(用于探测同目录的 doclite.exe / Umi-OCR.exe / 字体)
|
||||
pub fn exe_dir() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
/// 临时目录:%TEMP%\secret-scan-XXXX\
|
||||
pub fn temp_dir(scan_id: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("secret-scan-{}", scan_id));
|
||||
p
|
||||
}
|
||||
|
||||
/// 报告输出目录(默认 exe 同目录 reports/)
|
||||
pub fn default_report_dir() -> PathBuf {
|
||||
exe_dir().join("reports")
|
||||
}
|
||||
|
||||
/// 探测同目录 Umi-OCR.exe
|
||||
pub fn detect_umi_ocr() -> Option<PathBuf> {
|
||||
let p = exe_dir().join("Umi-OCR.exe");
|
||||
if p.exists() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// 探测同目录 doclite.exe
|
||||
pub fn detect_doclite() -> Option<PathBuf> {
|
||||
let p = exe_dir().join("doclite.exe");
|
||||
if p.exists() { Some(p) } else { None }
|
||||
}
|
||||
Reference in New Issue
Block a user