From 27b87498056f6de3995e9db027224d5090f32104 Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sun, 31 May 2026 19:12:22 +0800 Subject: [PATCH 01/16] docs: tighten project documentation structure --- DEVELOPMENT.md | 38 ++++++++++--------- README.md | 95 ++++++++++++----------------------------------- UPDATE_RELEASE.md | 43 ++++++++++----------- 3 files changed, 62 insertions(+), 114 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b384e73..f13cd02 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,5 +1,11 @@ # PlotKityCat Development +这份文档负责回答三件事: + +- 如何启动本地开发 +- 如何准备 runtime 与构建发布包 +- 仓库里哪些东西应该进 Git,哪些不应该 + ## 环境 - Windows @@ -23,26 +29,19 @@ wails dev ## 版本 -应用版本唯一来源: - -- `version.json` - -相关脚本都会默认读取这里的 `appVersion`。 +应用版本唯一来源是 `version.json` 的 `appVersion`。构建与发布脚本默认都读取这里的值。 ## Runtime -项目运行依赖便携 Python runtime: +项目运行依赖便携 Python runtime。约定如下: -- runtime 占位目录:`resources/runtime/` -- 发布输入:本地放置的 `resources/runtime/runtime.zip` +- 占位目录:`resources/runtime/` +- 本地发布输入:`resources/runtime/runtime.zip` - 本地展开目录:`runtime/` - 临时展开目录:`runtime.tmp/` - 元数据文件:`runtime.version.json` - -约定: - - `resources/runtime/runtime.zip` 默认不提交到 Git -- 该文件应通过 GitHub Release asset 或其他制品存储分发 +- runtime 应通过 GitHub Release asset 或其他制品存储分发 - 仓库仅跟踪 runtime 脚本、元数据和第三方补丁源码 准备 runtime 压缩包: @@ -51,7 +50,7 @@ wails dev .\tools\prepare-runtime.ps1 -SourceRuntimeDir <你的 runtime 目录> ``` -当前默认应保留的核心库: +默认核心库: - Python 标准库 - numpy @@ -59,11 +58,11 @@ wails dev - scipy - PyQt5 -`runtime.version.json` 应同步填写实际 Python 与核心库版本,不要长期保留 `pending`。 +`runtime.version.json` 必须填写真实版本,不要长期保留 `pending`。 从零重建 runtime 的完整说明见 [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md)。 -## 打包入口 +## 构建与打包 构建 exe: @@ -71,13 +70,13 @@ wails dev .\tools\build-versioned-app.ps1 ``` -生成发布 zip: +生成完整发布包: ```powershell .\tools\package-release.ps1 ``` -生成自动更新发布物: +生成在线更新产物: ```powershell .\tools\prepare-update-release.ps1 @@ -107,10 +106,11 @@ wails dev - `frontend/` - `tools/` - `build/windows/` -- `resources/` 下需要随项目维护的静态资源 +- `resources/` 下需要随项目维护的静态资源与占位文件 - `resources/runtime/.gitkeep` - `README.md` - `DEVELOPMENT.md` +- `UPDATE_RELEASE.md` - `RUNTIME_BUILD.md` - `version.json` - `runtime.version.json` @@ -125,3 +125,5 @@ wails dev - `build/release/` - `build/update/` - `packaging/` + +发布服务器更新步骤见 [UPDATE_RELEASE.md](D:/projects/plotkitycat/UPDATE_RELEASE.md)。 diff --git a/README.md b/README.md index 2332bed..e282f1a 100644 --- a/README.md +++ b/README.md @@ -20,55 +20,48 @@ ## 简介 -请允许我为你介绍这只可爱的,小猫! -PlotKityCat 是一个开源的数学可视化工具,支持运行 Matplotlib 代码并生成交互式图形。集成 AI 功能,支持通过自然语言提示词生成绘图代码。软件采用便携式设计,支持优盘即插即用,方便在教室等不同环境下快速部署与演示。 +PlotKityCat 是一个面向数学教学场景的 AI-native 可视化工具。它基于 Matplotlib 执行绘图代码,支持自然语言生成可视化,并以便携式 runtime 支撑课堂演示与离线分发。 ## 视频介绍 https://github.com/user-attachments/assets/df8167a7-d1e9-4f6a-a42d-de15596a4456 -## 开发初衷 +## 设计原则 -PlotKityCat 源于对 GGBPuppy 开发过程中 GGB Web API 封闭性的反思。我们转向 Matplotlib,为初高中数学可视化提供 AI-native 方案。 - -> 那天,我在研究GGB的webapi,AI总是写下错误的GGB代码,让我的另外一个项目GGBpuppy很受挫折。我突然发现一个GGB的api接口不完整,于是以开发者的口吻发了一封信给他们团队,结果收到了他们希望我付钱的要求......好吧,那天晚上关掉它肮脏线条和色彩的窗口,我梦见了Jobs..... - -1. **开源**:好的工具应该像太阳一样,太阳是闭源的吗? -2. **美**:拒绝 GGB 沉闷的色彩与线条。 -3. **AI 原生**:通过 AI 直接生成可视化代码,无需老师学习编程。 - -PlotKityCat 支持优盘便携,旨在让老师将其带入教室、讲台及学生手中。 +1. **开源**:以可审查、可扩展的技术栈承载教学工具。 +2. **美感**:避免传统数学软件沉闷的视觉体验。 +3. **AI 原生**:让老师通过自然语言驱动可视化生成,而不是先学编程。 ## 功能特性 -- **AI 绘图**:通过自然语言描述数学概念,由 AI 生成 Matplotlib 绘图代码。支持设计和生成代码双流程。 - +- **AI 绘图**:通过自然语言描述数学概念,由 AI 生成 Matplotlib 绘图代码。 - **笔记系统**:集成 Markdown 与 LaTeX 公式渲染,绑定代码,看到可视化的结果,更看到可视化的设计。 -- **便携运行**:内置 Python 运行时,支持U盘即插即用,让小猫真的可以在课堂中一展身手。 -- **.pck导入导出** : 支持导出和导入场景包,希望用户之间可以交流自己的可视化成果。 +- **便携运行**:依赖便携 Python runtime,适合 U 盘和教室环境分发。 +- **场景包导入导出**:支持 `.pck` 场景包的交换与复用。 ## 技术栈 - **前端**: Vue 3, TypeScript, Vite - **后端**: Go, Wails Framework -- **运行时**: WinPython (Matplotlib, NumPy, PyQt5) +- **运行时**: WinPython (NumPy, Matplotlib, SciPy, PyQt5) - **AI 接口**: OpenAI API / 自定义兼容接口 ## 快速开始 -1. **下载**:获取便携版压缩包。 -2. **配置**:设置 AI 服务商 API Key。 -3. **运行**:新建场景,笔记区输入描述,右键点击可视化或可视化设计运行。 +1. 下载便携版压缩包。 +2. 配置 AI 服务商 API Key。 +3. 启动应用并新建场景。 +4. 在笔记区输入描述后运行可视化或可视化设计。 -## 开发者指南 +## 开发入口 -### 环境要求 - **Windows** - **Go**: 1.21+ - **Node.js**: 18+ - **Wails**: v2.x -### 开发启动 +开发启动: + ```powershell cd frontend npm install @@ -76,72 +69,30 @@ cd .. wails dev ``` -### 版本来源 - -应用版本唯一来源: - -- `version.json` - -### Runtime - -项目运行依赖便携 Python runtime: - -- runtime 占位目录:`resources/runtime/` -- 发布输入:本地放置的 `resources/runtime/runtime.zip` -- 本地展开目录:`runtime/` -- 临时展开目录:`runtime.tmp/` -- 元数据文件:`runtime.version.json` - -注意: - -- `resources/runtime/runtime.zip` 默认不提交到 Git -- 这个文件应作为 release asset 或外部制品分发 - -准备 runtime 压缩包: +runtime 打包入口: ```powershell .\tools\prepare-runtime.ps1 -SourceRuntimeDir <你的 runtime 目录> ``` -当前默认核心库: - -- Python 标准库 -- numpy -- matplotlib -- scipy -- PyQt5 - -如何从零重建 runtime,见 [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md)。 - -### 打包入口 - -构建 exe: +应用打包入口: ```powershell .\tools\build-versioned-app.ps1 -``` - -生成发布 zip: - -```powershell .\tools\package-release.ps1 -``` - -生成自动更新发布物: - -```powershell .\tools\prepare-update-release.ps1 ``` -更详细的开发与发布说明见 [DEVELOPMENT.md](D:/projects/PlotKityCat/DEVELOPMENT.md)。 +## 文档索引 + +- 开发与构建总说明: [DEVELOPMENT.md](D:/projects/plotkitycat/DEVELOPMENT.md) +- runtime 分发与重建: [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md) +- 在线更新与发布: [UPDATE_RELEASE.md](D:/projects/plotkitycat/UPDATE_RELEASE.md) ## 致谢 - [Matplotlib](https://matplotlib.org/): 本项目核心渲染引擎。 - [ManimCat](https://github.com/Wing900/ManimCat): 提供了开发的基础和灵感。 - - - ## 期待 期待更多的可视化资源可以被开发,开源,开放,打破教育资源长期以来的垄断,让我们的教育越来越清晰,越来越公平! diff --git a/UPDATE_RELEASE.md b/UPDATE_RELEASE.md index 444636f..ab98adf 100644 --- a/UPDATE_RELEASE.md +++ b/UPDATE_RELEASE.md @@ -1,6 +1,8 @@ # PlotKityCat 更新发布说明 -这份文档只描述当前这套最小更新流程: +这份文档只负责发布与在线更新,不解释 runtime 从零重建。runtime 重建流程单独见 [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md)。 + +当前发布模型: - 更新客户端只更新 `exe` - runtime 走整包发布,不走在线更新 @@ -9,10 +11,11 @@ - `stable/manifest.json` - `releases/PlotKityCat-版本号-windows-amd64.exe` -关于 runtime: +runtime 约定: - `resources/runtime/runtime.zip` 默认不提交到 Git - 它应作为 release asset 或其他外部分发制品单独保存 +- 推荐命名为与应用版本对应的 release asset - 从零重建 runtime 的流程见 [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md) ## 1. 先改版本号 @@ -29,14 +32,12 @@ } ``` -说明: +相关脚本如果不手动传 `-Version`,都会默认读取这里的 `appVersion`: - `tools/build-versioned-app.ps1` - `tools/prepare-update-release.ps1` - `tools/package-release.ps1` -这三个脚本如果不手动传 `-Version`,都会默认读取这里的 `appVersion`。 - ## 2. 构建应用 exe 在仓库根目录运行: @@ -55,7 +56,7 @@ powershell -ExecutionPolicy Bypass -File .\tools\build-versioned-app.ps1 -Versio - [build/bin/PlotKityCat.exe](/D:/projects/PlotKityCat/build/bin/PlotKityCat.exe) -## 3. 生成给更新服务器用的 exe + manifest +## 3. 生成在线更新产物 运行: @@ -80,10 +81,10 @@ powershell -ExecutionPolicy Bypass -File .\tools\prepare-update-release.ps1 -Ver 说明: -- `manifest.json` 里已经自动写好下载地址和 sha256 +- `manifest.json` 会自动写入下载地址和 sha256 - 默认下载地址前缀是 `https://update.5051001.xyz/plotkitycat/releases` -## 4. 生成给用户下载的完整 zip +## 4. 生成完整下载包 运行: @@ -104,10 +105,7 @@ powershell -ExecutionPolicy Bypass -File .\tools\package-release.ps1 -Version 0. 说明: -- 这个 zip 里会包含: - - `PlotKityCat.exe` - - `resources/runtime/runtime.zip` - - `Scripts/` +- 这个 zip 会包含 `PlotKityCat.exe`、`resources/runtime/runtime.zip` 和 `Scripts/` - 因此发布完整包前,必须先在本地准备好 `resources/runtime/runtime.zip` ## 5. 上传到更新服务器 @@ -156,7 +154,7 @@ curl.exe -I https://update.5051001.xyz/plotkitycat/releases/PlotKityCat-0.0.1.9- ## 7. 最短发布流程 -以后你如果只想记住最短流程,就按这个顺序: +只记最短流程时,按这个顺序执行: 1. 改 [version.json](/D:/projects/PlotKityCat/version.json) 的 `appVersion` 2. 跑 `.\tools\build-versioned-app.ps1` @@ -165,18 +163,15 @@ curl.exe -I https://update.5051001.xyz/plotkitycat/releases/PlotKityCat-0.0.1.9- 5. 上传 `build/update/版本号/` 里的 `exe` 6. 把 `build/update/版本号/manifest.json` 覆盖到服务器 `stable/manifest.json` -## 8. 当前固定约定 +## 8. 固定约定 -- 更新 manifest 地址: - - `https://update.5051001.xyz/plotkitycat/stable/manifest.json` -- 更新 exe 文件名格式: - - `PlotKityCat-版本号-windows-amd64.exe` -- 完整发布包目录格式: - - `build/release/PlotKityCat-v版本号` -- 完整发布包 zip 格式: - - `build/release/PlotKityCat-v版本号.zip` +- 更新 manifest 地址:`https://update.5051001.xyz/plotkitycat/stable/manifest.json` +- 更新 exe 文件名格式:`PlotKityCat-版本号-windows-amd64.exe` +- 完整发布包目录格式:`build/release/PlotKityCat-v版本号` +- 完整发布包 zip 格式:`build/release/PlotKityCat-v版本号.zip` +- runtime asset 建议格式:`PlotKityCat-runtime-版本号.zip` -## 9. 如果只想发完整包,不想更新在线更新 +## 9. 只发完整包,不更新在线更新 只需要: @@ -184,7 +179,7 @@ curl.exe -I https://update.5051001.xyz/plotkitycat/releases/PlotKityCat-0.0.1.9- 2. 跑 `.\tools\build-versioned-app.ps1` 3. 跑 `.\tools\package-release.ps1` -这样你会得到完整 zip,但不会更新服务器上的在线更新入口。 +这样会得到完整 zip,但不会更新服务器上的在线更新入口。 ## 10. 常见问题 From cb42f2dbf8b63d6e4bf413164297186b54c2330d Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sun, 31 May 2026 21:19:37 +0800 Subject: [PATCH 02/16] docs: record runtime release asset conventions --- RUNTIME_BUILD.md | 15 ++++++++++++++- UPDATE_RELEASE.md | 7 +++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/RUNTIME_BUILD.md b/RUNTIME_BUILD.md index d1985d7..246ba2e 100644 --- a/RUNTIME_BUILD.md +++ b/RUNTIME_BUILD.md @@ -159,9 +159,22 @@ python .\tools\extract_winpython.py --exe --archive < 推荐约定: -- 每次 runtime 发生变化,都发布一个单独的 runtime asset +- 每次 runtime 发生变化,都发布到对应版本的 GitHub Release +- 当前使用的资产名是 `runtime.zip` - 在 release 说明里注明对应的 `runtime.version.json` +当前下载地址格式: + +```text +https://github.com/Wing900/PlotKityCat/releases/download/v版本号/runtime.zip +``` + +当前示例: + +```text +https://github.com/Wing900/PlotKityCat/releases/download/v0.0.2.6/runtime.zip +``` + ## 常见误区 ### 误区 1:仓库里应该直接提交 `runtime.zip` diff --git a/UPDATE_RELEASE.md b/UPDATE_RELEASE.md index ab98adf..a4d329c 100644 --- a/UPDATE_RELEASE.md +++ b/UPDATE_RELEASE.md @@ -15,7 +15,8 @@ runtime 约定: - `resources/runtime/runtime.zip` 默认不提交到 Git - 它应作为 release asset 或其他外部分发制品单独保存 -- 推荐命名为与应用版本对应的 release asset +- 当前使用的资产名是 `runtime.zip` +- 建议把 runtime 上传到与应用版本对应的 GitHub Release 下 - 从零重建 runtime 的流程见 [RUNTIME_BUILD.md](D:/projects/plotkitycat/RUNTIME_BUILD.md) ## 1. 先改版本号 @@ -169,7 +170,9 @@ curl.exe -I https://update.5051001.xyz/plotkitycat/releases/PlotKityCat-0.0.1.9- - 更新 exe 文件名格式:`PlotKityCat-版本号-windows-amd64.exe` - 完整发布包目录格式:`build/release/PlotKityCat-v版本号` - 完整发布包 zip 格式:`build/release/PlotKityCat-v版本号.zip` -- runtime asset 建议格式:`PlotKityCat-runtime-版本号.zip` +- runtime release 页面格式:`https://github.com/Wing900/PlotKityCat/releases/tag/v版本号` +- runtime 下载地址格式:`https://github.com/Wing900/PlotKityCat/releases/download/v版本号/runtime.zip` +- 当前示例:`https://github.com/Wing900/PlotKityCat/releases/download/v0.0.2.6/runtime.zip` ## 9. 只发完整包,不更新在线更新 From 3089db5a2fa5b7123ee31be1dba289a7485e38fa Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sat, 6 Jun 2026 09:39:42 +0800 Subject: [PATCH 03/16] docs(runtime): add slimming plan --- RUNTIME_SLIMMING_PLAN.md | 293 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 RUNTIME_SLIMMING_PLAN.md diff --git a/RUNTIME_SLIMMING_PLAN.md b/RUNTIME_SLIMMING_PLAN.md new file mode 100644 index 0000000..de3fae7 --- /dev/null +++ b/RUNTIME_SLIMMING_PLAN.md @@ -0,0 +1,293 @@ +# PlotKityCat Runtime 瘦身方案 + +## 目标 + +这份文档只回答一件事: + +- 当前 Windows 发布包里的 Python runtime,哪些内容原则上不该进入最终交付物 +- 哪些 Qt 组件可以分梯队裁剪 +- 保守到激进,最终大概可以压缩多少体积 + +当前基线: + +- `resources/runtime/runtime.zip`: 约 `297.53 MB` + +建议先把目标定成: + +- 第一阶段目标:压到 `220 MB` 左右 +- 第二阶段目标:压到 `180 MB` 左右 + +一个便于沟通的单一数字是: + +- **预估可压缩约 `120 MB`** + +也就是把 `runtime.zip` 从约 `297.53 MB` 压到约 `175 MB - 195 MB`。 + +## 运行时主链 + +当前产品代码明确声明和使用的 runtime 主链是: + +- `python` +- `numpy` +- `matplotlib` +- `scipy` +- `PyQt5` + +当前绘图后端为: + +- `MPLBACKEND=Qt5Agg` + +这意味着最终 runtime 只需要保证下面这条链稳定: + +- Python 解释器 +- Matplotlib +- NumPy / SciPy +- PyQt5 的 Qt Widgets 图形运行能力 + +它不需要携带一整套 WinPython 开发环境。 + +## 可删除的第三方库 + +下面这些包,从当前仓库代码依赖看,不属于 PlotKityCat 的核心运行依赖。 + +这些体积是本地 runtime 解压后的近似值。由于其中多数是二进制或大量字节码文件,它们对 `runtime.zip` 的最终体积也会产生明显影响。 + +### 优先删除 + +- `scs.libs`: `26.71 MB` + 用途:优化求解器底层动态库,通常服务 `scs/cvxpy` +- `pythonwin`: `8.88 MB` + 用途:Windows 下的 Python 开发工具 +- `google`: `4.24 MB` + 用途:Google 相关 Python SDK 命名空间 +- `networkx`: `3.82 MB` + 用途:图算法库 +- `xarray`: `3.53 MB` + 用途:多维标注数组数据分析 +- `pyqtgraph`: `3.39 MB` + 用途:另一套 Qt 绘图库 +- `PyWin32.chm`: `2.52 MB` + 用途:帮助文档 +- `plotpy`: `2.37 MB` + 用途:另一套 plotting 框架 +- `tiktoken`: `2.24 MB` + 用途:tokenizer +- `clarabel`: `2.11 MB` + 用途:优化求解器 +- `huggingface_hub`: `1.97 MB` + 用途:模型仓库客户端 +- `langchain_core`: `1.96 MB` + 用途:LangChain 核心 +- `qdarkstyle`: `1.91 MB` + 用途:Qt 主题库 +- `IPython`: `1.88 MB` + 用途:交互式 Python Shell +- `pylint`: `1.72 MB` + 用途:静态检查 +- `cvxpy`: `1.58 MB` + 用途:凸优化建模 +- `datasette`: `1.27 MB` + 用途:SQLite 浏览服务 +- `seaborn`: `0.98 MB` + 用途:统计绘图库 +- `astroid`: `0.97 MB` + 用途:`pylint` 依赖 +- `datasette_graphql`: `0.90 MB` + 用途:Datasette 插件 +- `black`: `0.65 MB` + 用途:代码格式化 +- `langchain`: `0.39 MB` + 用途:LangChain 高层封装 +- `isort`: `0.31 MB` + 用途:import 排序 +- `nbconvert`: `0.26 MB` + 用途:Notebook 导出 +- `nbformat`: `0.26 MB` + 用途:Notebook 格式 +- `nbclient`: `0.07 MB` + 用途:执行 Notebook + +### 同类可一并清理 + +- `jupyter*` +- `notebook*` +- `ipython_genutils` +- `blackd` +- `pylint_venv` +- `qtpy` + +### 这批的预估收益 + +- 保守:`30 MB - 60 MB` +- 中等:`45 MB - 75 MB` + +## Qt 裁剪梯队 + +Qt 是当前 runtime 里最值得单独处理的部分。 + +本地 `PyQt5/Qt5` 目录的大头约为: + +- `bin`: `177.71 MB` +- `translations`: `25.79 MB` +- `qml`: `18.05 MB` +- `resources`: `15.24 MB` +- `plugins`: `12.01 MB` + +### 第一梯队 + +这是最值得先裁的一组。 + +- `QtWebEngine` 相关整组 + +本地测得的原始体积约: + +- `97.28 MB` + +主要包括: + +- `Qt5WebEngineCore.dll` +- `qtwebengine_resources.pak` +- `qtwebengine_devtools_resources.pak` +- `qtwebengine_resources_100p.pak` +- `qtwebengine_resources_200p.pak` +- `icudtl.dat` +- `translations/qtwebengine_locales/` + +建议: + +- 如果应用没有任何 `QWebEngineView`、嵌入浏览器、HTML 渲染主界面需求,优先整组裁掉 + +预估对最终 `runtime.zip` 的收益: + +- `60 MB - 90 MB` + +风险判断: + +- **中低风险** + +原因: + +- 当前产品运行链是 `Matplotlib + Qt5Agg` +- Matplotlib Qt backend 直接使用的是 `QtCore / QtGui / QtWidgets` +- WebEngine 是独立部署子系统,不是 Widgets 绘图窗口的基础依赖 + +### 第二梯队 + +这一组可以继续裁,但收益小于第一梯队。 + +- `Qt5Designer.dll` +- `Qt5XmlPatterns.dll` +- `Qt5Location.dll` +- `Qt5Multimedia.dll` +- `plugins/platforms/qminimal.dll` +- `plugins/platforms/qoffscreen.dll` +- `plugins/assetimporters/` +- `plugins/renderers/` +- `plugins/sqldrivers/` + +本地测得的大致原始体积: + +- `Designer / Xml / Location / Multimedia`: `4.28 MB` +- `qminimal + qoffscreen`: `0.81 MB` +- `assetimporters + renderers + sqldrivers`: `1.85 MB` + +预估对最终 `runtime.zip` 的收益: + +- `3 MB - 8 MB` + +风险判断: + +- **中风险** + +原因: + +- 这批不在 Matplotlib Qt5Agg 主链上 +- 但 Qt 存在运行时动态加载机制,边缘路径更难仅靠静态 import 判断 + +### 不建议动的部分 + +下面这批不建议在第一轮瘦身里碰: + +- `Qt5Core.dll` +- `Qt5Gui.dll` +- `Qt5Widgets.dll` +- `plugins/platforms/qwindows.dll` + +风险判断: + +- **高风险** + +原因: + +- 这些是 Matplotlib Qt backend 的主链依赖 +- 删除后最常见结果不是“功能退化”,而是“图窗直接起不来” + +## 建议的压缩路径 + +### 方案 A:只清理无关第三方库 + +预估: + +- `297.53 MB -> 240 MB - 265 MB` + +### 方案 B:第三方库 + Qt 第一梯队 + +预估: + +- `297.53 MB -> 180 MB - 230 MB` + +### 方案 C:第三方库 + Qt 第一梯队 + Qt 第二梯队 + +预估: + +- `297.53 MB -> 175 MB - 220 MB` + +建议对外统一报的目标值: + +- **压缩约 `120 MB`** + +## 场景验证建议 + +不建议一次性大删后凭感觉验收。建议至少按下面的手工场景验证: + +### 主链验证 + +- 启动应用 +- 新建场景 +- 运行最简单的 `plt.plot([1, 2, 3])` +- 确认图窗能正常弹出和关闭 +- 连续运行两次,确认不是只有第一次成功 + +### Qt5Agg 交互验证 + +- `matplotlib.widgets.Slider` +- `Button` +- `CheckButtons` +- 缩放、平移、保存图片 +- 同时打开多个 figure + +### 项目特有验证 + +- 运行 `3D surface` 场景 +- 运行使用 `Poly3DCollection` 的场景 +- 验证 `surface-fastpath` 没有被误伤 +- 导入导出场景包后再次运行 + +### 边缘验证 + +- 保存文件对话框是否正常 +- 高 DPI 屏幕是否正常 +- 长时间运行后再次打开图窗是否正常 + +## 官方文档依据 + +- Python embeddable package + https://docs.python.org/3/using/windows.html#the-embeddable-package +- Matplotlib backends + https://matplotlib.org/stable/users/explain/figure/backends.html +- Matplotlib Qt backend API + https://matplotlib.org/stable/api/backend_qt_api.html +- Qt WebEngine deployment + https://doc.qt.io/qt-6/qtwebengine-deploying.html +- Qt plugin deployment + https://doc.qt.io/qt-6/deployment-plugins.html From d47e32bd2c93c13a79955bbab972f929a8098448 Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sat, 6 Jun 2026 09:40:03 +0800 Subject: [PATCH 04/16] feat(runtime): persist workspace state and track runtime bridge --- .../features/runtime/model/useRuntimeState.ts | 77 ++++++ .../runtime/services/runtimeBridgeCompat.ts | 73 +++++ .../runtime/services/runtimeRepository.ts | 10 + frontend/wailsjs/runtime/package.json | 24 ++ frontend/wailsjs/runtime/runtime.d.ts | 249 ++++++++++++++++++ frontend/wailsjs/runtime/runtime.js | 238 +++++++++++++++++ internal/paths/paths.go | 9 + internal/workspaces/manager.go | 28 +- internal/workspaces/state_store.go | 61 +++++ 9 files changed, 764 insertions(+), 5 deletions(-) create mode 100644 frontend/src/features/runtime/model/useRuntimeState.ts create mode 100644 frontend/src/features/runtime/services/runtimeBridgeCompat.ts create mode 100644 frontend/src/features/runtime/services/runtimeRepository.ts create mode 100644 frontend/wailsjs/runtime/package.json create mode 100644 frontend/wailsjs/runtime/runtime.d.ts create mode 100644 frontend/wailsjs/runtime/runtime.js create mode 100644 internal/workspaces/state_store.go diff --git a/frontend/src/features/runtime/model/useRuntimeState.ts b/frontend/src/features/runtime/model/useRuntimeState.ts new file mode 100644 index 0000000..147f985 --- /dev/null +++ b/frontend/src/features/runtime/model/useRuntimeState.ts @@ -0,0 +1,77 @@ +import { ref } from "vue"; +import type { RuntimeStatusLike } from "../services/runtimeBridgeCompat"; + +export function useRuntimeState() { + const environmentStatus = ref({ + ready: false, + code: "unknown", + severity: "error", + summary: "Not Ready", + recommendedAction: "", + items: [] as Array<{ + key?: string; + label?: string; + category?: string; + status?: string; + message?: string; + exists?: boolean; + }>, + missing: [] as string[], + canRebuild: false, + runtimeArchiveExists: false, + }); + const isInitializing = ref(true); + const isRebuilding = ref(false); + const initProgressPercent = ref(0); + const initProgressMessage = ref("Preparing runtime"); + + function applyEnvironmentStatus(status?: RuntimeStatusLike) { + environmentStatus.value = { + ready: !!status?.ready, + code: asString(status?.code || "unknown"), + severity: asString(status?.severity || "error"), + summary: asString(status?.summary), + recommendedAction: asString(status?.recommendedAction), + items: Array.isArray(status?.items) ? status.items : [], + missing: Array.isArray(status?.missing) ? status.missing : [], + canRebuild: !!status?.canRebuild, + runtimeArchiveExists: !!status?.runtimeArchiveExists, + }; + } + + function applyProgress(progress?: { percent?: number; message?: string }) { + if (typeof progress?.percent === "number") { + initProgressPercent.value = progress.percent; + } + if (progress?.message) { + initProgressMessage.value = progress.message; + } + } + + function finishInitialization(message = "Runtime ready") { + initProgressPercent.value = 100; + initProgressMessage.value = message; + isInitializing.value = false; + } + + function failInitialization(message: string) { + initProgressMessage.value = message; + isInitializing.value = false; + } + + return { + applyEnvironmentStatus, + applyProgress, + environmentStatus, + failInitialization, + finishInitialization, + isRebuilding, + initProgressMessage, + initProgressPercent, + isInitializing, + }; +} + +function asString(value: unknown) { + return typeof value === "string" ? value : String(value ?? ""); +} diff --git a/frontend/src/features/runtime/services/runtimeBridgeCompat.ts b/frontend/src/features/runtime/services/runtimeBridgeCompat.ts new file mode 100644 index 0000000..2ec754b --- /dev/null +++ b/frontend/src/features/runtime/services/runtimeBridgeCompat.ts @@ -0,0 +1,73 @@ +import { + GetEnvironmentStatus, + InitializeApp, + RebuildRuntime, + StopCurrentRun, +} from "../../../../wailsjs/go/bridge/App"; + +export type RuntimeCheckItemLike = { + key?: string; + label?: string; + relativePath?: string; + category?: string; + status?: string; + message?: string; + exists?: boolean; +}; + +export type RuntimeStatusLike = { + ready?: boolean; + code?: string; + severity?: string; + runtimeDir?: string; + summary?: unknown; + recommendedAction?: unknown; + checkedAt?: string; + items?: RuntimeCheckItemLike[]; + missing?: string[]; + canRebuild?: boolean; + runtimeArchivePath?: string; + runtimeArchiveExists?: boolean; +}; + +export type RunControlResultLike = { + handled?: boolean; + message?: string; +}; + +type RuntimeBridgeAppCompat = { + RebuildRuntime?: () => Promise; + StopCurrentRun?: () => Promise; +}; + +export async function getEnvironmentStatus() { + return GetEnvironmentStatus(); +} + +export async function initializeApp() { + return InitializeApp(); +} + +export async function rebuildRuntime(): Promise { + const bridgeApp = getBridgeApp(); + if (typeof bridgeApp.RebuildRuntime === "function") { + return bridgeApp.RebuildRuntime(); + } + + return RebuildRuntime(); +} + +export async function stopCurrentRun(): Promise { + const bridgeApp = getBridgeApp(); + if (typeof bridgeApp.StopCurrentRun === "function") { + return bridgeApp.StopCurrentRun(); + } + + return StopCurrentRun(); +} + +function getBridgeApp(): RuntimeBridgeAppCompat { + return ((window as typeof window & { + go?: { bridge?: { App?: RuntimeBridgeAppCompat } }; + }).go?.bridge?.App ?? {}) as RuntimeBridgeAppCompat; +} diff --git a/frontend/src/features/runtime/services/runtimeRepository.ts b/frontend/src/features/runtime/services/runtimeRepository.ts new file mode 100644 index 0000000..d9c4fa2 --- /dev/null +++ b/frontend/src/features/runtime/services/runtimeRepository.ts @@ -0,0 +1,10 @@ +import * as bridge from "./runtimeBridgeCompat"; + +export function createRuntimeRepository() { + return { + getEnvironmentStatus: bridge.getEnvironmentStatus, + initializeApp: bridge.initializeApp, + rebuildRuntime: bridge.rebuildRuntime, + stopCurrentRun: bridge.stopCurrentRun, + }; +} diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/frontend/wailsjs/runtime/package.json @@ -0,0 +1,24 @@ +{ + "name": "@wailsapp/runtime", + "version": "2.0.0", + "description": "Wails Javascript runtime library", + "main": "runtime.js", + "types": "runtime.d.ts", + "scripts": { + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wailsapp/wails.git" + }, + "keywords": [ + "Wails", + "Javascript", + "Go" + ], + "author": "Lea Anthony ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..4445dac --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -0,0 +1,249 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export interface Position { + x: number; + y: number; +} + +export interface Size { + w: number; + h: number; +} + +export interface Screen { + isCurrent: boolean; + isPrimary: boolean; + width : number + height : number +} + +// Environment information such as platform, buildtype, ... +export interface EnvironmentInfo { + buildType: string; + platform: string; + arch: string; +} + +// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit) +// emits the given event. Optional data may be passed with the event. +// This will trigger any event listeners. +export function EventsEmit(eventName: string, ...data: any): void; + +// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name. +export function EventsOn(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple) +// sets up a listener for the given event name, but will only trigger a given number times. +export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void; + +// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce) +// sets up a listener for the given event name, but will only trigger once. +export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void; + +// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff) +// unregisters the listener for the given event name. +export function EventsOff(eventName: string, ...additionalEventNames: string[]): void; + +// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall) +// unregisters all listeners. +export function EventsOffAll(): void; + +// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint) +// logs the given message as a raw message +export function LogPrint(message: string): void; + +// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace) +// logs the given message at the `trace` log level. +export function LogTrace(message: string): void; + +// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug) +// logs the given message at the `debug` log level. +export function LogDebug(message: string): void; + +// [LogError](https://wails.io/docs/reference/runtime/log#logerror) +// logs the given message at the `error` log level. +export function LogError(message: string): void; + +// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal) +// logs the given message at the `fatal` log level. +// The application will quit after calling this method. +export function LogFatal(message: string): void; + +// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo) +// logs the given message at the `info` log level. +export function LogInfo(message: string): void; + +// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning) +// logs the given message at the `warning` log level. +export function LogWarning(message: string): void; + +// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload) +// Forces a reload by the main application as well as connected browsers. +export function WindowReload(): void; + +// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp) +// Reloads the application frontend. +export function WindowReloadApp(): void; + +// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop) +// Sets the window AlwaysOnTop or not on top. +export function WindowSetAlwaysOnTop(b: boolean): void; + +// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme) +// *Windows only* +// Sets window theme to system default (dark/light). +export function WindowSetSystemDefaultTheme(): void; + +// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme) +// *Windows only* +// Sets window to light theme. +export function WindowSetLightTheme(): void; + +// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme) +// *Windows only* +// Sets window to dark theme. +export function WindowSetDarkTheme(): void; + +// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter) +// Centers the window on the monitor the window is currently on. +export function WindowCenter(): void; + +// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle) +// Sets the text in the window title bar. +export function WindowSetTitle(title: string): void; + +// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen) +// Makes the window full screen. +export function WindowFullscreen(): void; + +// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen) +// Restores the previous window dimensions and position prior to full screen. +export function WindowUnfullscreen(): void; + +// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen) +// Returns the state of the window, i.e. whether the window is in full screen mode or not. +export function WindowIsFullscreen(): Promise; + +// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize) +// Sets the width and height of the window. +export function WindowSetSize(width: number, height: number): void; + +// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize) +// Gets the width and height of the window. +export function WindowGetSize(): Promise; + +// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize) +// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMaxSize(width: number, height: number): void; + +// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize) +// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions. +// Setting a size of 0,0 will disable this constraint. +export function WindowSetMinSize(width: number, height: number): void; + +// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition) +// Sets the window position relative to the monitor the window is currently on. +export function WindowSetPosition(x: number, y: number): void; + +// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition) +// Gets the window position relative to the monitor the window is currently on. +export function WindowGetPosition(): Promise; + +// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide) +// Hides the window. +export function WindowHide(): void; + +// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow) +// Shows the window, if it is currently hidden. +export function WindowShow(): void; + +// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise) +// Maximises the window to fill the screen. +export function WindowMaximise(): void; + +// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise) +// Toggles between Maximised and UnMaximised. +export function WindowToggleMaximise(): void; + +// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise) +// Restores the window to the dimensions and position prior to maximising. +export function WindowUnmaximise(): void; + +// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised) +// Returns the state of the window, i.e. whether the window is maximised or not. +export function WindowIsMaximised(): Promise; + +// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise) +// Minimises the window. +export function WindowMinimise(): void; + +// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise) +// Restores the window to the dimensions and position prior to minimising. +export function WindowUnminimise(): void; + +// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised) +// Returns the state of the window, i.e. whether the window is minimised or not. +export function WindowIsMinimised(): Promise; + +// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal) +// Returns the state of the window, i.e. whether the window is normal or not. +export function WindowIsNormal(): Promise; + +// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour) +// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels. +export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void; + +// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall) +// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system. +export function ScreenGetAll(): Promise; + +// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl) +// Opens the given URL in the system browser. +export function BrowserOpenURL(url: string): void; + +// [Environment](https://wails.io/docs/reference/runtime/intro#environment) +// Returns information about the environment +export function Environment(): Promise; + +// [Quit](https://wails.io/docs/reference/runtime/intro#quit) +// Quits the application. +export function Quit(): void; + +// [Hide](https://wails.io/docs/reference/runtime/intro#hide) +// Hides the application. +export function Hide(): void; + +// [Show](https://wails.io/docs/reference/runtime/intro#show) +// Shows the application. +export function Show(): void; + +// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext) +// Returns the current text stored on clipboard +export function ClipboardGetText(): Promise; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop) +// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. +export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void + +// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff) +// OnFileDropOff removes the drag and drop listeners and handlers. +export function OnFileDropOff() :void + +// Check if the file path resolver is available +export function CanResolveFilePaths(): boolean; + +// Resolves file paths for an array of files +export function ResolveFilePaths(files: File[]): void \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..623397b --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.js @@ -0,0 +1,238 @@ +/* + _ __ _ __ +| | / /___ _(_) /____ +| | /| / / __ `/ / / ___/ +| |/ |/ / /_/ / / (__ ) +|__/|__/\__,_/_/_/____/ +The electron alternative for Go +(c) Lea Anthony 2019-present +*/ + +export function LogPrint(message) { + window.runtime.LogPrint(message); +} + +export function LogTrace(message) { + window.runtime.LogTrace(message); +} + +export function LogDebug(message) { + window.runtime.LogDebug(message); +} + +export function LogInfo(message) { + window.runtime.LogInfo(message); +} + +export function LogWarning(message) { + window.runtime.LogWarning(message); +} + +export function LogError(message) { + window.runtime.LogError(message); +} + +export function LogFatal(message) { + window.runtime.LogFatal(message); +} + +export function EventsOnMultiple(eventName, callback, maxCallbacks) { + return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks); +} + +export function EventsOn(eventName, callback) { + return EventsOnMultiple(eventName, callback, -1); +} + +export function EventsOff(eventName, ...additionalEventNames) { + return window.runtime.EventsOff(eventName, ...additionalEventNames); +} + +export function EventsOnce(eventName, callback) { + return EventsOnMultiple(eventName, callback, 1); +} + +export function EventsEmit(eventName) { + let args = [eventName].slice.call(arguments); + return window.runtime.EventsEmit.apply(null, args); +} + +export function WindowReload() { + window.runtime.WindowReload(); +} + +export function WindowReloadApp() { + window.runtime.WindowReloadApp(); +} + +export function WindowSetAlwaysOnTop(b) { + window.runtime.WindowSetAlwaysOnTop(b); +} + +export function WindowSetSystemDefaultTheme() { + window.runtime.WindowSetSystemDefaultTheme(); +} + +export function WindowSetLightTheme() { + window.runtime.WindowSetLightTheme(); +} + +export function WindowSetDarkTheme() { + window.runtime.WindowSetDarkTheme(); +} + +export function WindowCenter() { + window.runtime.WindowCenter(); +} + +export function WindowSetTitle(title) { + window.runtime.WindowSetTitle(title); +} + +export function WindowFullscreen() { + window.runtime.WindowFullscreen(); +} + +export function WindowUnfullscreen() { + window.runtime.WindowUnfullscreen(); +} + +export function WindowIsFullscreen() { + return window.runtime.WindowIsFullscreen(); +} + +export function WindowGetSize() { + return window.runtime.WindowGetSize(); +} + +export function WindowSetSize(width, height) { + window.runtime.WindowSetSize(width, height); +} + +export function WindowSetMaxSize(width, height) { + window.runtime.WindowSetMaxSize(width, height); +} + +export function WindowSetMinSize(width, height) { + window.runtime.WindowSetMinSize(width, height); +} + +export function WindowSetPosition(x, y) { + window.runtime.WindowSetPosition(x, y); +} + +export function WindowGetPosition() { + return window.runtime.WindowGetPosition(); +} + +export function WindowHide() { + window.runtime.WindowHide(); +} + +export function WindowShow() { + window.runtime.WindowShow(); +} + +export function WindowMaximise() { + window.runtime.WindowMaximise(); +} + +export function WindowToggleMaximise() { + window.runtime.WindowToggleMaximise(); +} + +export function WindowUnmaximise() { + window.runtime.WindowUnmaximise(); +} + +export function WindowIsMaximised() { + return window.runtime.WindowIsMaximised(); +} + +export function WindowMinimise() { + window.runtime.WindowMinimise(); +} + +export function WindowUnminimise() { + window.runtime.WindowUnminimise(); +} + +export function WindowSetBackgroundColour(R, G, B, A) { + window.runtime.WindowSetBackgroundColour(R, G, B, A); +} + +export function ScreenGetAll() { + return window.runtime.ScreenGetAll(); +} + +export function WindowIsMinimised() { + return window.runtime.WindowIsMinimised(); +} + +export function WindowIsNormal() { + return window.runtime.WindowIsNormal(); +} + +export function BrowserOpenURL(url) { + window.runtime.BrowserOpenURL(url); +} + +export function Environment() { + return window.runtime.Environment(); +} + +export function Quit() { + window.runtime.Quit(); +} + +export function Hide() { + window.runtime.Hide(); +} + +export function Show() { + window.runtime.Show(); +} + +export function ClipboardGetText() { + return window.runtime.ClipboardGetText(); +} + +export function ClipboardSetText(text) { + return window.runtime.ClipboardSetText(text); +} + +/** + * Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * + * @export + * @callback OnFileDropCallback + * @param {number} x - x coordinate of the drop + * @param {number} y - y coordinate of the drop + * @param {string[]} paths - A list of file paths. + */ + +/** + * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings. + * + * @export + * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished. + * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target) + */ +export function OnFileDrop(callback, useDropTarget) { + return window.runtime.OnFileDrop(callback, useDropTarget); +} + +/** + * OnFileDropOff removes the drag and drop listeners and handlers. + */ +export function OnFileDropOff() { + return window.runtime.OnFileDropOff(); +} + +export function CanResolveFilePaths() { + return window.runtime.CanResolveFilePaths(); +} + +export function ResolveFilePaths(files) { + return window.runtime.ResolveFilePaths(files); +} \ No newline at end of file diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 4bc951d..0ea324e 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -51,6 +51,15 @@ func AISettingsPath() (string, error) { return filepath.Join(dir, "ai-settings.json"), nil } +func WorkspaceStatePath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "workspace-state.json"), nil +} + func UpdatesDir() (string, error) { dir, err := ConfigDir() if err != nil { diff --git a/internal/workspaces/manager.go b/internal/workspaces/manager.go index b7a52a8..bc636d9 100644 --- a/internal/workspaces/manager.go +++ b/internal/workspaces/manager.go @@ -8,12 +8,15 @@ import ( ) type Manager struct { - mu sync.Mutex - active string + mu sync.Mutex + active string + stateStore *StateStore } func NewManager() *Manager { - return &Manager{} + return &Manager{ + stateStore: NewStateStore(), + } } func (m *Manager) EnsureReady() error { @@ -55,7 +58,16 @@ func (m *Manager) CurrentDir() (string, error) { } if m.active == "" || !contains(names, m.active) { - m.active = chooseInitialWorkspace(names) + if restored, err := m.stateStore.LoadCurrentWorkspace(); err != nil { + return "", err + } else if restored != "" && contains(names, restored) { + m.active = restored + } else { + m.active = chooseInitialWorkspace(names) + } + if err := m.stateStore.SaveCurrentWorkspace(m.active); err != nil { + return "", err + } } return filepath.Join(root, m.active), nil @@ -94,7 +106,7 @@ func (m *Manager) Switch(name string) error { } m.active = name - return nil + return m.stateStore.SaveCurrentWorkspace(m.active) } func (m *Manager) Create(name string) (Workspace, error) { @@ -122,6 +134,9 @@ func (m *Manager) Create(name string) (Workspace, error) { } m.active = name + if err := m.stateStore.SaveCurrentWorkspace(m.active); err != nil { + return Workspace{}, err + } return Workspace{Name: name, SceneCount: 0}, nil } @@ -158,6 +173,9 @@ func (m *Manager) Rename(oldName string, newName string) (Workspace, error) { } if m.active == oldName { m.active = newName + if err := m.stateStore.SaveCurrentWorkspace(m.active); err != nil { + return Workspace{}, err + } } count, err := countScenes(newPath) diff --git a/internal/workspaces/state_store.go b/internal/workspaces/state_store.go new file mode 100644 index 0000000..4a1d116 --- /dev/null +++ b/internal/workspaces/state_store.go @@ -0,0 +1,61 @@ +package workspaces + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "plotkitycat/internal/paths" +) + +type stateFile struct { + CurrentWorkspace string `json:"current_workspace"` +} + +type StateStore struct{} + +func NewStateStore() *StateStore { + return &StateStore{} +} + +func (s *StateStore) LoadCurrentWorkspace() (string, error) { + path, err := paths.WorkspaceStatePath() + if err != nil { + return "", err + } + + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var state stateFile + if err := json.Unmarshal(content, &state); err != nil { + return "", err + } + + return strings.TrimSpace(state.CurrentWorkspace), nil +} + +func (s *StateStore) SaveCurrentWorkspace(name string) error { + path, err := paths.WorkspaceStatePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + content, err := json.MarshalIndent(stateFile{ + CurrentWorkspace: strings.TrimSpace(name), + }, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, append(content, '\n'), 0o644) +} From d4e5d25a9ab473ee9c714c0a78afd7167b9ca582 Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sat, 6 Jun 2026 09:40:12 +0800 Subject: [PATCH 05/16] feat(editor): add in-editor code search --- frontend/src/components/editor/EditorPane.vue | 37 +++- .../src/components/editor/codeMirrorTheme.ts | 9 + .../components/editor/useCodeMirrorEditor.ts | 174 +++++++++++++++++- frontend/src/styles/components/editor.css | 56 ++++++ 4 files changed, 274 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/editor/EditorPane.vue b/frontend/src/components/editor/EditorPane.vue index 2d8b6b1..3a6f012 100644 --- a/frontend/src/components/editor/EditorPane.vue +++ b/frontend/src/components/editor/EditorPane.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/editor/codeMirrorTheme.ts b/frontend/src/components/editor/codeMirrorTheme.ts index ce2e64d..b587451 100644 --- a/frontend/src/components/editor/codeMirrorTheme.ts +++ b/frontend/src/components/editor/codeMirrorTheme.ts @@ -116,6 +116,15 @@ export const editorTheme = EditorView.theme({ ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "color-mix(in srgb, var(--muted), transparent 78%)", }, + ".cm-search-match": { + backgroundColor: "color-mix(in srgb, #ffd76a, transparent 42%)", + borderRadius: "3px", + }, + ".cm-search-match-active": { + backgroundColor: "color-mix(in srgb, #ffb347, transparent 22%)", + borderRadius: "3px", + boxShadow: "inset 0 0 0 1px color-mix(in srgb, #c46a18, transparent 28%)", + }, ".cm-cursor": { borderLeftColor: "var(--text)", }, diff --git a/frontend/src/components/editor/useCodeMirrorEditor.ts b/frontend/src/components/editor/useCodeMirrorEditor.ts index c724d0e..e07efec 100644 --- a/frontend/src/components/editor/useCodeMirrorEditor.ts +++ b/frontend/src/components/editor/useCodeMirrorEditor.ts @@ -1,6 +1,7 @@ -import { shallowRef, type ComputedRef, type Ref } from "vue"; +import { ref, shallowRef, type ComputedRef, type Ref } from "vue"; import { Compartment, + Decoration, type DecorationSet, EditorState, EditorView, @@ -38,8 +39,14 @@ type MountOptions = { export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { const editorView = shallowRef(null); + const isSearchOpen = ref(false); + const searchMatchCount = ref(0); + const searchQuery = ref(""); + const searchActiveIndex = ref(-1); const editableMode = new Compartment(); + const searchDecorations = new Compartment(); let isApplyingExternalCode = false; + let searchRanges: Array<{ from: number; to: number }> = []; function mountEditor(mountOptions: MountOptions) { if (!options.editorRoot.value) { @@ -64,6 +71,7 @@ export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { editorTheme, editableMode.of(EditorView.editable.of(!options.disabled())), mountOptions.cardDecorations.of(EditorView.decorations.of(mountOptions.buildDecorations())), + searchDecorations.of(EditorView.decorations.of(buildSearchDecorations())), EditorView.updateListener.of((update) => { if (update.docChanged && !isApplyingExternalCode) { options.onCodeChange(update.state.doc.toString()); @@ -71,9 +79,15 @@ export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { if (update.docChanged || update.selectionSet || update.viewportChanged) { options.onEditorActivity(); } + if (update.docChanged && isSearchOpen.value && searchQuery.value !== "") { + queueMicrotask(() => { + refreshSearch(false); + }); + } }), EditorView.domEventHandlers({ contextmenu: handleContextMenu, + keydown: handleEditorKeyDown, }), highlightActiveLine(), ], @@ -87,6 +101,7 @@ export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { function destroyEditor() { editorView.value?.destroy(); editorView.value = null; + searchRanges = []; } function syncExternalCode(code: string) { @@ -100,6 +115,7 @@ export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { changes: { from: 0, to: view.state.doc.length, insert: code }, }); isApplyingExternalCode = false; + refreshSearch(false); return true; } @@ -119,11 +135,167 @@ export function useCodeMirrorEditor(options: CodeMirrorEditorOptions) { return true; } + function handleEditorKeyDown(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + openSearch(); + return true; + } + return false; + } + + function openSearch() { + const view = editorView.value; + isSearchOpen.value = true; + if (!view) { + return; + } + + const selectedText = view.state.sliceDoc( + view.state.selection.main.from, + view.state.selection.main.to, + ); + if (searchQuery.value === "" && selectedText.trim() !== "" && !selectedText.includes("\n")) { + searchQuery.value = selectedText; + } + refreshSearch(true); + } + + function closeSearch() { + isSearchOpen.value = false; + searchQuery.value = ""; + searchMatchCount.value = 0; + searchActiveIndex.value = -1; + searchRanges = []; + applySearchDecorations(buildSearchDecorations()); + focusEditor(); + } + + function updateSearchQuery(value: string) { + searchQuery.value = value; + searchActiveIndex.value = 0; + refreshSearch(true); + } + + function focusEditor() { + editorView.value?.focus(); + } + + function findNextMatch() { + if (searchRanges.length === 0) { + return; + } + searchActiveIndex.value = (searchActiveIndex.value + 1 + searchRanges.length) % searchRanges.length; + revealActiveMatch(); + } + + function findPreviousMatch() { + if (searchRanges.length === 0) { + return; + } + searchActiveIndex.value = (searchActiveIndex.value - 1 + searchRanges.length) % searchRanges.length; + revealActiveMatch(); + } + + function refreshSearch(revealActive: boolean) { + if (!editorView.value) { + return; + } + + const query = searchQuery.value; + if (query === "") { + searchRanges = []; + searchMatchCount.value = 0; + searchActiveIndex.value = -1; + applySearchDecorations(buildSearchDecorations()); + return; + } + + searchRanges = collectSearchRanges(editorView.value.state.doc.toString(), query); + searchMatchCount.value = searchRanges.length; + if (searchRanges.length === 0) { + searchActiveIndex.value = -1; + applySearchDecorations(buildSearchDecorations()); + return; + } + + if (searchActiveIndex.value < 0 || searchActiveIndex.value >= searchRanges.length) { + searchActiveIndex.value = 0; + } + applySearchDecorations(buildSearchDecorations()); + if (revealActive) { + revealActiveMatch(); + } + } + + function revealActiveMatch() { + const view = editorView.value; + if (!view || searchActiveIndex.value < 0 || searchActiveIndex.value >= searchRanges.length) { + applySearchDecorations(buildSearchDecorations()); + return; + } + + const activeRange = searchRanges[searchActiveIndex.value]; + applySearchDecorations(buildSearchDecorations()); + view.dispatch({ + selection: { anchor: activeRange.from, head: activeRange.to }, + scrollIntoView: true, + }); + view.focus(); + } + + function applySearchDecorations(decorations: DecorationSet) { + editorView.value?.dispatch({ + effects: searchDecorations.reconfigure(EditorView.decorations.of(decorations)), + }); + } + + function buildSearchDecorations() { + if (searchRanges.length === 0) { + return Decoration.none; + } + + const decorations = searchRanges.map((range, index) => + Decoration.mark({ + class: index === searchActiveIndex.value ? "cm-search-match-active" : "cm-search-match", + }).range(range.from, range.to), + ); + return Decoration.set(decorations, true); + } + + function collectSearchRanges(content: string, query: string) { + const ranges: Array<{ from: number; to: number }> = []; + if (query === "") { + return ranges; + } + + let start = 0; + while (start <= content.length) { + const foundAt = content.indexOf(query, start); + if (foundAt === -1) { + break; + } + ranges.push({ from: foundAt, to: foundAt + query.length }); + start = foundAt + Math.max(query.length, 1); + } + return ranges; + } + return { destroyEditor, editorView, + findNextMatch, + findPreviousMatch, + focusEditor, + isSearchOpen, mountEditor, + openSearch, + closeSearch, + searchActiveIndex, + searchMatchCount, + searchQuery, syncDisabled, syncExternalCode, + updateSearchQuery, }; } diff --git a/frontend/src/styles/components/editor.css b/frontend/src/styles/components/editor.css index 70f0223..b686169 100644 --- a/frontend/src/styles/components/editor.css +++ b/frontend/src/styles/components/editor.css @@ -26,6 +26,62 @@ overflow: hidden; } +.editor-search-bar { + position: absolute; + top: 12px; + right: 18px; + z-index: 4; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--line), transparent 20%); + border-radius: 12px; + background: color-mix(in srgb, var(--surface), white 18%); + box-shadow: 0 10px 24px rgba(20, 24, 36, 0.12); + backdrop-filter: blur(10px); +} + +.editor-search-input { + width: 220px; + min-width: 0; + padding: 7px 10px; + border: 1px solid color-mix(in srgb, var(--line), transparent 28%); + border-radius: 9px; + background: rgba(255, 255, 255, 0.88); + color: var(--text); + font: inherit; +} + +.editor-search-input:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent), transparent 38%); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent), transparent 82%); +} + +.editor-search-status { + min-width: 36px; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.editor-search-button, +.editor-search-close { + border: 0; + border-radius: 9px; + padding: 7px 10px; + background: color-mix(in srgb, var(--surface-soft), white 14%); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.editor-search-button:hover, +.editor-search-close:hover { + background: color-mix(in srgb, var(--surface-soft), var(--accent) 12%); +} + @keyframes editor-stream-line { from { opacity: 0.1; From 5e306101597e3b9d67bcc68d8739754026a6a35d Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sat, 6 Jun 2026 09:40:21 +0800 Subject: [PATCH 06/16] fix(subscription): persist resolved device id --- internal/subscription/service.go | 60 ++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/internal/subscription/service.go b/internal/subscription/service.go index 52ac6c3..8a88c40 100644 --- a/internal/subscription/service.go +++ b/internal/subscription/service.go @@ -59,16 +59,22 @@ func (s *Service) Session(ctx context.Context, force bool) (Session, error) { } func (s *Service) PurchaseLink() (PurchaseLink, error) { - if s.deviceIDProvider == nil { + state, err := s.store.Load() + if err != nil { + return PurchaseLink{}, err + } + + deviceID, stateChanged, err := s.resolveDeviceID(&state) + if err != nil { return PurchaseLink{ Configured: false, Message: "设备识别服务未就绪", }, nil } - - deviceID, err := s.deviceIDProvider.ID() - if err != nil { - return PurchaseLink{}, err + if stateChanged { + if err := s.store.Save(state); err != nil { + return PurchaseLink{}, err + } } url, err := buildPurchaseURL(purchaseURL(), deviceID) @@ -92,25 +98,27 @@ func (s *Service) PurchaseLink() (PurchaseLink, error) { } func (s *Service) resolveState(ctx context.Context, force bool) (CacheState, error) { - if s.deviceIDProvider == nil { - return CacheState{ - Status: StatusError, - Message: "设备识别服务未就绪", - }, nil - } - - deviceID, err := s.deviceIDProvider.ID() + state, err := s.store.Load() if err != nil { return CacheState{}, err } - state, err := s.store.Load() + deviceID, stateChanged, err := s.resolveDeviceID(&state) if err != nil { - return CacheState{}, err + return CacheState{ + Status: StatusError, + Message: "设备识别服务未就绪", + }, nil } state.DeviceID = deviceID if strings.TrimSpace(state.InstallID) == "" { state.InstallID = uuid.NewString() + stateChanged = true + } + if stateChanged { + if err := s.store.Save(state); err != nil { + return CacheState{}, err + } } if !force && s.canUseCache(state) { @@ -153,6 +161,28 @@ func (s *Service) resolveState(ctx context.Context, force bool) (CacheState, err return state, nil } +func (s *Service) resolveDeviceID(state *CacheState) (string, bool, error) { + if state != nil { + if deviceID := strings.TrimSpace(state.DeviceID); deviceID != "" { + state.DeviceID = deviceID + return deviceID, false, nil + } + } + if s.deviceIDProvider == nil { + return "", false, fmt.Errorf("device id provider is nil") + } + + deviceID, err := s.deviceIDProvider.ID() + if err != nil { + return "", false, err + } + deviceID = strings.TrimSpace(deviceID) + if state != nil { + state.DeviceID = deviceID + } + return deviceID, true, nil +} + func (s *Service) canUseCache(state CacheState) bool { if strings.TrimSpace(state.DeviceID) == "" || strings.TrimSpace(state.LastCheckedAt) == "" { return false From b872998f2093ad2b2a18ce737cf2e49668abf79f Mon Sep 17 00:00:00 2001 From: Wing900 <2249381074@qq.com> Date: Sun, 7 Jun 2026 09:59:02 +0800 Subject: [PATCH 07/16] fix(editor): refine inline code search bar --- frontend/src/components/editor/EditorPane.vue | 69 +++++++++++----- .../components/editor/useCodeMirrorEditor.ts | 12 +-- frontend/src/styles/components/editor.css | 80 +++++++++++-------- 3 files changed, 99 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/editor/EditorPane.vue b/frontend/src/components/editor/EditorPane.vue index 3a6f012..514a817 100644 --- a/frontend/src/components/editor/EditorPane.vue +++ b/frontend/src/components/editor/EditorPane.vue @@ -30,6 +30,7 @@ const emit = defineEmits<{ }>(); const editorRoot = ref(null); +const searchBar = ref(null); const searchInput = ref(null); const isDesignCardDraggingOver = ref(false); const normalizedCode = computed(() => @@ -49,6 +50,14 @@ const editor = useCodeMirrorEditor({ target instanceof Element && Boolean(target.closest(".design-card-inline-block")), }); +const isSearchOpen = computed(() => editor.isSearchOpen.value); +const searchQuery = computed(() => editor.searchQuery.value); +const searchMatchLabel = computed(() => + editor.searchMatchCount.value > 0 && editor.searchActiveIndex.value >= 0 + ? `${editor.searchActiveIndex.value + 1}/${editor.searchMatchCount.value}` + : "0/0", +); + const viewportAnchor = useEditorViewportAnchor({ editorView: editor.editorView, onAnchorLine: (line) => emit("design-card-anchor-line", line), @@ -140,7 +149,7 @@ watch( ); watch( - () => editor.isSearchOpen.value, + () => isSearchOpen.value, async (isOpen) => { if (!isOpen) { return; @@ -150,34 +159,50 @@ watch( searchInput.value?.select(); }, ); + +function handlePanelPointerDown(event: PointerEvent) { + if (!isSearchOpen.value) { + return; + } + + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (searchBar.value?.contains(target)) { + return; + } + + editor.closeSearch(); +}