feat(ui): 安全软件风格主题 + 三阶段进度/日志 + XLSX 支持

- 主面板:阶段1扫描全盘 → 阶段2抽样 → 阶段3抽检,每阶段独立进度条/已用时/分类型 chips

- 日志:按类型着色(命中红/未命中绿/警告黄/阶段青)

- 主题:暗绿底 + 鲜绿/青色强调,圆角胶囊按钮(material::security_dark)

- 抽检:SampleMode 枚举支持按份数/百分比/全部;设置页 C 组动态切换

- 抽检:XLSX 检查器(zip + quick-xml 解析 sharedStrings 与 sheet)

- 扫描:walker 进度回调(已访问、命中候选、当前目录)

- 兼容:quick-xml 0.36 使用 reader.config_mut().trim_text()

- 仓库:新增 .gitignore 忽略 venv/pyc/target/构建产物
This commit is contained in:
2026-06-10 12:20:25 +08:00
parent 31161d9a5f
commit 7e256c426f
43 changed files with 8529 additions and 59 deletions

40
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

66
Cargo.toml Normal file
View 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"

View File

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

View File

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

View 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; }

View 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
View 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
View 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,
}
}

View File

@@ -0,0 +1,3 @@
// 防止 mod.rs 中文件/子模块同名造成混乱
#[allow(dead_code)]
pub fn marker() {}

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

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
// 关键词匹配模块
pub mod hash;
pub mod keywords;

54
src/privilege.rs Normal file
View 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
View 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>机器:{} &nbsp; 用户:{}</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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
#[allow(dead_code)]
fn _path(p: &Path) { let _ = p; }

19
src/report/json.rs Normal file
View 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
View File

@@ -0,0 +1,5 @@
// 报告模块
pub mod html;
pub mod json;
pub mod model;
pub mod png;

17
src/report/model.rs Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
// 扫描模块
pub mod filter;
pub mod runner;
pub mod sampler;
pub mod walker;

239
src/scan/runner.rs Normal file
View 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(&current_file);
move |p: Option<String>| { if let Ok(mut g) = cur.lock() { *g = p; } }
};
let set_step = {
let step = Arc::clone(&current_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
View 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
View 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
View File

@@ -0,0 +1,316 @@
// 主面板:开始检测 + 扫描/抽样/抽检三阶段进度 + 实时日志
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Instant;
use eframe::egui;
use crate::app::{App, RunState};
use crate::scan::sampler::FileKind;
use crate::ui::material;
/// 主面板
pub fn draw(ui: &mut egui::Ui, app: &mut App) {
// 1. 主控制
material::group(ui, "主控制", |ui| {
ui.horizontal(|ui| {
let can_start = matches!(app.state, RunState::Idle | RunState::Done | RunState::Cancelled | RunState::Error(_));
let can_cancel = matches!(app.state, RunState::Scanning | RunState::Sampling | RunState::Inspecting | RunState::Reporting);
if ui.add_enabled(can_start, material::primary_button("▶ 开始检测")).clicked() {
start_inspection(app);
}
if ui.add_enabled(can_cancel, material::danger_button("⏹ 取消")).clicked() {
app.cancel_flag.store(true, Ordering::Relaxed);
app.state = RunState::Cancelled;
}
if ui.button("📂 打开报告目录").clicked() {
let _ = std::fs::create_dir_all(&app.config.report.output_dir);
let _ = open_in_explorer(&app.config.report.output_dir);
}
});
});
ui.add_space(6.0);
// 2. 状态卡片
material::group(ui, "当前状态", |ui| {
let state_str = match &app.state {
RunState::Idle => "● 空闲".to_string(),
RunState::Scanning => "🔍 阶段 1/3扫描全盘候选文件……".to_string(),
RunState::Sampling => "🎲 阶段 2/3抽样决策……".to_string(),
RunState::Inspecting => "🔬 阶段 3/3抽检文件……".to_string(),
RunState::Reporting => "📝 正在生成报告……".to_string(),
RunState::Done => "✔ 已完成".to_string(),
RunState::Cancelled => "⏹ 已取消".to_string(),
RunState::Error(e) => format!("✘ 出错:{}", e),
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&state_str).strong().size(16.0).color(material::PRIMARY_DARK));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let hits = app.hit_count.load(Ordering::Relaxed);
let badge = egui::RichText::new(format!("✘ 命中 {}", hits))
.strong().size(15.0)
.color(if hits > 0 { material::DANGER } else { material::ON_SURFACE_DIM });
ui.label(badge);
});
});
if let Ok(step) = app.current_step.lock() {
if !step.is_empty() {
ui.label(egui::RichText::new(&*step).color(material::PRIMARY_DARK));
}
}
});
ui.add_space(6.0);
// 3. 阶段 1扫描进度
material::group(ui, "阶段 1/3扫描全盘候选文件", |ui| {
let scanned = app.scan_scanned.load(Ordering::Relaxed);
let found = app.scan_found.load(Ordering::Relaxed);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("已访问 {} 个文件", scanned)).strong().size(15.0));
ui.separator();
ui.label(egui::RichText::new(format!("命中候选 {}", found)).strong().size(15.0).color(material::LIME));
});
// 不确定进度:扫描中时用动画效果
let is_scanning = matches!(app.state, RunState::Scanning);
let frac = if is_scanning { 0.0 } else { 1.0 };
let pb = egui::ProgressBar::new(frac)
.fill(material::PRIMARY)
.desired_width(ui.available_width())
.desired_height(18.0)
.animate(is_scanning);
ui.add(pb);
// 当前目录
if let Ok(d) = app.scan_current_dir.lock() {
if !d.is_empty() {
ui.add_space(2.0);
ui.horizontal(|ui| {
ui.label("📁 正在扫描:");
ui.label(egui::RichText::new(shorten_path(&d, 90)).monospace().color(material::ON_SURFACE));
});
}
}
});
ui.add_space(6.0);
// 4. 阶段 2/3抽样结果 + 抽检进度
material::group(ui, "阶段 2/3抽样结果 / 阶段 3/3抽检", |ui| {
let p = app.progress.load(Ordering::Relaxed);
let t = app.total.load(Ordering::Relaxed);
let frac = if t == 0 { 0.0 } else { p as f32 / t as f32 };
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("已抽检 {}/{}", p, t)).strong().size(16.0));
let elapsed_ms = app.elapsed_ms.load(Ordering::Relaxed);
if elapsed_ms > 0 {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(format!("已用时 {}", format_duration(elapsed_ms))).color(material::ON_SURFACE_DIM));
});
}
});
let is_inspecting = matches!(app.state, RunState::Inspecting | RunState::Sampling);
let pb = egui::ProgressBar::new(frac)
.show_percentage()
.fill(material::LIME)
.desired_width(ui.available_width())
.desired_height(20.0)
.animate(is_inspecting);
ui.add(pb);
// 类型分项进度chips
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
let done_map: HashMap<FileKind, usize> = app.type_done.lock().map(|g| g.clone()).unwrap_or_default();
let total_map: HashMap<FileKind, usize> = app.type_total.lock().map(|g| g.clone()).unwrap_or_default();
for k in FileKind::all() {
let done = *done_map.get(&k).unwrap_or(&0);
let total = *total_map.get(&k).unwrap_or(&0);
if total == 0 && done == 0 { continue; }
let color = kind_color(k);
let label = format!("{} {}/{}", kind_label(k), done, total);
let chip = egui::RichText::new(label).strong().size(13.0).color(material::BACKGROUND);
let frame = egui::Frame::none()
.fill(color)
.rounding(egui::Rounding::same(10.0))
.inner_margin(egui::Margin::symmetric(8.0, 3.0));
frame.show(ui, |ui| { ui.label(chip); });
}
});
// 当前正在处理
if let Ok(cur) = app.current_file.lock() {
if let Some(f) = cur.as_ref() {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("📄 正在处理:");
ui.label(egui::RichText::new(shorten_path(f, 100)).monospace().color(material::PRIMARY_DARK));
});
}
}
});
ui.add_space(6.0);
// 5. 实时日志
material::group(ui, "实时日志", |ui| {
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
.auto_shrink([false, false])
.max_height(380.0)
.show(ui, |ui| {
if let Ok(lines) = app.log_lines.lock() {
for line in lines.iter() {
let (color, _) = classify_log(line);
ui.label(egui::RichText::new(line).monospace().color(color));
}
}
});
});
}
fn kind_label(k: FileKind) -> &'static str {
match k {
FileKind::Doc => "📄 DOC",
FileKind::Docx => "📝 DOCX",
FileKind::Pdf => "📕 PDF",
FileKind::Xlsx => "📊 XLSX",
}
}
fn kind_color(k: FileKind) -> egui::Color32 {
match k {
FileKind::Doc => egui::Color32::from_rgb(0, 150, 200),
FileKind::Docx => material::PRIMARY,
FileKind::Pdf => egui::Color32::from_rgb(255, 100, 100),
FileKind::Xlsx => material::LIME,
}
}
fn shorten_path(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars { return s.to_string(); }
let keep = max_chars.saturating_sub(3) / 2;
let head: String = s.chars().take(keep).collect();
let tail: String = s.chars().rev().take(keep).collect::<String>().chars().rev().collect();
format!("{}{}", head, tail)
}
fn format_duration(ms: u64) -> String {
let s = ms / 1000;
if s < 60 { format!("{}", s) }
else if s < 3600 { format!("{}{}", s / 60, s % 60) }
else { format!("{}{}", s / 3600, (s % 3600) / 60) }
}
fn classify_log(line: &str) -> (egui::Color32, &'static str) {
if line.starts_with("═══") {
(material::LIME, "")
} else if line.contains("命中") {
(material::DANGER, "")
} else if line.contains("未命中") || line.contains("") {
(material::SUCCESS, "")
} else if line.starts_with("") || line.contains("警告") {
(material::WARNING, "")
} else if line.starts_with("") || line.contains("错误") || line.contains("失败") {
(material::DANGER, "")
} else if line.starts_with("") {
(material::PRIMARY_DARK, "")
} else if line.starts_with("🔍") || line.starts_with("🎲") || line.starts_with("📁") || line.starts_with("📝") || line.starts_with("🔬") {
(material::PRIMARY_DARK, "")
} else if line.starts_with("") {
(material::SUCCESS, "")
} else {
(material::ON_SURFACE, "")
}
}
/// 启动后台抽检任务
fn start_inspection(app: &mut App) {
app.state = RunState::Scanning;
app.progress.store(0, Ordering::Relaxed);
app.total.store(0, Ordering::Relaxed);
app.hit_count.store(0, Ordering::Relaxed);
app.cancel_flag.store(false, Ordering::Relaxed);
app.elapsed_ms.store(0, Ordering::Relaxed);
app.scan_scanned.store(0, Ordering::Relaxed);
app.scan_found.store(0, Ordering::Relaxed);
if let Ok(mut s) = app.scan_current_dir.lock() { s.clear(); }
if let Ok(mut s) = app.current_step.lock() { s.clear(); }
if let Ok(mut cur) = app.current_file.lock() { cur.take(); }
if let Ok(mut lines) = app.log_lines.lock() { lines.clear(); }
if let Ok(mut s) = app.samples.lock() { s.clear(); }
if let Ok(mut d) = app.type_done.lock() { d.clear(); }
if let Ok(mut t) = app.type_total.lock() { t.clear(); }
let progress = Arc::clone(&app.progress);
let total = Arc::clone(&app.total);
let hit_count = Arc::clone(&app.hit_count);
let cancel = Arc::clone(&app.cancel_flag);
let scan_scanned = Arc::clone(&app.scan_scanned);
let scan_found = Arc::clone(&app.scan_found);
let scan_dir = Arc::clone(&app.scan_current_dir);
let cur_file = Arc::clone(&app.current_file);
let cur_step = Arc::clone(&app.current_step);
let elapsed_ms = Arc::clone(&app.elapsed_ms);
let type_done = Arc::clone(&app.type_done);
let type_total = Arc::clone(&app.type_total);
let log_lines = Arc::clone(&app.log_lines);
let samples = Arc::clone(&app.samples);
let report_slot = Arc::clone(&app.report);
let state_slot = Arc::new(std::sync::Mutex::new(RunState::Scanning));
let state_slot_for_task = Arc::clone(&state_slot);
let start_instant = Instant::now();
let cfg = app.config.clone();
app.task_state = Some(state_slot.clone());
app.task_log("开始抽检……");
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
let outcome = rt.block_on(async move {
crate::scan::runner::run(
cfg,
progress,
total,
hit_count,
cancel,
scan_scanned,
scan_found,
scan_dir,
cur_file,
cur_step,
start_instant,
elapsed_ms,
type_done,
type_total,
log_lines,
samples,
report_slot,
state_slot_for_task,
)
.await
});
tracing::info!("抽检任务结束:{:?}", outcome);
});
}
fn open_in_explorer(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(windows)]
{
std::process::Command::new("explorer")
.arg(path)
.spawn()
.map(|_| ())
}
#[cfg(not(windows))]
{
let _ = path;
Ok(())
}
}

205
src/ui/material.rs Normal file
View File

@@ -0,0 +1,205 @@
// Material/Security 风格:颜色 token + 主题 + 字体安装
use eframe::egui;
use eframe::egui::{FontData, FontDefinitions, FontFamily};
use crate::config::Theme;
// —— 主色:鲜亮绿(深色底上的主色,类似安全软件主色) ——
pub const PRIMARY: egui::Color32 = egui::Color32::from_rgb(0, 230, 118); // #00E676 鲜绿
pub const PRIMARY_DARK: egui::Color32 = egui::Color32::from_rgb(29, 233, 182); // #1DE9B6 薄荷
pub const SECONDARY: egui::Color32 = egui::Color32::from_rgb(0, 176, 255); // #00B0FF 青蓝
pub const SUCCESS: egui::Color32 = egui::Color32::from_rgb(0, 200, 83); // #00C853
pub const WARNING: egui::Color32 = egui::Color32::from_rgb(255, 171, 0); // #FFAB00
pub const DANGER: egui::Color32 = egui::Color32::from_rgb(255, 82, 82); // #FF5252
pub const LIME: egui::Color32 = egui::Color32::from_rgb(118, 255, 3); // #76FF03
pub const TEAL: egui::Color32 = egui::Color32::from_rgb(0, 191, 165); // #00BFA5
// —— 暗色背景 ——
pub const SURFACE: egui::Color32 = egui::Color32::from_rgb(8, 40, 22); // #082816 卡片底
pub const BACKGROUND: egui::Color32 = egui::Color32::from_rgb(0, 26, 13); // #001A0D 主底
pub const BACKGROUND_ALT: egui::Color32 = egui::Color32::from_rgb(0, 46, 26); // #002E1A 渐变次底
pub const ON_SURFACE: egui::Color32 = egui::Color32::from_rgb(185, 246, 202); // #B9F6CA 浅绿文字
pub const ON_SURFACE_DIM: egui::Color32 = egui::Color32::from_rgb(120, 180, 140); // 暗一档
pub const CARD_BORDER: egui::Color32 = egui::Color32::from_rgb(0, 191, 165); // #00BFA5 卡片边
/// 安装中文字体(如有指定路径或同目录字体文件)
pub fn install_fonts(ctx: &egui::Context, font_path: &Option<std::path::PathBuf>) {
let mut fonts = FontDefinitions::default();
let mut added = false;
// 字体候选路径(顺序:用户指定 → exe 同目录 → 系统字体目录)
let mut candidates: Vec<(std::path::PathBuf, usize)> = Vec::new();
if let Some(p) = font_path.as_ref() {
candidates.push((p.clone(), 0));
}
let exe = crate::utils::paths::exe_dir();
for name in [
"SourceHanSansSC-Regular.otf",
"NotoSansCJKsc-Regular.otf",
"msyh.ttc",
"simhei.ttf",
"simsun.ttc",
] {
candidates.push((exe.join(name), 0));
}
// 系统字体目录C:\Windows\Fonts
let sys_fonts = std::path::PathBuf::from(r"C:\Windows\Fonts");
for (name, idx) in [
("msyh.ttc", 0usize), // 微软雅黑
("msyhbd.ttc", 0), // 微软雅黑 Bold
("simhei.ttf", 0), // 黑体
("simsun.ttc", 0), // 宋体
("simkai.ttf", 0), // 楷体
] {
candidates.push((sys_fonts.join(name), idx));
}
for (p, idx) in &candidates {
match std::fs::read(p) {
Ok(data) => {
tracing::info!("加载中文字体:{} (index={}, {} bytes)", p.display(), idx, data.len());
let fd = if *idx == 0 {
FontData::from_owned(data)
} else {
FontData {
font: std::borrow::Cow::Owned(data),
index: *idx as u32,
tweak: Default::default(),
}
};
fonts.font_data.insert("cn".into(), fd);
added = true;
break;
}
Err(_) => continue,
}
}
if added {
if let Some(p) = fonts.families.get_mut(&FontFamily::Proportional) {
p.insert(0, "cn".into());
}
if let Some(p) = fonts.families.get_mut(&FontFamily::Monospace) {
p.insert(0, "cn".into());
}
} else {
tracing::warn!("未找到中文字体文件UI 中文可能显示为方块;可把字体文件放到 exe 同目录或在设置中指定");
}
ctx.set_fonts(fonts);
}
/// 应用主题
pub fn apply_theme(ctx: &egui::Context, theme: Theme) {
let visuals = match theme {
Theme::Light => material_light(),
Theme::Dark => security_dark(),
Theme::Follow => security_dark(), // 默认走深色安全风格
};
ctx.set_visuals(visuals);
}
/// Material 风格浅色主题(蓝主色 + 中性灰白底)
fn material_light() -> egui::Visuals {
let mut v = egui::Visuals::light();
v.window_fill = egui::Color32::from_rgb(255, 255, 255);
v.panel_fill = egui::Color32::from_rgb(250, 250, 250);
v.extreme_bg_color = egui::Color32::from_rgb(240, 244, 250);
v.faint_bg_color = egui::Color32::from_rgb(245, 247, 250);
v.widgets.noninteractive.bg_fill = egui::Color32::from_rgb(245, 247, 250);
v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 224, 230));
v.widgets.inactive.bg_fill = egui::Color32::from_rgb(250, 250, 250);
v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(220, 224, 230));
v.widgets.hovered.bg_fill = egui::Color32::from_rgb(235, 243, 255);
v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, PRIMARY);
v.widgets.active.bg_fill = egui::Color32::from_rgb(205, 230, 255);
v.widgets.active.bg_stroke = egui::Stroke::new(1.5, PRIMARY_DARK);
v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 70));
v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(50, 50, 60));
v.selection.bg_fill = egui::Color32::from_rgb(180, 215, 255);
v.selection.stroke = egui::Stroke::new(1.0, PRIMARY);
v.hyperlink_color = PRIMARY;
v.widgets.noninteractive.rounding = egui::Rounding::same(6.0);
v.widgets.inactive.rounding = egui::Rounding::same(6.0);
v.widgets.hovered.rounding = egui::Rounding::same(6.0);
v.widgets.active.rounding = egui::Rounding::same(6.0);
v.window_rounding = egui::Rounding::same(8.0);
v
}
/// 安全软件风格深色主题:暗绿底 + 鲜绿/青色强调
fn security_dark() -> egui::Visuals {
let mut v = egui::Visuals::dark();
// 整体色调:暗绿
v.window_fill = BACKGROUND;
v.panel_fill = BACKGROUND_ALT;
v.extreme_bg_color = egui::Color32::from_rgb(0, 18, 8);
v.faint_bg_color = SURFACE;
// 控件底色(卡片用 SURFACE控件用略亮
v.widgets.noninteractive.bg_fill = SURFACE;
v.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, CARD_BORDER);
v.widgets.inactive.bg_fill = egui::Color32::from_rgb(12, 50, 28);
v.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 150, 130));
v.widgets.hovered.bg_fill = egui::Color32::from_rgb(20, 90, 50);
v.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, PRIMARY);
v.widgets.active.bg_fill = egui::Color32::from_rgb(0, 120, 70);
v.widgets.active.bg_stroke = egui::Stroke::new(1.5, LIME);
// 文字色
v.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, ON_SURFACE);
v.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, ON_SURFACE);
v.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
v.widgets.active.fg_stroke = egui::Stroke::new(1.0, egui::Color32::WHITE);
// 文本默认色
v.override_text_color = Some(ON_SURFACE);
// 选中色
v.selection.bg_fill = egui::Color32::from_rgb(0, 150, 80);
v.selection.stroke = egui::Stroke::new(1.0, LIME);
// 链接
v.hyperlink_color = PRIMARY_DARK;
// 圆角
v.widgets.noninteractive.rounding = egui::Rounding::same(8.0);
v.widgets.inactive.rounding = egui::Rounding::same(8.0);
v.widgets.hovered.rounding = egui::Rounding::same(8.0);
v.widgets.active.rounding = egui::Rounding::same(8.0);
v.window_rounding = egui::Rounding::same(10.0);
v
}
/// 创建一个安全软件风格主按钮(绿渐变 + 圆角胶囊)
pub fn primary_button(text: &str) -> egui::Button<'_> {
egui::Button::new(egui::RichText::new(text).color(BACKGROUND).strong().size(15.0))
.fill(PRIMARY)
.stroke(egui::Stroke::new(1.0, LIME))
.rounding(egui::Rounding::same(18.0))
.min_size(egui::vec2(120.0, 36.0))
}
/// 创建一个危险按钮(红色)
pub fn danger_button(text: &str) -> egui::Button<'_> {
egui::Button::new(egui::RichText::new(text).color(egui::Color32::WHITE).strong())
.fill(DANGER)
.rounding(egui::Rounding::same(18.0))
.min_size(egui::vec2(100.0, 36.0))
}
/// 一个安全软件风格分组面板:暗底 + 青色边
pub fn group<R>(ui: &mut egui::Ui, title: &str, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
egui::Frame::group(ui.style())
.rounding(egui::Rounding::same(10.0))
.inner_margin(egui::Margin::same(14.0))
.fill(SURFACE)
.stroke(egui::Stroke::new(1.0, CARD_BORDER))
.show(ui, |ui| {
ui.label(egui::RichText::new(title).strong().size(15.0).color(PRIMARY_DARK));
ui.add_space(6.0);
add_contents(ui)
})
.inner
}

6
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
// UI 模块
pub mod home;
pub mod material;
pub mod report;
pub mod settings;
pub mod widgets;

40
src/ui/report.rs Normal file
View File

@@ -0,0 +1,40 @@
// 报告查看页
use eframe::egui;
use crate::report::model::Report;
use crate::ui::material;
pub fn draw(ui: &mut egui::Ui, r: &Report) {
material::group(ui, "本次抽检", |ui| {
ui.label(format!("检查时间:{} ~ {}", r.started_at, r.finished_at));
ui.label(format!("抽检:{},命中:{}", r.total, r.hit));
ui.label(format!("机器:{},用户:{}", r.machine, r.user));
});
ui.add_space(8.0);
material::group(ui, "命中清单", |ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
egui::Grid::new("findings").striped(true).show(ui, |ui| {
ui.strong("文件");
ui.strong("类型");
ui.strong("命中关键词");
ui.strong("置信度");
ui.strong("截图");
ui.end_row();
for f in &r.findings {
ui.label(f.path.display().to_string());
ui.label(&f.kind);
ui.label(f.matched.join(", "));
ui.label(format!("{:.2}", f.confidence));
if let Some(s) = &f.screenshot {
ui.label(s.display().to_string());
} else {
ui.label("-");
}
ui.end_row();
}
});
});
});
}

314
src/ui/settings.rs Normal file
View File

@@ -0,0 +1,314 @@
// 设置页A-F 六分组
use eframe::egui;
use crate::config::{AppConfig, KeywordSettings, LogLevel, ReportFormat, SampleMode, SampleStrategy, ScreenshotMode, Theme};
use crate::ui::{material, widgets};
/// 绘制设置页
pub fn draw(ui: &mut egui::Ui, cfg: &mut AppConfig) {
egui::ScrollArea::vertical()
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible)
.auto_shrink([false, false])
.show(ui, |ui| {
group_a_general(ui, &mut cfg.general);
ui.add_space(8.0);
group_b_scan(ui, &mut cfg.scan);
ui.add_space(8.0);
group_c_inspect(ui, &mut cfg.inspect);
ui.add_space(8.0);
group_d_viewer(ui, &mut cfg.viewer);
ui.add_space(8.0);
group_e_keyword(ui, &mut cfg.keyword);
ui.add_space(8.0);
group_f_report(ui, &mut cfg.report);
ui.add_space(8.0);
material::group(ui, "操作", |ui| {
ui.horizontal(|ui| {
if ui.button("💾 立即保存").clicked() {
if let Err(e) = cfg.save() {
tracing::warn!("保存失败:{}", e);
}
}
if ui.button("🔄 恢复默认").clicked() {
*cfg = AppConfig::default();
}
if ui.button("📂 打开配置文件").clicked() {
let path = crate::config::config_path();
let _ = std::fs::create_dir_all(path.parent().unwrap());
let _ = open_in_explorer(&path);
}
});
});
ui.add_space(20.0); // 底部留白,避免被裁切
});
}
fn group_a_general(ui: &mut egui::Ui, g: &mut crate::config::GeneralSettings) {
material::group(ui, "A. 常规", |ui| {
ui.checkbox(&mut g.auto_run, "启动时自动开始检测");
ui.checkbox(&mut g.start_minimized, "启动时最小化到托盘");
ui.checkbox(&mut g.auto_start, "开机自启");
ui.horizontal(|ui| {
ui.label("主题:");
egui::ComboBox::from_id_source("theme")
.selected_text(match g.theme { Theme::Light => "Light", Theme::Dark => "Dark", Theme::Follow => "Follow System" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut g.theme, Theme::Light, "Light");
ui.selectable_value(&mut g.theme, Theme::Dark, "Dark");
ui.selectable_value(&mut g.theme, Theme::Follow, "Follow System");
});
});
ui.horizontal(|ui| {
ui.label("日志级别:");
egui::ComboBox::from_id_source("log_level")
.selected_text(format!("{:?}", g.log_level))
.show_ui(ui, |ui| {
for l in [LogLevel::Error, LogLevel::Warn, LogLevel::Info, LogLevel::Debug, LogLevel::Trace] {
ui.selectable_value(&mut g.log_level, l, format!("{:?}", l));
}
});
});
ui.horizontal(|ui| {
ui.label("日志保留天数:");
ui.add(egui::DragValue::new(&mut g.log_retention_days).clamp_range(1..=365));
});
ui.checkbox(&mut g.single_instance, "单实例锁");
ui.checkbox(&mut g.clear_temp_on_start, "启动时清空临时目录");
ui.checkbox(&mut g.auto_exit, "检查完自动退出");
if g.auto_exit {
ui.horizontal(|ui| {
ui.label("倒计时(秒):");
ui.add(egui::DragValue::new(&mut g.auto_exit_seconds).clamp_range(1..=600));
});
}
});
}
fn group_b_scan(ui: &mut egui::Ui, s: &mut crate::config::ScanSettings) {
material::group(ui, "B. 扫描范围(默认全盘 + 白名单 = 不扫)", |ui| {
ui.label("白名单目录(不扫描):");
// 先把 s.whitelist 转 Vec<String> 给 string_list_editor
let mut wl_strs: Vec<String> = s.whitelist.iter().map(|p| p.display().to_string()).collect();
widgets::string_list_editor(ui, &mut wl_strs, r"例如 C:\Windows");
// 写回
s.whitelist = wl_strs.iter().map(|x| std::path::PathBuf::from(x)).collect();
ui.horizontal(|ui| {
if ui.button(" 系统目录").clicked() {
s.whitelist.push(std::path::PathBuf::from(r"C:\Windows"));
}
if ui.button(" Program Files").clicked() {
s.whitelist.push(std::path::PathBuf::from(r"C:\Program Files"));
}
if ui.button(" ProgramData").clicked() {
s.whitelist.push(std::path::PathBuf::from(r"C:\ProgramData"));
}
if ui.button(" 回收站").clicked() {
s.whitelist.push(std::path::PathBuf::from(r"C:\$Recycle.Bin"));
}
});
// 把编辑框的字符串结果写回 s.whitelist
// (简化:用 text 列表同步一次 —— 实际可用 table state这里临时方案
// 提示:上面的 string_list_editor 改的是临时 Vec需要保留后再写回。
// 简单起见:使用一个固定 placeholder
ui.checkbox(&mut s.include_hidden, "包含隐藏文件");
ui.checkbox(&mut s.include_system, "包含系统文件");
ui.checkbox(&mut s.follow_symlinks, "跟随符号链接");
ui.horizontal(|ui| {
ui.label("文件最小大小 KB");
ui.add(egui::DragValue::new(&mut s.min_size_kb).clamp_range(0..=1024 * 1024));
});
ui.horizontal(|ui| {
ui.label("最大遍历深度0=无限):");
ui.add(egui::DragValue::new(&mut s.max_depth).clamp_range(0..=1000));
});
ui.label("扩展名白名单(不含点,逗号分隔):");
let mut ext_str = s.extensions.join(",");
if ui.text_edit_singleline(&mut ext_str).changed() {
s.extensions = ext_str.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
}
ui.horizontal(|ui| {
ui.label("单次扫描超时分钟0=无限):");
ui.add(egui::DragValue::new(&mut s.scan_timeout_minutes).clamp_range(0..=24 * 60));
});
});
}
fn group_c_inspect(ui: &mut egui::Ui, i: &mut crate::config::InspectSettings) {
material::group(ui, "C. 抽检(先扫全盘,再按下方配置抽样检查)", |ui| {
// 抽样数量模式
ui.horizontal(|ui| {
ui.label("抽样模式:");
egui::ComboBox::from_id_source("sample_mode")
.selected_text(match i.sample_mode {
SampleMode::Count => "📦 按份数",
SampleMode::Percent => "📊 按百分比",
SampleMode::All => "🌐 全部",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut i.sample_mode, SampleMode::Count, "📦 按份数");
ui.selectable_value(&mut i.sample_mode, SampleMode::Percent, "📊 按百分比");
ui.selectable_value(&mut i.sample_mode, SampleMode::All, "🌐 全部");
});
});
ui.horizontal(|ui| {
match i.sample_mode {
SampleMode::Count => {
ui.label("抽检份数:");
ui.add(egui::DragValue::new(&mut i.sample_count).clamp_range(1..=100_000));
}
SampleMode::Percent => {
ui.label("百分比:");
ui.add(egui::DragValue::new(&mut i.sample_percent).clamp_range(0.1..=100.0).speed(0.5));
ui.label("%");
}
SampleMode::All => { ui.label("(将抽检全部候选文件)"); }
}
});
ui.horizontal(|ui| {
ui.label("抽样策略:");
egui::ComboBox::from_id_source("strategy")
.selected_text(match i.strategy { SampleStrategy::Random => "完全随机", SampleStrategy::Stratified => "分层", SampleStrategy::Quota => "类型配额" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut i.strategy, SampleStrategy::Random, "完全随机");
ui.selectable_value(&mut i.strategy, SampleStrategy::Stratified, "分层");
ui.selectable_value(&mut i.strategy, SampleStrategy::Quota, "类型配额");
});
});
ui.horizontal(|ui| {
ui.label("DOC 配额:");
ui.add(egui::DragValue::new(&mut i.doc_quota).clamp_range(0..=100_000));
ui.label("DOCX 配额:");
ui.add(egui::DragValue::new(&mut i.docx_quota).clamp_range(0..=100_000));
ui.label("PDF 配额:");
ui.add(egui::DragValue::new(&mut i.pdf_quota).clamp_range(0..=100_000));
ui.label("XLSX 配额:");
ui.add(egui::DragValue::new(&mut i.xlsx_quota).clamp_range(0..=100_000));
});
ui.horizontal(|ui| {
ui.label("单文件总超时(秒):");
ui.add(egui::DragValue::new(&mut i.per_file_timeout_sec).clamp_range(10..=3600));
});
ui.horizontal(|ui| {
ui.label("同文件 N 天内不重复:");
ui.add(egui::DragValue::new(&mut i.dedup_days).clamp_range(0..=365));
});
ui.checkbox(&mut i.skip_locked, "跳过锁定/无权限文件");
ui.checkbox(&mut i.stop_on_first_hit, "命中后停止后续抽检");
});
}
fn group_d_viewer(ui: &mut egui::Ui, v: &mut crate::config::ViewerSettings) {
material::group(ui, "D. 查看器与截图", |ui| {
ui.horizontal(|ui| {
ui.label("截图模式:");
egui::ComboBox::from_id_source("screenshot_mode")
.selected_text(match v.screenshot_mode {
ScreenshotMode::Manual => "ManualWin+Shift+S + 手动框选)",
ScreenshotMode::AutoPrintWindow => "AutoPrintWindow 全自动)",
ScreenshotMode::AutoWithFallback => "Auto + 手动兜底",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::Manual, "ManualWin+Shift+S + 手动框选)");
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::AutoPrintWindow, "AutoPrintWindow 全自动)");
ui.selectable_value(&mut v.screenshot_mode, ScreenshotMode::AutoWithFallback, "Auto + 手动兜底");
});
});
widgets::file_picker(ui, ".doc 查看器(默认 exe 同目录 doclite.exe", &mut v.doc_viewer, &["exe"]);
ui.horizontal(|ui| { ui.label("启动参数模板:"); ui.text_edit_singleline(&mut v.doc_args); });
widgets::file_picker(ui, ".pdf 查看器(默认 Windows 关联)", &mut v.pdf_viewer, &["exe"]);
ui.horizontal(|ui| { ui.label("启动参数模板:"); ui.text_edit_singleline(&mut v.pdf_args); });
ui.horizontal(|ui| { ui.label("截图前等待时间 ms"); ui.add(egui::DragValue::new(&mut v.pre_capture_wait_ms).clamp_range(0..=30_000)); });
ui.horizontal(|ui| { ui.label("手动截图超时秒:"); ui.add(egui::DragValue::new(&mut v.manual_capture_timeout_sec).clamp_range(5..=600)); });
ui.horizontal(|ui| { ui.label("自动模式空位图判定阈值 (max_black_ratio)"); ui.add(egui::DragValue::new(&mut v.max_black_ratio).clamp_range(0.0..=1.0)); });
ui.checkbox(&mut v.auto_close_after, "截图后自动关闭查看器");
ui.horizontal(|ui| { ui.label("关闭等待 ms"); ui.add(egui::DragValue::new(&mut v.close_wait_ms).clamp_range(0..=30_000)); });
ui.horizontal(|ui| { ui.label("强杀超时 ms"); ui.add(egui::DragValue::new(&mut v.kill_timeout_ms).clamp_range(0..=30_000)); });
ui.add_space(6.0);
ui.label(egui::RichText::new("Umi-OCR").strong());
ui.horizontal(|ui| { ui.label("HTTP 地址:"); ui.text_edit_singleline(&mut v.umi_ocr_url); });
widgets::file_picker(ui, "Umi-OCR.exe 路径(默认 exe 同目录)", &mut v.umi_ocr_exe, &["exe"]);
ui.horizontal(|ui| { ui.label("启动后等待秒:"); ui.add(egui::DragValue::new(&mut v.umi_ocr_startup_wait_sec).clamp_range(0..=60)); });
ui.horizontal(|ui| { ui.label("调用超时秒:"); ui.add(egui::DragValue::new(&mut v.umi_ocr_call_timeout_sec).clamp_range(5..=600)); });
ui.horizontal(|ui| { ui.label("OCR 语言:"); ui.text_edit_singleline(&mut v.ocr_language); });
ui.checkbox(&mut v.ocr_cls, "启用文本方向校正");
ui.horizontal(|ui| { ui.label("限制边长 px"); ui.add(egui::DragValue::new(&mut v.ocr_limit_side_len).clamp_range(0..=99999)); });
});
}
fn group_e_keyword(ui: &mut egui::Ui, k: &mut KeywordSettings) {
material::group(ui, "E. 关键词", |ui| {
ui.label("全局关键词(每行一个):");
widgets::string_list_editor(ui, &mut k.global, "新增关键词");
ui.horizontal(|ui| {
if ui.button("📥 导入 txt").clicked() {
if let Some(p) = rfd::FileDialog::new().add_filter("text", &["txt"]).pick_file() {
if let Ok(s) = std::fs::read_to_string(&p) {
k.global = s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect();
}
}
}
if ui.button("📤 导出 txt").clicked() {
if let Some(p) = rfd::FileDialog::new().add_filter("text", &["txt"]).save_file() {
let _ = std::fs::write(p, k.global.join("\n"));
}
}
});
ui.checkbox(&mut k.case_sensitive, "区分大小写");
ui.checkbox(&mut k.use_regex, "使用正则");
ui.checkbox(&mut k.whole_word, "整词匹配");
ui.label("DOC 追加关键词:");
widgets::string_list_editor(ui, &mut k.doc_extra, "新增");
ui.label("DOCX 追加关键词:");
widgets::string_list_editor(ui, &mut k.docx_extra, "新增");
ui.label("PDF 追加关键词:");
widgets::string_list_editor(ui, &mut k.pdf_extra, "新增");
ui.label("XLSX 追加关键词:");
widgets::string_list_editor(ui, &mut k.xlsx_extra, "新增");
ui.horizontal(|ui| { ui.label("最低置信度:"); ui.add(egui::DragValue::new(&mut k.min_confidence).clamp_range(0.0..=1.0)); });
ui.label("误报白名单指纹(每行一个,可清除):");
widgets::string_list_editor(ui, &mut k.false_positive_fingerprints, "新增");
});
}
fn group_f_report(ui: &mut egui::Ui, r: &mut crate::config::ReportSettings) {
material::group(ui, "F. 报告", |ui| {
widgets::folder_picker(ui, "输出目录:", &mut Some(r.output_dir.clone()));
ui.label("输出格式:");
ui.horizontal(|ui| {
// 用三个 bool 缓冲切换 Vec<ReportFormat> 的成员
let mut has_html = r.formats.contains(&ReportFormat::Html);
let mut has_json = r.formats.contains(&ReportFormat::Json);
let mut has_png = r.formats.contains(&ReportFormat::Png);
if ui.checkbox(&mut has_html, "HTML").changed() {
toggle_format(&mut r.formats, ReportFormat::Html, has_html);
}
if ui.checkbox(&mut has_json, "JSON").changed() {
toggle_format(&mut r.formats, ReportFormat::Json, has_json);
}
if ui.checkbox(&mut has_png, "PNG 组图").changed() {
toggle_format(&mut r.formats, ReportFormat::Png, has_png);
}
});
ui.horizontal(|ui| { ui.label("文件名前缀:"); ui.text_edit_singleline(&mut r.file_prefix); });
ui.checkbox(&mut r.include_screenshots, "包含截图");
ui.checkbox(&mut r.highlight_sensitive, "敏感词高亮");
ui.horizontal(|ui| { ui.label("截图最大边长 px"); ui.add(egui::DragValue::new(&mut r.max_screenshot_side).clamp_range(320..=8192)); });
ui.horizontal(|ui| { ui.label("历史保留条数:"); ui.add(egui::DragValue::new(&mut r.history_keep).clamp_range(1..=1000)); });
ui.checkbox(&mut r.auto_open, "检查完自动打开报告");
ui.checkbox(&mut r.copy_summary_to_clipboard, "复制摘要到剪贴板");
});
}
fn open_in_explorer(path: &std::path::Path) -> std::io::Result<()> {
#[cfg(windows)]
{ std::process::Command::new("explorer").arg(path).spawn().map(|_| ()) }
#[cfg(not(windows))]
{ let _ = path; Ok(()) }
}
/// 切换 formats 列表中的某一项
fn toggle_format(list: &mut Vec<ReportFormat>, fmt: ReportFormat, on: bool) {
let has = list.contains(&fmt);
if on && !has { list.push(fmt); }
if !on && has { list.retain(|f| *f != fmt); }
}

68
src/ui/widgets.rs Normal file
View File

@@ -0,0 +1,68 @@
// 通用 widget路径选择器、列表编辑器等
use std::path::PathBuf;
use eframe::egui;
/// 文件夹选择按钮:点击后调用 native picker选完后把路径写入 `target`
pub fn folder_picker(ui: &mut egui::Ui, label: &str, target: &mut Option<PathBuf>) {
ui.horizontal(|ui| {
ui.label(label);
let display = target.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "<未选择>".to_string());
ui.label(egui::RichText::new(&display).monospace());
if ui.button("📁").clicked() {
if let Some(p) = rfd::FileDialog::new().pick_folder() {
*target = Some(p);
}
}
if ui.button("清除").clicked() {
*target = None;
}
});
}
/// 文件选择按钮
pub fn file_picker(ui: &mut egui::Ui, label: &str, target: &mut Option<PathBuf>, extensions: &[&str]) {
ui.horizontal(|ui| {
ui.label(label);
let display = target.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "<未选择>".to_string());
ui.label(egui::RichText::new(&display).monospace());
if ui.button("📄").clicked() {
let mut d = rfd::FileDialog::new();
for ext in extensions {
// 把 &&str 解引用为 &strrfd 才会接受
d = d.add_filter(*ext, &[*ext]);
}
if let Some(p) = d.pick_file() {
*target = Some(p);
}
}
if ui.button("清除").clicked() {
*target = None;
}
});
}
/// 列表编辑器:每一项是 String下方一个添加按钮、一个删除按钮
pub fn string_list_editor(ui: &mut egui::Ui, items: &mut Vec<String>, hint: &str) {
let mut to_remove: Option<usize> = None;
for (i, item) in items.iter_mut().enumerate() {
ui.horizontal(|ui| {
ui.label(format!("{}.", i + 1));
ui.text_edit_singleline(item);
if ui.button("").clicked() {
to_remove = Some(i);
}
});
}
if let Some(i) = to_remove {
items.remove(i);
}
ui.horizontal(|ui| {
if ui.button(" 添加").clicked() {
items.push(hint.to_string());
}
if ui.button("🗑 清空").clicked() {
items.clear();
}
});
}

25
src/utils/logger.rs Normal file
View 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
View File

@@ -0,0 +1,3 @@
// 工具模块入口
pub mod logger;
pub mod paths;

53
src/utils/paths.rs Normal file
View 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 }
}