From a3476c22bf295d8e25bb4958c5dfeb064da0e0d9 Mon Sep 17 00:00:00 2001 From: Reyppp <1310590802zss@gmail.com> Date: Wed, 26 Aug 2026 18:40:47 +0800 Subject: [PATCH 1/2] release: prepare v0.1.0-beta.3 --- .gitignore | 1 + PRODUCT.md | 2 +- README.md | 33 +++- RELEASE_NOTES.md | 15 +- analyzer.py | 26 ++- desktop.py | 141 +++++++++++--- index.html | 19 +- installer/LogAnalysis.iss | 2 +- server.py | 132 ++++++++++--- site/index.html | 24 ++- test_analyzer.py | 178 +++++++++++++++++- version.py | 2 +- ...2\345\256\211\350\243\205\345\214\205.bat" | 5 +- 13 files changed, 502 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index 301404d..07adf0b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,6 @@ release/ *.p12 settings.json SHA256SUMS.txt +context-summary.md !site/assets/*.png !site/assets/*.jpg diff --git a/PRODUCT.md b/PRODUCT.md index c82875d..07b8dc6 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -28,7 +28,7 @@ Log Analysis 将分散的镀膜日志只读汇总为可筛选、可视化、可 - 保留总览、理论趋势、光学、设备、异常关联五个模块和 CSV 导出。 - 中文界面为主,核心入口保持 `analyze_folder(path) -> AnalysisResult`。 - 第一版只支持 Windows x64,不提供在线分析、数据库、账号或自动更新。 -- 首个二进制版本是未签名测试版 `v0.1.0-beta.1`。 +- 当前二进制版本是未签名测试版 `v0.1.0-beta.3`。 ## Brand Commitments diff --git a/README.md b/README.md index 7b8d56c..c3a8010 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,38 @@ ## 安装 -从 [GitHub Releases](https://github.com/Reyppp/log-analysis/releases) 下载 Windows x64 安装程序,并使用同一版本的 `SHA256SUMS.txt` 核对文件。 +### 系统要求 -首个 `v0.1.0-beta.1` 是未签名测试版,Windows SmartScreen 或组织安全策略可能发出警告。运行需要 Windows 10/11 x64 和 Microsoft Edge WebView2 Runtime。 +- Windows 10/11 x64。 +- Microsoft Edge WebView2 Runtime;Windows 10/11 通常已经安装,缺失时安装程序会显示下载指引。 + +### 下载与校验 + +1. 升级前先关闭所有正在运行的 Log Analysis 窗口。 +2. 下载 [Log Analysis v0.1.0-beta.3 安装程序](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.3/Log-Analysis-Setup-v0.1.0-beta.3-x64.exe) 和同一版本的 [SHA256SUMS.txt](https://github.com/Reyppp/log-analysis/releases/download/v0.1.0-beta.3/SHA256SUMS.txt)。也可以先查看[完整版本说明](https://github.com/Reyppp/log-analysis/releases/tag/v0.1.0-beta.3)。 +3. 在下载目录打开 PowerShell,运行: + +```powershell +Get-FileHash -Algorithm SHA256 .\Log-Analysis-Setup-v0.1.0-beta.3-x64.exe +Get-Content .\SHA256SUMS.txt +``` + +4. 确认两处显示的 64 位 SHA-256 完全一致;不一致时不要运行安装程序,请重新下载。 + +### 安装与首次使用 + +1. 双击 `Log-Analysis-Setup-v0.1.0-beta.3-x64.exe`。 +2. 按安装向导继续;程序默认安装到当前用户目录,不需要管理员权限,桌面快捷方式可选。 +3. 当前版本是未签名测试版。若 SmartScreen 显示“Windows 已保护你的电脑”,请先确认 SHA-256 已匹配,再选择“更多信息”,核对文件名后选择“仍要运行”。如果组织安全策略禁止运行,请联系管理员,不要关闭系统安全功能。 +4. 安装完成后,从开始菜单打开 **Log Analysis**。 +5. 点击“选择文件夹”,选择炉次日志目录,再点击“开始分析”。所有数据只在本机处理。 + +覆盖安装会保留上次选择的炉次路径。卸载默认保留设置,也可以在卸载时选择同时删除。 + +### 已知限制 + +- 这是未签名测试版,SmartScreen 或组织安全策略可能发出警告。 +- 部分安装环境中,单层详情图表或原始截图可能不显示;该问题仍在修复,不影响逐层汇总和其他全炉分析页面。 ## 开发运行 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0fd37e1..8d8d80d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,6 @@ -# Log Analysis v0.1.0-beta.1 +# Log Analysis v0.1.0-beta.3 -首个公开测试版,将镀膜日志分析工作台封装为 Windows x64 独立应用。 +文件夹选择与性能稳定性测试版,使用 Windows 原生资源管理器选择炉次目录。 ## 主要功能 @@ -8,6 +8,16 @@ - 总览、理论趋势、光学、设备、异常关联五个分析模块。 - 单层曲线、理论曲线、原始截图和前后切层。 - UTF-8 BOM 逐层汇总与异常清单导出。 +- 新炉次选择后自动清空旧分析结果。 +- 安装版使用 Windows 原生资源管理器式文件夹选择窗口,并自动定位上次目录。 +- 文件未变化时复用会话内结果,异常阈值调整不再重读全部 CSV。 + +## 本版改进 + +- 优化逐列数值校验,黄金样本首次分析时间由约 9 秒降低至约 5.3 秒。 +- 增加分析进度、会话缓存、异常阈值快速重算和重复分析保护。 +- 修复窗口关闭后的残留进程,并限制为单实例运行。 +- 文件夹选择改用 Windows 原生资源管理器界面;取消选择不会清空已有路径。 ## 系统要求 @@ -23,3 +33,4 @@ 这是未签名测试版,Windows SmartScreen 或组织安全策略可能发出警告或阻止运行。 统计异常只用于提示复核,不代表工艺或产品合格判定。 +部分安装环境中,单层详情图表或原始截图可能不显示;该问题仍在修复,不影响逐层汇总和其他全炉分析页面。 diff --git a/analyzer.py b/analyzer.py index 14dd347..a198241 100644 --- a/analyzer.py +++ b/analyzer.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, Callable import pandas as pd @@ -154,7 +154,10 @@ def _convert_numeric(frame: pd.DataFrame, columns: list[str], scope: str, layer: for column in columns: raw = result[column] converted = pd.to_numeric(raw, errors="coerce") - invalid = raw.notna() & raw.astype(str).str.strip().ne("") & converted.isna() + invalid = raw.notna() & converted.isna() + if invalid.any(): + candidates = raw.loc[invalid].astype(str).str.strip() + invalid.loc[candidates.index[candidates.eq("")]] = False if invalid.any(): _quality( quality, @@ -286,11 +289,17 @@ def _parse_events(log_paths: list[Path], recipe_materials: list[str], quality: l } -def analyze_folder(folder: str | Path) -> AnalysisResult: +def analyze_folder( + folder: str | Path, + progress: Callable[[str, int, int], None] | None = None, +) -> AnalysisResult: root = Path(folder).expanduser().resolve() if not root.is_dir(): raise AnalysisError(f"文件夹不存在:{root}") + if progress: + progress("索引文件", 0, 0) + quality: list[dict[str, Any]] = [] profile: dict[str, set[str]] = {} recipe_path, recipe = _find_recipe(root) @@ -386,7 +395,8 @@ def analyze_folder(folder: str | Path) -> AnalysisResult: } machine_numeric = sorted(machine_required - {"LayNum", "Timer"}) - for recipe_row in recipe.to_dict("records"): + total_layers = len(expected) + for layer_index, recipe_row in enumerate(recipe.to_dict("records"), start=1): layer = int(recipe_row["layer"]) material = str(recipe_row["Material"]).strip() method = str(recipe_row["Method"]).strip() @@ -525,6 +535,8 @@ def analyze_folder(folder: str | Path) -> AnalysisResult: row["machine_rows"] = len(machine) summaries.append(row) + if progress: + progress("逐层读取", layer_index, total_layers) layer_summary = pd.DataFrame(summaries).sort_values("layer").reset_index(drop=True) for column in SUMMARY_COLUMNS: @@ -532,6 +544,8 @@ def analyze_folder(folder: str | Path) -> AnalysisResult: layer_summary[column] = math.nan layer_summary = layer_summary[SUMMARY_COLUMNS] log_paths = sorted((root / "CoatingLog").glob("*.log")) if (root / "CoatingLog").is_dir() else [] + if progress: + progress("事件汇总", total_layers, total_layers) if not log_paths: _quality(quality, "警告", "CoatingLog", "日志缺失", "未找到 .log 文件") for path in log_paths: @@ -585,7 +599,11 @@ def analyze_folder(folder: str | Path) -> AnalysisResult: "ignored_date_csvs": ignored_date_csvs, "hidden_summary_metrics": hidden_summary_metrics, } + if progress: + progress("统计计算", total_layers, total_layers) anomalies = detect_anomalies(layer_summary, 3.5) + if progress: + progress("完成", total_layers, total_layers) return AnalysisResult(batch_summary, layer_summary, anomalies, data_quality, constant_fields, file_index, events) diff --git a/desktop.py b/desktop.py index 4359f12..a979acb 100644 --- a/desktop.py +++ b/desktop.py @@ -6,16 +6,56 @@ import threading import urllib.request import webbrowser +import ctypes +import logging +from logging.handlers import RotatingFileHandler from pathlib import Path from urllib.parse import quote import webview -import server -from analyzer import csv_bytes, detect_anomalies from version import APP_NAME, APP_VERSION, BUILD_CHANNEL, RELEASES_URL +LOGGER = logging.getLogger(__name__) +MUTEX_NAME = "Local\\LogAnalysisDesktop" + + +def configure_logging() -> Path: + from server import settings_path + + log_path = settings_path().parent / "logs" / "app.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler(log_path, maxBytes=1_000_000, backupCount=2, encoding="utf-8") + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + logging.basicConfig(level=logging.INFO, handlers=[handler], force=True) + return log_path + + +def show_message(message: str, title: str = APP_NAME) -> None: + ctypes.windll.user32.MessageBoxW(None, message, title, 0x40) + + +def acquire_single_instance() -> int | None: + kernel32 = ctypes.windll.kernel32 + user32 = ctypes.windll.user32 + kernel32.CreateMutexW.restype = ctypes.c_void_p + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + user32.FindWindowW.restype = ctypes.c_void_p + handle = kernel32.CreateMutexW(None, False, MUTEX_NAME) + if handle and kernel32.GetLastError() != 183: + return handle + if handle: + kernel32.CloseHandle(handle) + window = user32.FindWindowW(None, APP_NAME) + if window: + user32.ShowWindow(window, 9) + user32.SetForegroundWindow(window) + else: + show_message("Log Analysis 已在运行。若窗口没有显示,请先在任务管理器中结束旧进程后重试。") + return None + + class DesktopApi: def __init__(self) -> None: self.window = None @@ -26,7 +66,26 @@ def get_version(self) -> dict[str, str]: def open_releases(self) -> bool: return webbrowser.open(RELEASES_URL) + def choose_folder(self) -> dict[str, object]: + import server + + previous = server.saved_path() + directory = previous if Path(previous).is_dir() else str(Path.home()) + try: + selected = self.window.create_file_dialog(webview.FileDialog.FOLDER, directory=directory) + path, cancelled, changed = server.apply_folder_selection(selected[0] if selected else "") + return {"path": path, "cancelled": cancelled, "changed": changed} + except Exception as exc: + return { + "error": f"无法打开 Windows 文件夹选择器:{exc}", + "code": "folder_picker_failed", + "recovery": "请关闭残留的选择窗口后重试;如仍失败,请重新启动 Log Analysis。", + } + def save_export(self, kind: str, threshold: float = 3.5) -> dict[str, object]: + import server + from analyzer import csv_bytes, detect_anomalies + result = server.STATE.get("result") if result is None: return {"ok": False, "error": "请先完成炉次分析。"} @@ -46,6 +105,8 @@ def save_export(self, kind: str, threshold: float = 3.5) -> dict[str, object]: def smoke_test() -> None: + import server + httpd = server.create_server(0, secrets.token_urlsafe(32)) worker = threading.Thread(target=httpd.serve_forever, daemon=True) worker.start() @@ -63,29 +124,67 @@ def smoke_test() -> None: def main() -> None: + mutex = acquire_single_instance() + if mutex is None: + return + import server + + try: + log_path = configure_logging() + except OSError: + log_path = server.settings_path().parent / "logs" / "app.log" + logging.basicConfig(level=logging.INFO, force=True) + LOGGER.info("starting version=%s channel=%s", APP_VERSION, BUILD_CHANNEL) os.environ.setdefault("PYWEBVIEW_GUI", "edgechromium") - token = secrets.token_urlsafe(32) - server.STATE["path"] = server.saved_path() - httpd = server.create_server(0, token) - worker = threading.Thread(target=httpd.serve_forever, name="log-analysis-http", daemon=True) - worker.start() - api = DesktopApi() - url = f"http://127.0.0.1:{httpd.server_port}/?token={quote(token)}" - api.window = webview.create_window( - APP_NAME, - url, - js_api=api, - width=1440, - height=900, - min_size=(960, 640), - text_select=True, - ) + httpd = None + worker = None + ready = threading.Event() + stopped = threading.Event() try: + token = secrets.token_urlsafe(32) + server.STATE["path"] = server.saved_path() + httpd = server.create_server(0, token) + worker = threading.Thread(target=httpd.serve_forever, name="log-analysis-http", daemon=True) + worker.start() + LOGGER.info("local service ready port=%s", httpd.server_port) + api = DesktopApi() + url = f"http://127.0.0.1:{httpd.server_port}/?token={quote(token)}" + api.window = webview.create_window( + APP_NAME, + url, + js_api=api, + width=1440, + height=900, + min_size=(960, 640), + text_select=True, + ) + + def page_loaded() -> None: + ready.set() + LOGGER.info("window loaded") + + api.window.events.loaded += page_loaded + + def watch_startup() -> None: + if not ready.wait(15) and not stopped.is_set(): + LOGGER.error("window did not load within 15 seconds") + show_message(f"Log Analysis 启动时间过长。请关闭后重试;诊断日志位于:\n{log_path}") + + threading.Thread(target=watch_startup, name="startup-watchdog", daemon=True).start() webview.start(gui="edgechromium", debug=False) + except Exception as exc: + LOGGER.exception("desktop startup failed") + show_message(f"Log Analysis 启动失败:{exc}\n\n诊断日志:{log_path}") finally: - httpd.shutdown() - httpd.server_close() - worker.join(timeout=3) + stopped.set() + ready.set() + if httpd is not None: + httpd.shutdown() + httpd.server_close() + if worker is not None: + worker.join(timeout=3) + LOGGER.info("stopped") + ctypes.windll.kernel32.CloseHandle(mutex) if __name__ == "__main__": diff --git a/index.html b/index.html index 965a529..1fdca71 100644 --- a/index.html +++ b/index.html @@ -2,28 +2,33 @@
程序只读扫描日志,不会修改任何原始文件。
程序只读扫描日志,不会修改任何原始文件。