diff --git a/.gitignore b/.gitignore index ee84753..280ee13 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # ---------- Build artifacts ---------- dist/ src-tauri/target/ +src-tauri/gen/schemas/ # ---------- Markdown docs policy ---------- *.md @@ -32,21 +33,11 @@ Roaming/ *.log *.err.log *.out.log -cargo-check*.log -cargo-check*.err.log -cargo-check*.out.log -tauri-check*.log -tauri-check*.err.log -tauri-check*.out.log -tauri-dev*.log -tauri-dev*.err.log -tauri-dev*.out.log -tauri-open*.log -tauri-open*.err.log -tauri-open*.out.log -tauri-dev-run*.log -tauri-dev-run*.err.log -tauri-dev-run*.out.log +cargo-check.* +tauri-check.* +tauri-dev.* +tauri-open.* +tauri-dev-run.* test-output/ # ---------- Temporary screenshots ---------- @@ -75,3 +66,6 @@ document.title' Thumbs.db Desktop.ini .DS_Store + +# ---------- AI agent ---------- +.reasonix/ diff --git a/README.md b/README.md index 051b278..8c64113 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,32 @@ -# DeepSeek Monitor Windows +# DeepSeek / MiMo Monitor Windows -DeepSeek Monitor Windows 是一个面向 Windows 的 DeepSeek API 用量监控桌面应用,用于查看账户余额、当月消费、模型 Token 用量和最近用量趋势。 +DeepSeek / MiMo Monitor Windows 是一个面向 Windows 的 DeepSeek & MiMo API 用量监控桌面应用,用于查看账户余额、当月消费、模型 Token 用量和最近用量趋势。 本项目基于 [JayHome137/deepseek-monitor](https://github.com/JayHome137/DeepSeekMonitor) 的开源项目思路做 Windows 系统适配,**感谢原作者 JayHome137 的开源工作**。原项目是 Python Web Dashboard,用于追踪 DeepSeek 平台多类公开变化,原项目当前仅支持mac版本。本项目开发目标是 Windows 桌面端监控工具,技术栈和使用方式已经按 Windows 平台重构实现。 -郑重声明:本项目不是 DeepSeek 官方产品。 +郑重声明:本项目不是 DeepSeek 官方产品,也不是 MiMo 官方产品。 ## About -DeepSeek Monitor Windows: Windows desktop adaptation of felikschu/deepseek-monitor, built with Tauri, React and Rust for DeepSeek balance and usage monitoring. - -## 页面截图 - -### 旧版本 UI - -![DeepSeek Monitor Windows 页面总览](screenshots/overview.png) - -### 新版本 UI - -![DeepSeek Monitor Windows 新版本 UI](screenshots/new-ui.png) - -## 联系方式 - -### 微信交流 - -扫码添加微信(备注 GitHub): - -微信二维码 - -微信号:`pixel-cafetime` - -微信公众号:像素与咖啡时光 - -抖音号:像素与咖啡时光 +DeepSeek / MiMo Monitor Windows: Windows desktop adaptation of felikschu/deepseek-monitor, built with Tauri, React and Rust for DeepSeek and MiMo balance and usage monitoring. ## 当前能力 - 查询 DeepSeek API 账户余额,使用 DeepSeek 官方余额接口。 - 查询 DeepSeek 平台用量数据,包括当月消费、模型 Token 总量、请求数、缓存命中、缓存未命中和输出 Token。 - 支持 V4 Flash 与 V4 Pro 两类模型用量展示。 -- 支持最近 7 天消费趋势图和模型详情页。 +- 支持最近 7 天消费趋势图,可按周翻页浏览历史数据。 +- 支持模型详情页,按日 Token 消耗柱状图,同样支持周翻页。 - 支持 Windows 托盘入口,主窗口默认不进入任务栏。 - 支持 API Key 保存、清除和余额验证。 - 支持用量 Token 自动同步和手动粘贴兜底。 +- **MiMo 平台完整支持**:通过顶部按钮在 DeepSeek 与 MiMo 之间切换。 + - MiMo 余额查询:通过 WebView2 + JavaScript Fetch 方式获取,支持 HttpOnly Cookie 登录态透传。 + - MiMo 用量明细:按模型(V2.5 / V2.5 Pro)和日期分解的用量数据,包括 Token 总量、缓存命中/未命中、输出 Token。 + - MiMo 每日趋势图:按日期聚合的用量数据,支持缓存命中明细展示。 + - MiMo 静默查询:WebView 默认隐藏,仅在需要登录时弹出窗口。 + - MiMo 401 自动跳转登录:检测到未登录时自动显示登录窗口。 +- 液态玻璃质感 UI:基于 `backdrop-filter: blur()` 实现动态高斯模糊,叠加半透明渐变层模拟 Vibrance 效果,边缘内高光+半透明描边模拟玻璃厚度与折射,支持深色/浅色主题。 - UI 复用原 macOS 版本的视觉方向,并按 Windows Tauri 窗口做适配。 ## 与原项目的关系 @@ -51,7 +35,7 @@ DeepSeek Monitor Windows: Windows desktop adaptation of felikschu/deepseek-monit | --- | --- | --- | | 目标平台 | macOS / Web Dashboard | Windows 桌面端 | | 核心技术 | Python, Web Server, HTML Dashboard | Tauri 2, React 18, TypeScript, Rust | -| 主要用途 | 追踪 DeepSeek 网页端、Feature Flags、API 端点、法律文档、GitHub 等公开变化 | 查看 DeepSeek API 余额、消费、Token 用量和趋势 | +| 主要用途 | 追踪 DeepSeek 网页端、Feature Flags、API 端点、法律文档、GitHub 等公开变化 | 查看 DeepSeek/MiMo API 余额、消费、Token 用量和趋势 | | 启动方式 | Python 服务 + 浏览器访问 | Windows 桌面应用 | | 本项目是否复用原事件追踪内容 | 不复用 | 不写入 README,不作为本项目能力声明 | @@ -65,8 +49,6 @@ DeepSeek Monitor Windows: Windows desktop adaptation of felikschu/deepseek-monit ## 安装与开发 -Windows 源码开发需要安装 Visual Studio Build Tools 2022,并勾选 `Desktop development with C++`。项目脚本会自动探测本机 VS Build Tools 安装位置,无需手动配置固定路径。 - ```powershell git clone cd DeepSeekMonitorWindows @@ -88,8 +70,6 @@ npm run build Tauri 打包目标当前配置为 NSIS 安装包,产物位于 `src-tauri/target/release/bundle/nsis/`。 -如果出现 `Visual Studio Build Tools not found`,请安装 Visual Studio Build Tools 2022,并确认已勾选 `Desktop development with C++` 组件。 - ## 使用方式 打开应用后进入设置页,先配置 DeepSeek API Key。API Key 用于查询账户余额,来自 DeepSeek 开放平台的 API Keys 页面。 @@ -111,6 +91,14 @@ Tauri 打包目标当前配置为 NSIS 安装包,产物位于 `src-tauri/targe **Token 可能过期。用量查询失败时,重新执行网页登录同步或手动粘贴即可。** +### MiMo 平台使用说明 + +主面板顶部可切换至 MiMo 平台。首次切换时会自动弹出小米账号登录窗口,登录成功后即可查看账户余额和用量数据。 + +MiMo 平台通过 WebView2 代理机制获取数据,利用 HttpOnly Cookie 实现登录态透传。用量明细通过 `api-platform_ph` 动态参数调用 detail API 获取,支持按模型(V2.5 / V2.5 Pro)和日期分解。 + +WebView 默认隐藏运行,仅在需要登录时弹出窗口。登录完成后窗口自动隐藏。 + ## 数据存储 应用配置默认存储在: @@ -134,10 +122,24 @@ WebView2 登录缓存通常位于: ```text DeepSeekMonitorWindows/ ├── src/ # React + TypeScript 前端 -│ ├── main.tsx # 主界面、设置页、详情页和 Tauri 调用 -│ └── styles.css # Windows 桌面 UI 样式 +│ ├── main.tsx # App 入口、全局状态、路由 +│ ├── types.ts # TypeScript 类型定义 +│ ├── utils.ts # 工具函数(格式化、日期) +│ ├── i18n.ts # 中英双语国际化 +│ ├── styles.css # Windows 桌面 UI 样式 +│ └── components/ +│ ├── DashboardPanel.tsx # 主面板(余额、用量、图表) +│ ├── SettingsPanel.tsx # 设置面板(手风琴分类) +│ └── ModelDetailPanel.tsx # 模型详情页 ├── src-tauri/ # Tauri + Rust 后端 -│ ├── src/lib.rs # API 调用、配置存储、托盘、网页登录同步 +│ ├── src/ +│ │ ├── lib.rs # Tauri commands、窗口管理、回调服务器 +│ │ └── modules/ +│ │ ├── types.rs # 共享数据结构 +│ │ ├── config.rs # DPAPI 加密配置、读写 +│ │ ├── deepseek.rs # DeepSeek API 调用 +│ │ ├── mimo.rs # MiMo API 调用(WebView 代理) +│ │ └── tray.rs # 系统托盘与窗口定位 │ ├── tauri.conf.json # Tauri 窗口、打包和安全配置 │ ├── Cargo.toml # Rust 依赖与包信息 │ └── capabilities/ # Tauri 权限配置 @@ -192,6 +194,170 @@ Rust 后端依赖: 完整发布记录见 GitHub Releases。 +### v2.5.3 + +- **稳定性修复**:DeepSeek 用量查询恢复为组件内 `invoke` 调用,修复 v2.5.0 中提取公用函数导致的生产环境数据加载失败。 +- **安全加固**:回调服务器启动失败不再 panic,改为优雅降级;CSP 白名单新增 `open.er-api.com`,修复汇率 API 在生产环境被拦截。 +- **代码清理**:去除 main.tsx 冗余 import 和 `MimoBalanceData` 重复导入。 +- **文档更新**:README 中 i18n 相关描述同步更新为 zh/en 双语。 + +### v2.5.2 + +- **模型详情页增强**:标题下方新增该模型的平均命中率和平均单价;每日柱状图悬浮 tooltip 新增缓存命中率和平均单价。 +- **主页面 tooltip 文案**:「单价」统一改为「平均单价」,与详情页一致。 + +### v2.5.1 + +- **修复更新日志无法加载**:生产环境 CSP `connect-src` 白名单缺少 `https://api.github.com`,已添加。 + +### v2.5.0 + +- **设置页UI增强**:字体透明度和玻璃透明度独立调优,设置页视觉效果更清晰。 +- **更新日志**:设置页新增"查看更新日志"功能,通过 GitHub API 分页拉取全部版本记录,marked 渲染 Markdown,默认折叠按版本展开。 +- **MiMo 颜色**:设置页 MiMo 区域标识色从绿色改为小米品牌橙色 `#FF6900`。 +- **手风琴动画优化**:展开/折叠过渡从 0.3s 微调到 0.35s,更流畅自然。 +- **下载进度条修复**:消除点击下载时进度条先跳到 30% 再回 0% 的视觉跳动。 +- **图表增强**:缓存命中明细右上角新增效率指标(MT/¥ 或 ¥/MT);悬浮 tooltip 增加每日命中率和单价显示。 +- **Bug 修复**:自定义刷新间隔不再被静默重置为 60 秒;MimoDetailCache 空缓存状态修正;Mutex 双检锁优化、去中毒绕过;独立 poll server 改为复用主 CallbackServer,消除线程泄漏;`start_usage_title_watcher` 超时从 30 分钟缩短到 15 分钟。 +- **代码质量**:i18n 精简为 zh/en 双语;消除重复动态 import;`modelIcon` 支持 MiMo 模型;`:not()` 选择器改为白名单;`.detail-bar-column` 三重定义合并。 +- **错误处理**:关键 Tauri 命令失败时添加 `console.warn`;`url.parse().unwrap()` 替换为 `map_err`。 + +### v2.4.5 + +- **MiMo 切换稳定性**:修复从 DeepSeek 切换到 MiMo 时窗口消失的偶发崩溃(去除重复 loadBalance/loadUsage 调用、ensure_mimo_webview_sync 添加 Mutex 防竞态、阻塞 sleep 改为异步)。 +- **设置页标题**:设置页左上角改为静态文本 `DeepSeek / MiMo Monitor`,主页面保留原有的点击切换功能。 + +### v2.4.4 + +- **检查更新修复**:检查更新失败时显示具体错误信息,不再误导性地显示"已是最新版本"。 +- **latest.json 文件名修复**:上传到 GitHub Release 的文件名从 `latest-vX.Y.Z.json` 修正为 `latest.json`,确保 updater 端点 `/releases/latest/download/latest.json` 能正确访问。 + +### v2.4.3 + +- **安全加固**:f64→u64 溢出防护、unsafe 块 SAFETY 注释、敏感数据日志降级为 debug!、解析失败添加 warn! 日志。 +- **代码质量**:UA 字符串提取为 `USER_AGENT` 常量、魔术数字提取为命名常量、`fetchWithCache` 工具函数消除重复缓存逻辑、`mimoDefaultModels` 提取为模块级常量。 +- **输入验证**:`lowBalanceThreshold` 服务端添加 `is_finite()` + `>= 0` 校验。 +- **编译缓存清理**:删除 8.5GB target 目录,仅保留 release 产物。 + +### v2.4.2 + +- **设置 UI 统一**:所有分段按钮改为内联样式按钮组或下拉框,移除死代码 `.segmented` CSS。 +- **下拉框自定义输入**:刷新间隔和通知冷却支持"自定义"选项,选择后出现输入框。 +- **Bug 修复**:`export_config_json`/`import_config_json` 未注册到 invoke_handler、CSS `var(--text)` 未定义、默认汇率 7.25→0.137、通知冷却预设增加 30 分钟、自定义状态从配置初始化。 +- **死代码清理**:移除 main.tsx/SettingsPanel 中未使用的 imports 和变量。 + +### v2.4.1 + +- **汇率修复**:修正汇率计算方向(`n * rate` 而非 `n / rate`),更新缓存 key 丢弃旧反向值,修正 sanity check。 +- **手风琴动画优化**:从 `max-height` 改为 CSS Grid `grid-template-rows`,过渡更流畅。 + +### v2.4.0 + +- **设置页面重构**:从平铺式改为手风琴展开式分类导航(账户、通用、显示、通知、关于)。 +- **货币单位设置**:支持人民币(¥) / 美元($) 切换,实时汇率转换(`open.er-api.com`,24h 缓存)。 +- **效率指标**:统一使用 MT 单位(MT/¥ 或 ¥/MT,美元时自动切换为 MT/$ 或 $/MT)。 +- **主题设置**:浅色 / 深色 / 跟随系统,实时切换,支持系统偏好监听。 +- **新 Rust 命令**:`save_currency`、`save_efficiency_unit`、`save_theme`。 +- **货币 prop 贯穿全链路**:main.tsx → DashboardPanel → BalanceCard/UsageRow,ModelDetailPanel。 + +### v2.3.4 + +- **安全加固**:修复 `method` 参数 JS 注入、poll server CORS `*` → `null`、`mimo_ph` DPAPI 加密。 +- **代码质量**:删除孤立 `config_tests.rs`、修复 `Cargo.toml` 版本号、修复未使用变量警告。 + +### v2.3.3 + +- **安全加固**:`loginUrl` 和 `ph` JS 注入修复(`serde_json::to_string`)、`login-sync` WebView `on_navigation` 守卫、DPAPI `encrypt_credential` 返回 `Result`。 + +### v2.3.0 + +- **i18n 国际化**:支持 17 种语言。v2.5.0 起精简为 zh/en 双语,其余语言已移除。 +- **Windows 余额通知**:余额低于阈值时弹出 Windows toast 通知(默认关闭,可设置阈值)。 +- **Tauri 自动更新**:集成 `tauri-plugin-updater`,支持签名验证的自动更新。 +- **窗口状态记忆**:保存窗口大小和位置,下次启动自动恢复。 +- **Rust 单元测试**:9 个测试覆盖配置模块。 +- **代码拆分**:SettingsPanel 和 ModelDetailPanel 提取为独立组件。 +- **MiMo 查询稳定性**:修复 `api-platform_ph` 缓存过期不被清除的问题,401 重试限制(最多 2 次),`initialization_script` 替代 `on_page_load`。 + +### v2.2.1 + +- **MiMo 查询性能优化**:`initialization_script` 替代 `on_page_load`,hook 在 SPA 脚本运行前注入,detail API 请求被即时拦截,查询速度大幅提升。 +- **默认主题改为浅色**:首次安装启动即为浅色蓝色主题,不再显示深色棕色主题。 +- **窗口首次定位**:应用首次启动时自动定位到屏幕右下角,不再出现在左上角。 +- **免责声明更新**:补充 MiMo 平台相关风险说明。 + +### v2.2.0 + +- **Rust 后端模块化重构**:`lib.rs`(1894 行)拆分为 6 个模块:`types.rs`、`config.rs`、`deepseek.rs`、`mimo.rs`、`tray.rs`,遵循高内聚低耦合原则。 +- **DPAPI 凭据加密**:API Key、Usage Token 使用 Windows DPAPI 加密存储(`enc1:` 前缀),向后兼容明文。 +- **持久化回调服务器**:tiny_http 回调服务器启动时创建一次,所有 API 调用复用同一端口,消除每次调用的线程创建开销。 +- **窗口可拉伸 + 预设**:支持拖拽窗口边缘自由调整大小(min 340×500,max 700×1200),设置中提供 4 个预设尺寸(紧凑/标准/宽屏/大屏),锚定右下角。 +- **安全加固**:CSP 强制执行、`withGlobalTauri: false`、注入脚本域名白名单、MiMo 窗口导航白名单、输入长度验证。 +- **离线数据缓存**:余额和用量数据自动缓存到 localStorage,API 失败时自动加载缓存数据。 +- **前端模块化**:拆分 `types.ts`、`utils.ts`、`DashboardPanel.tsx`;配置 Vitest 单元测试框架(16 个测试)。 +- **UI 改进**:默认主题改为浅色、刷新按钮添加 hover/active 反馈、紧凑预设高度优化、MiMo 平台设置界面适配。 +- **窗口大小修复**:CSS 面板改用 100% 填充窗口,`resize_window` 使用纯物理像素避免 DPI 问题。 +- **Detail API 修复**:从 GET 改为 POST(MiMo API 要求)。 +- **MiMo 查询稳定性**:`initialization_script` 替代 `on_page_load`、ph 缓存过期自动清除、401 重试限制、并发防护。 + +### v2.1.1 + +- **MiMo 查询稳定性修复**:修复 `api-platform_ph` 缓存过期后不被清除导致 detail API 持续 401 的问题。fast-path 失败时自动清除旧 ph,让 `initialization_script` hook 重新捕获。 +- **401 重试限制**:detail 提取遇到 401 时最多显示 2 次登录窗口,之后静默降级到概览数据,避免反复弹窗。 +- **Provider 切换修复**:修复从 MiMo 切换到 DeepSeek(或反向)时卡在"查询中"的问题。`setProvider` 现在直接触发数据加载。 +- **轮询超时优化**:detail 提取轮询从 120 秒缩短到 30 秒,减少无效等待。 + +### v2.1.0 + +- **MiMo 查询速度优化**:使用 `initialization_script` 替代 `on_page_load`,hook 在 SPA 脚本运行前注入,detail API 请求被即时拦截,查询速度大幅提升。 +- **DPAPI 凭据加密**:API Key、Usage Token 等敏感凭据使用 Windows DPAPI 加密存储(`enc1:` 前缀),向后兼容明文。 +- **持久化回调服务器**:tiny_http 回调服务器启动时创建一次,所有 API 调用复用同一端口,消除每次调用的线程创建开销。 +- **窗口可拉伸**:支持拖拽窗口边缘自由调整大小(min 340×500,max 700×1200),设置中提供 4 个预设尺寸。 +- **窗口大小锚定**:调整窗口大小时保持右下角位置固定。 +- **离线数据缓存**:余额和用量数据自动缓存到 localStorage,API 失败时自动加载缓存数据。 +- **安全加固**:CSP 强制执行、`withGlobalTauri: false`、注入脚本域名白名单、MiMo 窗口导航白名单、输入长度验证。 +- **代码架构改进**:拆分 types.ts、utils.ts、DashboardPanel.tsx 模块;配置 Vitest 单元测试框架(16 个测试)。 +- **MiMo 查询稳定性**:修复 CallbackServer 状态注册导致的 panic、detail API 401 循环弹窗等问题。 + +### v2.0.0 + +- **MiMo 平台完整支持**:MiMo 从 Beta 升级为正式支持,用量明细、每日趋势图、缓存命中明细全部打通。 +- **MiMo 静默查询**:WebView 默认隐藏运行,仅在需要登录时弹出窗口,不再强制保持窗口打开。 +- **MiMo 用量明细**:通过 `on_page_load` hook 自动拦截 SPA 的 detail API 请求,提取 `api-platform_ph` 参数并缓存。支持按模型(V2.5 / V2.5 Pro)和日期分解的完整用量数据。 +- **MiMo 缓存命中明细**:图表展示每日缓存命中/未命中/输出 Token 分布,与 DeepSeek 统一显示规则。 +- **7 天窗口 + 周导航**:缓存命中明细和按日 Token 消耗图表默认显示最近 7 天,支持左右翻页浏览历史周数据,无数据天以 0 填充。 +- **悬停区域优化**:柱状图整列可悬停,解决矮柱子难以触发提示的问题。 +- **设置界面适配**:API Key、开机自启、自动刷新等设置项根据当前平台动态显示文案。MiMo 模式下隐藏 DeepSeek 专属的 API Key 和用量 Token 配置。 +- **并发防护**:detail 提取添加 `in_progress` 标记,防止多个提取同时运行导致的 cascade。 +- **Detail API 修复**:从 GET 改为 POST 方法(MiMo API 要求)。 +- **版本号升级**:v1.2.0 → v2.0.0。 + +### v1.2.0 + +> **开发中版本,MiMo 用量明细功能尚未完成。** + +- **MiMo 平台支持(Beta)**:新增 MiMo 平台切换能力,通过顶部按钮在 DeepSeek 与 MiMo 之间切换。 +- **MiMo 余额查询**:通过 WebView2 内嵌 HTTP 服务器 + JavaScript Fetch 方式获取 MiMo API 数据,支持 HttpOnly Cookie 登录态透传。 +- **MiMo 用量明细(开发中)**:后端已实现 `/api/v1/usage/detail/list` 接口调用,支持按模型(V2.5 / V2.5 Pro)和日期分解的用量数据。自动提取 `api-platform_ph` 参数的逻辑尚不稳定,首次使用需手动触发页面加载。 +- **MiMo 模型展示**:主面板始终显示 V2.5 和 V2.5 Pro 两行模型占位,无论是否有数据。 +- **MiMo 每日趋势图**:后端已实现按日期聚合的用量数据,前端趋势图已对接,待 `api-platform_ph` 提取打通后可正常显示。 +- **MiMo 401 自动跳转登录**:检测到 MiMo API 返回 401 时,自动跳转小米账号登录页面。 +- **MiMo 配置缓存**:`api-platform_ph` 参数缓存至本地配置文件,避免重复提取。 +- **Provider 持久化**:当前选择的平台(DeepSeek/MiMo)存入配置文件,重启自动恢复。 +- **串行化 WebView 访问**:解决并发导航竞争导致的接口请求失败。 +- **Rust 依赖新增**:`tiny_http` 0.12 用于本地 HTTP 回调;`tokio::sync::Mutex` 用于 WebView 访问串行化。 +- **已知问题**:`api-platform_ph` 动态参数的自动提取逻辑不稳定,可能导致用量明细无法显示;401 登录跳转在某些场景下不生效。 +- **液态玻璃 UI 增强**:Provider 切换按钮适配两种平台名称显示。 + +### v1.1.1 + +- **液态玻璃 UI**:全面升级为 `backdrop-filter: blur(42px)` 动态高斯模糊质感,叠加半透明渐变层实现 Vibrance 色彩浸透效果,边缘内高光+多层阴影模拟玻璃厚度与折射。支持深色/浅色主题统一变量体系。 +- **界面尺寸调整**:主面板加宽 30%(356px→463px)、加高 10%(600px→660px),设置页同步缩放,提供更充裕的展示空间。 +- **Token 显示修复**:解决用量行 Token 文本因空间不足被截断的问题,左侧展示区增加约 5 字符宽度。 +- **价格单位变更**:右侧 `T/¥` 改为 `¥/MT`(元/百万 Token),保留三位小数,精度更高且符合行业惯例。 +- **缓存命中精度**:模型用量行与趋势图的缓存命中率统一精确到小数点后三位。 +- **窗口尺寸同步**:Tauri 窗口 `tauri.conf.json` 同步调整至 463×660。 + ### v1.1.0 - 支持缓存命中、缓存未命中与输出 Token 的明细显示。 @@ -215,6 +381,6 @@ Rust 后端依赖: ## 免责声明 -本项目仅用于学习和研究目的。请遵守 DeepSeek 的使用条款,合理使用相关接口,避免频繁请求。 +本项目仅用于学习和研究目的。请遵守 DeepSeek 和 MiMo 的使用条款,合理使用相关接口,避免频繁请求。 -DeepSeek 平台页面结构、登录状态、WebView2 缓存和内部用量接口都可能变化,本项目不保证长期可用。**API Key 和用量 Token 属于敏感凭据,使用者需自行承担本机存储、账号安全、网络请求和数据展示带来的风险。** +DeepSeek 和 MiMo 平台页面结构、登录状态、WebView2 缓存和内部用量接口都可能变化,本项目不保证长期可用。**API Key、用量 Token 和小米账号凭据属于敏感凭据,使用者需自行承担本机存储、账号安全、网络请求和数据展示带来的风险。** diff --git a/package-lock.json b/package-lock.json index cb47755..f03a500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,94 @@ { "name": "deepseek-monitor-windows", - "version": "1.1.0", + "version": "2.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deepseek-monitor-windows", - "version": "1.1.0", + "version": "2.4.5", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "lucide-react": "^0.468.0", + "marked": "^18.0.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@tauri-apps/cli": "^2.11.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", + "jsdom": "^29.1.1", "typescript": "5.6.3", - "vite": "5.4.11" + "vite": "5.4.11", + "vitest": "^4.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -257,6 +323,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", @@ -305,6 +381,193 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -696,6 +959,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -746,24 +1027,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -772,12 +1068,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -786,12 +1085,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -800,77 +1102,367 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" - ] + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ - "arm" + "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.61.1", @@ -1096,6 +1688,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.11.0", "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.0.tgz", @@ -1323,52 +1922,192 @@ "node": ">= 10" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@tauri-apps/api": "^2.11.0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-process": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", + "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@babel/types": "^7.0.0" + "@tauri-apps/api": "^2.8.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.10.1", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", + "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "license": "MIT OR Apache-2.0", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@tauri-apps/api": "^2.10.1" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/types": "^7.28.2" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@types/estree": { + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", @@ -1423,6 +2162,137 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.33", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", @@ -1436,6 +2306,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", @@ -1491,6 +2371,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1498,6 +2388,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -1505,6 +2416,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", @@ -1523,6 +2448,41 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.368", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", @@ -1530,6 +2490,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", @@ -1579,6 +2559,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -1604,40 +2622,394 @@ "node": ">=6.9.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/loose-envify": { - "version": "1.4.0", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", @@ -1667,6 +3039,56 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -1703,6 +3125,40 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1710,6 +3166,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", @@ -1739,6 +3208,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", @@ -1764,6 +3259,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", @@ -1774,6 +3277,64 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.61.1.tgz", @@ -1819,6 +3380,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", @@ -1838,6 +3412,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1848,6 +3429,138 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", @@ -1862,6 +3575,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1953,6 +3676,283 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f4b7707..d964960 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deepseek-monitor-windows", - "version": "1.1.0", + "version": "2.5.3", "private": true, "license": "MIT", "type": "module", @@ -9,20 +9,30 @@ "build": "tsc && vite build", "preview": "vite preview --host 127.0.0.1", "tauri:check": "powershell -ExecutionPolicy Bypass -File scripts/tauri-check.ps1", - "tauri:dev": "powershell -ExecutionPolicy Bypass -File scripts/tauri-dev.ps1" + "tauri:dev": "powershell -ExecutionPolicy Bypass -File scripts/tauri-dev.ps1", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tauri-apps/api": "^2.11.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "lucide-react": "^0.468.0", + "marked": "^18.0.5", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@tauri-apps/cli": "^2.11.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", + "jsdom": "^29.1.1", "typescript": "5.6.3", - "vite": "5.4.11" + "vite": "5.4.11", + "vitest": "^4.1.9" } } diff --git a/scripts/env.ps1 b/scripts/env.ps1 index 9c9e72e..2a86c17 100644 --- a/scripts/env.ps1 +++ b/scripts/env.ps1 @@ -1,38 +1,5 @@ $ErrorActionPreference = "Stop" -function Resolve-VsDevCmd { - $paths = @() - - $vswhereRoot = ${env:ProgramFiles(x86)} - if ($vswhereRoot) { - $vswhere = Join-Path $vswhereRoot "Microsoft Visual Studio\Installer\vswhere.exe" - if (Test-Path -LiteralPath $vswhere) { - $installationPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - if ($installationPath) { - $paths += (Join-Path $installationPath "Common7\Tools\VsDevCmd.bat") - } - } - } - - foreach ($root in @(${env:ProgramFiles}, ${env:ProgramFiles(x86)})) { - if (-not $root) { - continue - } - - foreach ($edition in @("BuildTools", "Community", "Professional", "Enterprise")) { - $paths += (Join-Path $root "Microsoft Visual Studio\2022\$edition\Common7\Tools\VsDevCmd.bat") - } - } - - foreach ($path in $paths) { - if (Test-Path -LiteralPath $path) { - return $path - } - } - - throw "Visual Studio Build Tools not found. Install Visual Studio Build Tools 2022 with Desktop development with C++." -} - $project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path $root = Split-Path -Parent $project diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 243543b..0fd22d8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,16 +77,32 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app" -version = "1.1.0" +version = "2.5.3" dependencies = [ "log", + "notify-rust", "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", + "tauri-plugin-process", "tauri-plugin-single-instance", + "tauri-plugin-updater", + "tiny_http", + "tokio", + "winreg", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", ] [[package]] @@ -95,6 +111,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -589,6 +611,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "combine" version = "4.6.7" @@ -805,6 +833,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1117,6 +1156,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1701,6 +1750,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -2010,6 +2065,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2177,6 +2262,20 @@ dependencies = [ "value-bag", ] +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2209,6 +2308,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2298,6 +2403,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2467,6 +2586,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.12.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2482,6 +2602,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2604,6 +2736,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2748,7 +2894,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -2915,6 +3061,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -3119,15 +3274,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3139,6 +3299,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3234,12 +3418,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3249,6 +3446,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3608,6 +3832,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3837,7 +4071,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3876,6 +4110,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3899,7 +4144,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4011,6 +4256,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" @@ -4033,6 +4320,16 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -4048,6 +4345,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4058,7 +4388,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4081,7 +4411,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4148,6 +4478,18 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4244,6 +4586,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4938,6 +5292,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5571,7 +5934,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5627,6 +5990,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5791,6 +6164,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 467eac2..5f7e45c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.1.0" +version = "2.5.3" description = "DeepSeek Monitor Windows desktop app" authors = ["you"] license = "MIT" @@ -24,4 +24,11 @@ log = "0.4" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-log = "2" tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +notify-rust = "4" +tiny_http = "0.12" reqwest = { version = "0.12", features = ["json"] } +tokio = { version = "1", features = ["sync"] } +winreg = "0.55" +tauri-plugin-dialog = "2.7.1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 19bc3e0..00db968 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,9 @@ "core:window:allow-set-focus", "core:event:allow-emit", "core:event:allow-listen", - "core:webview:allow-internal-toggle-devtools" + "core:webview:allow-internal-toggle-devtools", + "updater:default", + "process:default", + "dialog:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fcee6d3..f0861c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,929 +1,523 @@ -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - use serde::{Deserialize, Serialize}; - use std::{ - fs, - io::Read, - os::windows::fs::OpenOptionsExt, - path::{Path, PathBuf}, - process::Command, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - thread, - time::Duration, - }; - use tauri::{ - menu::{Menu, MenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - webview::PageLoadEvent, - Emitter, Manager, PhysicalPosition, Position, WebviewWindow, - }; +//! DeepSeek / MiMo Monitor Windows — Tauri 入口 +//! +//! 职责:模块声明、Tauri 命令注册、Builder 配置。 +//! 具体业务逻辑分散在 modules/ 子模块中。 + +mod modules; +use modules::{ + config, deepseek, mimo, tray, + types::{ + AppConfig, BalanceResult, CallbackServerPort, MimoBalanceResult, MimoDetailCache, + MimoUsageResult, UsageResult, + }, +}; + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; +use tokio::sync::oneshot; + +use tauri::{Manager, WebviewWindow}; + +// ─── Callback Server(持久化 tiny_http)─────────────────── + +struct CallbackServer { + port: u16, +} - #[derive(Debug, Default, Deserialize, Serialize)] - struct StoredConfig { - api_key: Option, - #[serde(default)] - usage_token: Option, - refresh_interval_seconds: u64, - #[serde(default)] - auto_refresh_enabled: bool, - autostart: bool, +impl CallbackServer { + fn start(shared_map: Arc>>>) -> std::io::Result { + use tiny_http::{Header, Method, Response, Server}; + let server = Server::http("127.0.0.1:0").map_err(|e| std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, format!("无法启动回调服务器:{e}")))?; + let port = server.server_addr().to_ip().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "回调服务器地址无效"))?.port(); + std::thread::spawn(move || { + while let Ok(Some(mut request)) = + server.recv_timeout(std::time::Duration::from_secs(3600)) + { + if *request.method() == Method::Options { + let response = Response::from_string(String::new()) + .with_header( + Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"null"[..]) + .unwrap(), + ) + .with_header( + Header::from_bytes( + &b"Access-Control-Allow-Methods"[..], + &b"POST, OPTIONS"[..], + ) + .unwrap(), + ) + .with_header( + Header::from_bytes( + &b"Access-Control-Allow-Headers"[..], + &b"Content-Type"[..], + ) + .unwrap(), + ); + let _ = request.respond(response); + } else { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + if let Ok(parsed) = serde_json::from_str::(&body) { + if let (Some(rid), Some(data)) = ( + parsed.get("reqId").and_then(|v| v.as_str()), + parsed.get("data").and_then(|v| v.as_str()), + ) { + let mut map = shared_map.lock().unwrap(); + if let Some(tx) = map.remove(rid) { + let _ = tx.send(data.to_string()); + } + } + } + let response = Response::from_string("OK").with_header( + Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"null"[..]).unwrap(), + ); + let _ = request.respond(response); + } + } + }); + Ok(CallbackServer { port }) } +} - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct AppConfig { - api_key_configured: bool, - api_key_preview: Option, - usage_token_configured: bool, - refresh_interval_seconds: u64, - auto_refresh_enabled: bool, - autostart: bool, - config_path: String, - } +// ─── Tauri 命令 ────────────────────────────────────────── - fn config_path() -> Result { - let appdata = std::env::var_os("APPDATA").ok_or("APPDATA is not available")?; - Ok(PathBuf::from(appdata) - .join("DeepSeekMonitorWindows") - .join("config.json")) - } +#[tauri::command] +fn hide_main_window(window: WebviewWindow) -> Result<(), String> { + window.hide().map_err(|error| error.to_string()) +} - fn read_stored_config() -> Result { - let path = config_path()?; - if !path.exists() { - return Ok(StoredConfig { - refresh_interval_seconds: 60, - ..StoredConfig::default() - }); - } +#[tauri::command] +fn resize_window(window: WebviewWindow, width: f64, height: f64) -> Result<(), String> { + use tauri::{PhysicalPosition, LogicalSize}; - let text = fs::read_to_string(&path).map_err(|error| error.to_string())?; - let mut config: StoredConfig = - serde_json::from_str(&text).map_err(|error| error.to_string())?; - config.refresh_interval_seconds = - normalize_refresh_interval_seconds(config.refresh_interval_seconds); - Ok(config) - } + // 当前右下角(物理像素) + let old_pos = window.outer_position().map_err(|e| e.to_string())?; + let old_size = window.outer_size().map_err(|e| e.to_string())?; + let old_right = old_pos.x + old_size.width as i32; + let old_bottom = old_pos.y + old_size.height as i32; - fn normalize_refresh_interval_seconds(value: u64) -> u64 { - match value { - 60 | 300 | 1800 | 3600 => value, - _ => 60, - } - } + // 逻辑像素设置大小 + window.set_size(LogicalSize::new(width, height)).map_err(|e| e.to_string())?; - fn write_stored_config(config: &StoredConfig) -> Result<(), String> { - let path = config_path()?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } + // 读取新物理尺寸,锚定右下角 + let new_size = window.outer_size().map_err(|e| e.to_string())?; + let new_x = (old_right - new_size.width as i32).max(0); + let new_y = (old_bottom - new_size.height as i32).max(0); + window.set_position(PhysicalPosition::new(new_x, new_y)).map_err(|e| e.to_string())?; - let text = serde_json::to_string_pretty(config).map_err(|error| error.to_string())?; - fs::write(path, text).map_err(|error| error.to_string()) - } + // 保存窗口状态 + let _ = save_window_state(&window); + Ok(()) +} - fn api_key_preview(api_key: &str) -> String { - let chars: Vec = api_key.chars().collect(); - if chars.len() <= 12 { - return "已保存".to_string(); - } +/// 保存窗口大小和位置到配置 +fn save_window_state(window: &WebviewWindow) -> Result<(), String> { + let pos = window.outer_position().map_err(|e| e.to_string())?; + let size = window.outer_size().map_err(|e| e.to_string())?; + let scale = window.current_monitor().ok().flatten().map(|m| m.scale_factor()).unwrap_or(1.0); + // 转为逻辑像素保存 + let logical_w = size.width as f64 / scale; + let logical_h = size.height as f64 / scale; + + let mut config = config::read_stored_config()?; + config.window_width = Some(logical_w); + config.window_height = Some(logical_h); + config.window_x = Some(pos.x); + config.window_y = Some(pos.y); + config::write_stored_config(&config) +} - let start: String = chars.iter().take(7).collect(); - let end: String = chars - .iter() - .rev() - .take(4) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{start}...{end}") - } +#[tauri::command] +fn get_app_config() -> Result { + config::to_app_config(config::read_stored_config()?) +} - fn to_app_config(config: StoredConfig) -> Result { - let path = config_path()?; - let api_key_preview = config - .api_key - .as_ref() - .filter(|value| !value.is_empty()) - .map(|value| api_key_preview(value)); - - let usage_token_configured = config - .usage_token - .as_ref() - .map(|value| !value.is_empty()) - .unwrap_or(false); - - Ok(AppConfig { - api_key_configured: api_key_preview.is_some(), - api_key_preview, - usage_token_configured, - refresh_interval_seconds: config.refresh_interval_seconds, - auto_refresh_enabled: config.auto_refresh_enabled, - autostart: config.autostart, - config_path: path.to_string_lossy().to_string(), - }) +#[tauri::command] +fn save_api_key(api_key: String) -> Result { + let value = api_key.trim().to_string(); + if value.is_empty() { + return Err("API Key 不能为空".to_string()); } + let mut config = config::read_stored_config()?; + config.api_key = Some(value); + config::write_stored_config(&config)?; + config::to_app_config(config) +} - fn position_near_tray(window: &WebviewWindow) -> tauri::Result<()> { - let cursor = window.cursor_position()?; - let monitor = window - .monitor_from_point(cursor.x, cursor.y)? - .or(window.current_monitor()?) - .or(window.primary_monitor()?) - .ok_or_else(|| tauri::Error::WindowNotFound)?; - - let work_area = monitor.work_area(); - let scale_factor = monitor.scale_factor(); - let size = window.outer_size()?; - let margin = (12.0 * scale_factor).round() as i32; - let width = size.width as i32; - let height = size.height as i32; - let right = work_area.position.x + work_area.size.width as i32; - let bottom = work_area.position.y + work_area.size.height as i32; - let x = right - width - margin; - let y = bottom - height - margin; - - window.set_position(Position::Physical(PhysicalPosition::new( - x.max(work_area.position.x), - y.max(work_area.position.y), - ))) - } +#[tauri::command] +fn clear_api_key() -> Result { + let mut config = config::read_stored_config()?; + config.api_key = None; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - fn show_main_window(window: &WebviewWindow) { - let _ = position_near_tray(window); - let _ = window.show(); - let _ = window.set_focus(); - } +#[tauri::command] +fn save_refresh_interval(refresh_interval_seconds: u64) -> Result { + let mut config = config::read_stored_config()?; + config.refresh_interval_seconds = refresh_interval_seconds; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn hide_main_window(window: WebviewWindow) -> Result<(), String> { - window.hide().map_err(|error| error.to_string()) - } +#[tauri::command] +fn save_auto_refresh_enabled(auto_refresh_enabled: bool) -> Result { + let mut config = config::read_stored_config()?; + config.auto_refresh_enabled = auto_refresh_enabled; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn get_app_config() -> Result { - to_app_config(read_stored_config()?) - } +#[tauri::command] +fn save_autostart(autostart: bool) -> Result { + config::apply_autostart(autostart)?; + let mut config = config::read_stored_config()?; + config.autostart = autostart; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn save_api_key(api_key: String) -> Result { - let value = api_key.trim().to_string(); - if value.is_empty() { - return Err("API Key 不能为空".to_string()); - } +#[tauri::command] +fn save_low_balance_notify(enabled: bool) -> Result { + let mut config = config::read_stored_config()?; + config.low_balance_notify = enabled; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - let mut config = read_stored_config()?; - config.api_key = Some(value); - write_stored_config(&config)?; - to_app_config(config) +#[tauri::command] +fn save_low_balance_threshold(threshold: f64) -> Result { + if !threshold.is_finite() || threshold < 0.0 { + return Err("阈值必须为非负数".to_string()); } + let mut config = config::read_stored_config()?; + config.low_balance_threshold = threshold; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn clear_api_key() -> Result { - let mut config = read_stored_config()?; - config.api_key = None; - write_stored_config(&config)?; - to_app_config(config) +#[tauri::command] +fn save_theme(theme: String) -> Result { + if !["light", "dark", "system"].contains(&theme.as_str()) { + return Err("无效主题".to_string()); } + let mut config = config::read_stored_config()?; + config.theme = theme; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn save_refresh_interval(refresh_interval_seconds: u64) -> Result { - let mut config = read_stored_config()?; - config.refresh_interval_seconds = - normalize_refresh_interval_seconds(refresh_interval_seconds); - write_stored_config(&config)?; - to_app_config(config) +#[tauri::command] +fn save_currency(currency: String) -> Result { + if !["cny", "usd"].contains(¤cy.as_str()) { + return Err("无效货币".to_string()); } + let mut config = config::read_stored_config()?; + config.currency = currency; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn save_auto_refresh_enabled(auto_refresh_enabled: bool) -> Result { - let mut config = read_stored_config()?; - config.auto_refresh_enabled = auto_refresh_enabled; - write_stored_config(&config)?; - to_app_config(config) +#[tauri::command] +fn save_efficiency_unit(unit: String) -> Result { + if !["token_per_currency", "currency_per_token"].contains(&unit.as_str()) { + return Err("无效效率单位".to_string()); } + let mut config = config::read_stored_config()?; + config.efficiency_unit = unit; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - fn apply_autostart(enabled: bool) -> Result<(), String> { - let run_key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; - let value_name = "DeepSeekMonitorWindows"; - - if enabled { - let exe = std::env::current_exe().map_err(|error| error.to_string())?; - let exe_arg = exe.to_string_lossy().to_string(); - let status = Command::new("reg") - .args(["add", run_key, "/v", value_name, "/t", "REG_SZ", "/d"]) - .arg(exe_arg) - .args(["/f"]) - .status() - .map_err(|error| format!("写入开机自启失败:{error}"))?; - if !status.success() { - return Err("写入开机自启失败".to_string()); - } - return Ok(()); - } - - let status = Command::new("reg") - .args(["delete", run_key, "/v", value_name, "/f"]) - .status() - .map_err(|error| format!("关闭开机自启失败:{error}"))?; - if !status.success() { - return Ok(()); - } - Ok(()) +#[tauri::command] +fn save_default_provider(provider: String) -> Result { + if !["deepseek", "mimo"].contains(&provider.as_str()) { + return Err("无效平台".to_string()); } + let mut config = config::read_stored_config()?; + config.default_provider = provider; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[tauri::command] - fn save_autostart(autostart: bool) -> Result { - apply_autostart(autostart)?; - let mut config = read_stored_config()?; - config.autostart = autostart; - write_stored_config(&config)?; - to_app_config(config) - } +#[tauri::command] +fn save_mimo_refresh_interval(seconds: u64) -> Result { + let mut config = config::read_stored_config()?; + config.mimo_refresh_interval_seconds = seconds; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct BalanceResult { - is_available: bool, - currency: String, - total_balance: String, - granted_balance: String, - topped_up_balance: String, - } +#[tauri::command] +fn save_notify_cooldown(minutes: u64) -> Result { + let mut config = config::read_stored_config()?; + config.notify_cooldown_minutes = minutes; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - // 实时查询 DeepSeek 账户余额。DeepSeek 官方仅提供余额接口,无用量接口。 - #[tauri::command] - async fn fetch_balance() -> Result { - let config = read_stored_config()?; - let api_key = config - .api_key - .filter(|value| !value.is_empty()) - .ok_or_else(|| "未配置 API Key".to_string())?; - - let client = reqwest::Client::new(); - let response = client - .get("https://api.deepseek.com/user/balance") - .bearer_auth(&api_key) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|error| format!("网络请求失败:{error}"))?; - - match response.status().as_u16() { - 200 => {} - 401 => return Err("API Key 无效或已过期".to_string()), - 429 => return Err("请求过于频繁,请稍后再试".to_string()), - code if code >= 500 => return Err(format!("DeepSeek 服务器错误:{code}")), - code => return Err(format!("请求失败:HTTP {code}")), - } +#[tauri::command] +fn export_config_json() -> Result { + let config = config::read_stored_config()?; + serde_json::to_string_pretty(&config).map_err(|e| e.to_string()) +} - #[derive(Deserialize)] - struct BalanceInfo { - currency: String, - total_balance: String, - granted_balance: String, - topped_up_balance: String, - } - #[derive(Deserialize)] - struct BalanceResponse { - is_available: bool, - balance_infos: Vec, - } +#[tauri::command] +fn import_config_json(json: String) -> Result { + let config: config::StoredConfig = serde_json::from_str(&json).map_err(|e| format!("JSON 解析失败: {}", e))?; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - let data: BalanceResponse = response - .json() - .await - .map_err(|error| format!("解析余额数据失败:{error}"))?; - - let info = data - .balance_infos - .into_iter() - .next() - .ok_or_else(|| "余额信息为空".to_string())?; - - Ok(BalanceResult { - is_available: data.is_available, - currency: info.currency, - total_balance: info.total_balance, - granted_balance: info.granted_balance, - topped_up_balance: info.topped_up_balance, - }) +/// 余额检查并发送 Windows 通知 +fn check_and_notify_low_balance(_app: &tauri::AppHandle, balance: &BalanceResult) { + let config = match config::read_stored_config() { + Ok(c) => c, + Err(_) => return, + }; + if !config.low_balance_notify { + return; } - - #[tauri::command] - fn save_usage_token(usage_token: String) -> Result { - let value = usage_token.trim().to_string(); - if value.is_empty() { - return Err("用量 Token 不能为空".to_string()); - } - let mut config = read_stored_config()?; - config.usage_token = Some(value); - write_stored_config(&config)?; - to_app_config(config) + let threshold = config.low_balance_threshold; + if threshold <= 0.0 { + return; } - - #[tauri::command] - fn clear_usage_token() -> Result { - let mut config = read_stored_config()?; - config.usage_token = None; - write_stored_config(&config)?; - to_app_config(config) + let balance_val = match balance.total_balance.parse::() { + Ok(v) => v, + Err(_) => return, + }; + if balance_val < threshold { + let symbol = if balance.currency == "USD" { "$" } else { "¥" }; + let _ = notify_rust::Notification::new() + .summary("DeepSeek / MiMo Monitor") + .body(&format!("余额不足提醒:当前余额 {}{},低于阈值 {}{}", symbol, balance.total_balance, symbol, threshold)) + .appname("DeepSeekMonitor") + .show(); + log::info!("[Notify] 余额不足: {}{} < {}{}", symbol, balance.total_balance, symbol, threshold); } +} - const USAGE_TOKEN_TITLE_PREFIX: &str = "DSM_USAGE_TOKEN:"; +#[tauri::command] +fn set_provider(provider: String) -> Result { + if provider != "deepseek" && provider != "mimo" { + return Err("无效的 provider,仅支持 deepseek 或 mimo".to_string()); + } + let mut config = config::read_stored_config()?; + config.provider = provider; + config::write_stored_config(&config)?; + config::to_app_config(config) +} - fn capture_usage_token(app: &tauri::AppHandle, token: String) -> Result { - let value = token.trim().to_string(); - if value.is_empty() { - return Err("用量 Token 为空".to_string()); - } - let mut config = read_stored_config()?; - config.usage_token = Some(value); - write_stored_config(&config)?; - let app_config = to_app_config(config)?; - - // 标记本次同步已成功,避免 watcher 在窗口关闭后误发"结束等待"事件 - if let Some(flag) = app.try_state::>() { - flag.store(true, Ordering::SeqCst); - } +#[tauri::command] +async fn fetch_balance(app: tauri::AppHandle) -> Result { + let result = deepseek::do_fetch_balance().await?; + check_and_notify_low_balance(&app, &result); + Ok(result) +} - if let Some(window) = app.get_webview_window("login-sync") { - let _ = window.close(); - } +#[tauri::command] +fn save_usage_token(usage_token: String) -> Result { + deepseek::do_save_usage_token(usage_token) +} - let _ = app.emit("usage-token-captured", &app_config); +#[tauri::command] +fn clear_usage_token() -> Result { + deepseek::do_clear_usage_token() +} - Ok(app_config) - } +#[tauri::command] +async fn start_usage_sync(app: tauri::AppHandle) -> Result { + deepseek::start_usage_sync(&app) +} - // 用 token 试调平台用量接口,验证它确实是有效的用量 token。 - async fn verify_usage_token(token: &str, month: u32, year: u32) -> Result<(), String> { - let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; - let url = - format!("https://platform.deepseek.com/api/v0/usage/amount?month={month}&year={year}"); - let resp = reqwest::Client::new() - .get(&url) - .bearer_auth(token) - .header("x-app-version", "1.0.0") - .header("Accept", "*/*") - .header("User-Agent", ua) - .timeout(Duration::from_secs(15)) - .send() - .await - .map_err(|error| format!("验证 token 失败:{error}"))?; - if resp.status().as_u16() == 200 { - Ok(()) - } else { - Err(format!("token 无效:HTTP {}", resp.status().as_u16())) - } - } +#[tauri::command] +async fn usage_token_captured( + app: tauri::AppHandle, + token: String, + month: u32, + year: u32, +) -> Result { + deepseek::do_usage_token_captured(&app, token, month, year).await +} - fn read_shared_text(path: &Path) -> Option { - let mut file = fs::OpenOptions::new() - .read(true) - .share_mode(0x1 | 0x2 | 0x4) - .open(path) - .ok()?; - let metadata = file.metadata().ok()?; - if metadata.len() == 0 || metadata.len() > 20 * 1024 * 1024 { - return None; - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - file.read_to_end(&mut bytes).ok()?; - Some(String::from_utf8_lossy(&bytes).replace('\0', "")) - } +#[tauri::command] +async fn fetch_usage(month: u32, year: u32) -> Result { + deepseek::do_fetch_usage(month, year).await +} - fn extract_user_api_token(text: &str) -> Option { - let mut search_from = 0; - let marker = "\"token\":\""; - while let Some(relative_index) = text[search_from..].find(marker) { - let token_start = search_from + relative_index + marker.len(); - let token_end = token_start + text[token_start..].find('"')?; - let token = &text[token_start..token_end]; - let context_end = (token_end + 1800).min(text.len()); - let context = &text[token_end..context_end]; - if token.len() > 20 - && context.contains("\"id_profile\"") - && context.contains("\"feature_gates\"") - { - return Some(token.to_string()); +#[tauri::command] +async fn fetch_mimo_balance(app: tauri::AppHandle) -> Result { + let result = mimo::do_fetch_mimo_balance(&app).await?; + // 检查 MiMo 余额是否低于阈值 + let config = config::read_stored_config().unwrap_or_default(); + if config.low_balance_notify && config.low_balance_threshold > 0.0 { + if let Ok(val) = result.available_balance.parse::() { + if val < config.low_balance_threshold { + let symbol = if result.currency == "USD" { "$" } else { "¥" }; + let _ = notify_rust::Notification::new() + .summary("DeepSeek / MiMo Monitor") + .body(&format!("余额不足提醒:当前余额 {}{},低于阈值 {}{}", symbol, result.available_balance, symbol, config.low_balance_threshold)) + .appname("DeepSeekMonitor") + .show(); + log::info!("[Notify] MiMo 余额不足: {}{} < {}{}", symbol, result.available_balance, symbol, config.low_balance_threshold); } - search_from = token_end + 1; } - None } + Ok(result) +} - fn find_webview_cached_usage_token() -> Option { - let local_app_data = std::env::var_os("LOCALAPPDATA")?; - let cache_dir = PathBuf::from(local_app_data) - .join("com.deepseek.monitor.windows") - .join("EBWebView") - .join("Default") - .join("Cache") - .join("Cache_Data"); - let entries = fs::read_dir(cache_dir).ok()?; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_file() { - continue; - } - if let Some(text) = read_shared_text(&path) { - if let Some(token) = extract_user_api_token(&text) { - return Some(token); - } - } - } - None - } - - fn start_usage_title_watcher(app: tauri::AppHandle) { - thread::spawn(move || { - // 登录页加载并触发平台 API 请求需要时间,等待后再开始扫缓存 - thread::sleep(Duration::from_secs(3)); - for _ in 0..1200 { - if let Some(token) = find_webview_cached_usage_token() { - let _ = capture_usage_token(&app, token); - return; - } - - let Some(window) = app.get_webview_window("login-sync") else { - // 窗口已关闭:若不是因成功捕获而关闭,才通知前端结束等待 - let captured = app - .try_state::>() - .map(|flag| flag.load(Ordering::SeqCst)) - .unwrap_or(false); - if !captured { - let _ = app.emit("usage-sync-ended", ()); - } - return; - }; - - if let Ok(title) = window.title() { - if let Some(rest) = title.strip_prefix(USAGE_TOKEN_TITLE_PREFIX) { - // 注入脚本写入的格式:{year}:{month}:{token} - let mut parts = rest.splitn(3, ':'); - if let (Some(y), Some(m), Some(tok)) = - (parts.next(), parts.next(), parts.next()) - { - if let (Ok(year), Ok(month)) = (y.parse::(), m.parse::()) { - let token = tok.to_string(); - // 验证 token 真能调用用量接口,过滤登录中途的临时 token - let verified = tauri::async_runtime::block_on( - verify_usage_token(&token, month, year), - ); - if verified.is_ok() { - let _ = capture_usage_token(&app, token); - return; - } - } - } - } - } - - thread::sleep(Duration::from_millis(1500)); - } - // 30 分钟超时,若仍未成功则通知前端结束等待 - let captured = app - .try_state::>() - .map(|flag| flag.load(Ordering::SeqCst)) - .unwrap_or(false); - if !captured { - let _ = app.emit("usage-sync-ended", ()); - } - }); - } +#[tauri::command] +async fn fetch_mimo_usage( + app: tauri::AppHandle, + month: u32, + year: u32, +) -> Result { + mimo::do_fetch_mimo_usage(&app, month, year).await +} - // 在登录窗口注入,hook fetch / XMLHttpRequest,主动从平台 API 请求的 - // Authorization 头里抓 Bearer token。登录后页面自动调 API 即可即时捕获, - // 不再依赖 WebView2 磁盘缓存的延迟落盘。 - const USAGE_SYNC_POLL_JS: &str = r#" - (function() { - if (window.__dsm_token_hook__) return; - window.__dsm_token_hook__ = true; - var done = false; - var pending = false; - - function deliver(token) { - if (done) return; - if (!token || typeof token !== 'string') return; - token = token.trim(); - if (token.length < 20) return; - var now = new Date(); - var y = now.getFullYear(); - var m = now.getMonth() + 1; - // 主通道:写入 document.title,原生侧 window.title() 读取。 - // 外部网站窗口默认不注入 __TAURI__,此通道不依赖它,最可靠。 - try { document.title = 'DSM_USAGE_TOKEN:' + y + ':' + m + ':' + token; } catch (e) {} - // 辅通道:若本窗口恰好可用 __TAURI__,直接上报更快 - try { - if (!pending && window.__TAURI__ && window.__TAURI__.core) { - pending = true; - window.__TAURI__.core.invoke('usage_token_captured', { - token: token, month: m, year: y - }).then(function() { done = true; }).catch(function() { pending = false; }); - } - } catch (e) {} - } - - function fromAuth(value) { - if (!value) return; - var m = /Bearer\s+(\S+)/i.exec(String(value)); - if (m && m[1]) deliver(m[1]); - } - - var origFetch = window.fetch; - if (typeof origFetch === 'function') { - window.fetch = function(input, init) { - try { - var headers = (init && init.headers) || (input && input.headers); - if (headers) { - if (typeof Headers !== 'undefined' && headers instanceof Headers) { - fromAuth(headers.get('authorization')); - } else if (Array.isArray(headers)) { - for (var i = 0; i < headers.length; i++) { - if (headers[i] && String(headers[i][0]).toLowerCase() === 'authorization') { - fromAuth(headers[i][1]); - } - } - } else if (typeof headers === 'object') { - for (var k in headers) { - if (k.toLowerCase() === 'authorization') fromAuth(headers[k]); - } - } - } - } catch (e) {} - return origFetch.apply(this, arguments); - }; - } - - var origSet = XMLHttpRequest.prototype.setRequestHeader; - XMLHttpRequest.prototype.setRequestHeader = function(name, value) { - try { - if (name && String(name).toLowerCase() === 'authorization') fromAuth(value); - } catch (e) {} - return origSet.apply(this, arguments); - }; - })(); - "#; - - #[tauri::command] - async fn start_usage_sync(app: tauri::AppHandle) -> Result { - // 重置本次同步的成功标志 - if let Some(flag) = app.try_state::>() { - flag.store(false, Ordering::SeqCst); - } +#[tauri::command] +async fn start_mimo_sync(app: tauri::AppHandle) -> Result { + mimo::do_start_mimo_sync(&app) +} - // 先扫一次缓存:登录完成后重复点击本命令,缓存落盘后即可命中 - if let Some(token) = find_webview_cached_usage_token() { - capture_usage_token(&app, token)?; - return Ok(true); - } +#[tauri::command] +async fn ensure_mimo_webview(app: tauri::AppHandle) -> Result<(), String> { + mimo::do_ensure_mimo_webview(&app) +} - // 登录窗口已存在:刷新它,促使用量页重新请求接口、把响应写入缓存, - // 用户随后再点一次本按钮即可命中。不重复弹新窗口、不死等。 - if app.get_webview_window("login-sync").is_some() { - if let Some(window) = app.get_webview_window("login-sync") { - let _ = window.eval("location.reload();"); - } - return Ok(false); - } +#[tauri::command] +fn mimo_api_response( + app: tauri::AppHandle, + req_id: String, + json: String, +) -> Result<(), String> { + mimo::do_mimo_api_response(&app, req_id, json) +} - let url = tauri::WebviewUrl::External("https://platform.deepseek.com".parse().unwrap()); - tauri::WebviewWindowBuilder::new( - &app, - "login-sync", - url, - ) - .title("DeepSeek 账号登录") - .inner_size(480.0, 720.0) - .min_inner_size(360.0, 480.0) - .resizable(true) - .center() - .visible(true) - .initialization_script(USAGE_SYNC_POLL_JS) - .on_page_load(|window, payload| { - if matches!(payload.event(), PageLoadEvent::Finished) - && payload - .url() - .host_str() - .is_some_and(|host| host == "platform.deepseek.com") - { - // 双保险:万一 initialization_script 未注入,页面加载完再装一次 hook - let _ = window.eval(USAGE_SYNC_POLL_JS); - } - }) - .build() - .map_err(|error| format!("打开登录窗口失败:{error}"))?; - start_usage_title_watcher(app); - Ok(false) - } +// ─── 自动更新 ────────────────────────────────────────────── - #[tauri::command] - async fn usage_token_captured( - app: tauri::AppHandle, - token: String, - month: u32, - year: u32, - ) -> Result { - let value = token.trim().to_string(); - if value.is_empty() { - return Err("用量 Token 为空".to_string()); - } - // 先验证再保存:拦截到的 token 可能是登录中途的临时 token, - // 只有能真正调用用量接口的才接受 - verify_usage_token(&value, month, year).await?; - capture_usage_token(&app, value) - } +struct PendingUpdate(std::sync::Mutex>); - #[derive(Debug, Serialize)] - #[serde(rename_all = "camelCase")] - struct UsageModelSummary { - key: String, - name: String, - total_tokens: u64, - request_count: u64, - cache_hit_tokens: u64, - cache_miss_tokens: u64, - response_tokens: u64, - cost: f64, - } +#[derive(serde::Serialize)] +struct UpdateInfo { + version: String, + date: String, + body: String, +} - #[derive(Debug, Serialize)] +#[derive(Clone, serde::Serialize)] +#[serde(tag = "event", content = "data")] +enum DownloadEvent { #[serde(rename_all = "camelCase")] - struct UsageDaySummary { - date: String, - flash_tokens: u64, - flash_cache_hit: u64, - flash_cache_miss: u64, - flash_response: u64, - pro_tokens: u64, - pro_cache_hit: u64, - pro_cache_miss: u64, - pro_response: u64, - total_tokens: u64, - total_cost: f64, - } - - #[derive(Debug, Serialize)] + Started { content_length: Option }, #[serde(rename_all = "camelCase")] - struct UsageResult { - models: Vec, - days: Vec, - month_cost: f64, - } - - // 通过 DeepSeek 平台内部接口拉取用量与费用(需网页登录 token,非官方 API Key)。 - #[tauri::command] - async fn fetch_usage(month: u32, year: u32) -> Result { - let config = read_stored_config()?; - let token = config - .usage_token - .filter(|value| !value.is_empty()) - .ok_or_else(|| "未配置用量 Token".to_string())?; - - #[derive(Deserialize)] - struct Entry { - #[serde(rename = "type")] - kind: String, - amount: String, - } - #[derive(Deserialize)] - struct ModelUsage { - model: String, - usage: Vec, - } - #[derive(Deserialize)] - struct DayUsage { - date: String, - data: Vec, - } - #[derive(Deserialize)] - struct AmountBiz { - total: Vec, - days: Vec, - } - #[derive(Deserialize)] - struct AmountData { - biz_data: AmountBiz, - } - #[derive(Deserialize)] - struct AmountResp { - data: AmountData, - } - #[derive(Deserialize)] - struct CostBiz { - total: Vec, - days: Vec, - } - #[derive(Deserialize)] - struct CostData { - biz_data: Vec, - } - #[derive(Deserialize)] - struct CostResp { - data: CostData, - } - - async fn get_json( - client: &reqwest::Client, - url: &str, - token: &str, - ) -> Result { - let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; - let resp = client - .get(url) - .bearer_auth(token) - .header("x-app-version", "1.0.0") - .header("Accept", "*/*") - .header("User-Agent", ua) - .timeout(std::time::Duration::from_secs(15)) - .send() - .await - .map_err(|error| format!("用量请求失败:{error}"))?; - match resp.status().as_u16() { - 200 => {} - 401 => return Err("用量 Token 无效或已过期,请重新获取".to_string()), - 429 => return Err("请求过于频繁,请稍后再试".to_string()), - code => return Err(format!("用量接口错误:HTTP {code}")), - } - resp.json::() - .await - .map_err(|error| format!("解析用量数据失败:{error}")) - } - - fn token_breakdown(usage: &[Entry]) -> (u64, u64, u64, u64, u64) { - // 返回 (总 token, 请求数, 缓存命中, 缓存未命中, 输出 token) - let mut total = 0u64; - let mut request = 0u64; - let mut hit = 0u64; - let mut miss = 0u64; - let mut response = 0u64; - for entry in usage { - let value = entry.amount.parse::().unwrap_or(0.0).round() as u64; - match entry.kind.as_str() { - "REQUEST" => request = value, - "PROMPT_CACHE_HIT_TOKEN" => { - hit = value; - total += value; - } - "PROMPT_CACHE_MISS_TOKEN" => { - miss = value; - total += value; - } - "RESPONSE_TOKEN" => { - response = value; - total += value; - } - "PROMPT_TOKEN" => total += value, - _ => {} - } - } - (total, request, hit, miss, response) - } - - fn cost_sum(usage: &[Entry]) -> f64 { - usage - .iter() - .filter(|entry| entry.kind != "REQUEST") - .map(|entry| entry.amount.parse::().unwrap_or(0.0)) - .sum() - } + Progress { chunk_length: usize, downloaded: u64 }, + Finished, +} - let client = reqwest::Client::new(); - let amount_url = - format!("https://platform.deepseek.com/api/v0/usage/amount?month={month}&year={year}"); - let cost_url = - format!("https://platform.deepseek.com/api/v0/usage/cost?month={month}&year={year}"); - - let amount: AmountResp = get_json(&client, &amount_url, &token).await?; - let cost: CostResp = get_json(&client, &cost_url, &token).await?; - - let cost_total = cost.data.biz_data.first(); - let cost_for_model = |model: &str| -> f64 { - cost_total - .and_then(|item| item.total.iter().find(|m| m.model == model)) - .map(|m| cost_sum(&m.usage)) - .unwrap_or(0.0) - }; - - let mut models = Vec::new(); - for model_usage in &amount.data.biz_data.total { - let label = match model_usage.model.as_str() { - "deepseek-v4-flash" => Some(("flash", "V4 Flash")), - "deepseek-v4-pro" => Some(("pro", "V4 Pro")), - _ => None, +#[tauri::command] +async fn check_update(app: tauri::AppHandle, pending: tauri::State<'_, PendingUpdate>) -> Result, String> { + use tauri_plugin_updater::UpdaterExt; + let updater = app.updater().map_err(|e| format!("获取更新器失败:{e}"))?; + match updater.check().await { + Ok(Some(update)) => { + let info = UpdateInfo { + version: update.version.clone(), + date: update.date.map(|d| { + let y = d.year(); + let m = d.month() as u8; + let day = d.day(); + format!("{y}-{m:02}-{day:02}") + }).unwrap_or_default(), + body: update.body.clone().unwrap_or_default(), }; - if let Some((key, name)) = label { - let (total, request, hit, miss, response) = token_breakdown(&model_usage.usage); - models.push(UsageModelSummary { - key: key.to_string(), - name: name.to_string(), - total_tokens: total, - request_count: request, - cache_hit_tokens: hit, - cache_miss_tokens: miss, - response_tokens: response, - cost: cost_for_model(&model_usage.model), - }); - } - } - - let mut cost_by_date: std::collections::HashMap = - std::collections::HashMap::new(); - if let Some(item) = cost_total { - for day in &item.days { - let day_cost: f64 = day.data.iter().map(|m| cost_sum(&m.usage)).sum(); - cost_by_date.insert(day.date.clone(), day_cost); - } + *pending.0.lock().unwrap() = Some(update); + Ok(Some(info)) } + Ok(None) => Ok(None), + Err(e) => Err(format!("检查更新失败:{e}")), + } +} - let mut days = Vec::new(); - for day in &amount.data.biz_data.days { - let mut flash = 0u64; - let mut flash_hit = 0u64; - let mut flash_miss = 0u64; - let mut flash_resp = 0u64; - let mut pro = 0u64; - let mut pro_hit = 0u64; - let mut pro_miss = 0u64; - let mut pro_resp = 0u64; - let mut total = 0u64; - for model_usage in &day.data { - let (tokens, _, hit, miss, response) = token_breakdown(&model_usage.usage); - total += tokens; - match model_usage.model.as_str() { - "deepseek-v4-flash" => { - flash += tokens; - flash_hit += hit; - flash_miss += miss; - flash_resp += response; - } - "deepseek-v4-pro" => { - pro += tokens; - pro_hit += hit; - pro_miss += miss; - pro_resp += response; - } - _ => {} +#[tauri::command] +async fn install_update(pending: tauri::State<'_, PendingUpdate>, on_event: tauri::ipc::Channel) -> Result<(), String> { + let update = pending.0.lock().unwrap().take().ok_or("没有待安装的更新")?; + let mut downloaded: u64 = 0; + let mut started = false; + update + .download_and_install( + |chunk_len, content_len| { + if !started { + let _ = on_event.send(DownloadEvent::Started { content_length: content_len }); + started = true; } - } - days.push(UsageDaySummary { - date: day.date.clone(), - flash_tokens: flash, - flash_cache_hit: flash_hit, - flash_cache_miss: flash_miss, - flash_response: flash_resp, - pro_tokens: pro, - pro_cache_hit: pro_hit, - pro_cache_miss: pro_miss, - pro_response: pro_resp, - total_tokens: total, - total_cost: cost_by_date.get(&day.date).copied().unwrap_or(0.0), - }); - } - - let month_cost: f64 = cost_total - .map(|item| item.total.iter().map(|m| cost_sum(&m.usage)).sum()) - .unwrap_or(0.0); + downloaded += chunk_len as u64; + let _ = on_event.send(DownloadEvent::Progress { chunk_length: chunk_len, downloaded }); + }, + || { + let _ = on_event.send(DownloadEvent::Finished); + }, + ) + .await + .map_err(|e| format!("下载安装失败:{e}"))?; + Ok(()) +} - Ok(UsageResult { - models, - days, - month_cost, - }) - } +// ─── 主入口 ────────────────────────────────────────────── +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { tauri::Builder::default() - // 单实例守卫:必须作为第一个注册的插件。 - // 程序已运行时再次启动 exe,第二个进程不会新开窗口, - // 而是触发此回调把已有主窗口显示并聚焦,随后第二个进程自行退出。 .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { - show_main_window(&window); + tray::show_main_window(&window); } })) - .manage(Arc::new(AtomicBool::new(false))) + .manage(Arc::new(std::sync::atomic::AtomicBool::new(false))) + .manage(Arc::new(Mutex::new(HashMap::>::new()))) + .manage(Arc::new(tokio::sync::Mutex::new(()))) + .manage(Mutex::new(MimoDetailCache::new())) + .manage(Mutex::new(CallbackServerPort(0))) + .manage(PendingUpdate(std::sync::Mutex::new(None))) .invoke_handler(tauri::generate_handler![ hide_main_window, + resize_window, get_app_config, save_api_key, clear_api_key, save_refresh_interval, save_auto_refresh_enabled, save_autostart, + save_low_balance_notify, + save_low_balance_threshold, + save_theme, + save_currency, + save_efficiency_unit, + save_default_provider, + save_mimo_refresh_interval, + save_notify_cooldown, + set_provider, fetch_balance, save_usage_token, clear_usage_token, fetch_usage, start_usage_sync, - usage_token_captured + usage_token_captured, + fetch_mimo_balance, + fetch_mimo_usage, + start_mimo_sync, + ensure_mimo_webview, + mimo_api_response, + check_update, + install_update, + export_config_json, + import_config_json ]) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -933,49 +527,33 @@ pub fn run() { )?; } - let show_item = MenuItem::with_id(app, "show", "显示主面板", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?; - - let mut tray_builder = TrayIconBuilder::new() - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id().as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - show_main_window(&window); - } - } - "quit" => { - app.exit(0); - } - _ => {} - }) - .on_tray_icon_event(|tray, event| { - // 仅在左键“抬起”时切换;否则按下+抬起各触发一次,窗口会闪现后立即隐藏 - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = window.hide(); - } else { - show_main_window(&window); - } - } - } - }); + // 启动持久化回调服务器 + let shared_map = app + .state::>>>>() + .inner() + .clone(); + let cb_server = CallbackServer::start(shared_map)?; + *app.state::>().lock().unwrap() = + CallbackServerPort(cb_server.port); + app.manage(Mutex::new(cb_server)); + + // 初始化托盘 + tray::setup_tray(app)?; - if let Some(icon) = app.default_window_icon() { - tray_builder = tray_builder.icon(icon.clone()); + // 恢复窗口大小和位置,或首次启动定位到右下角 + if let Some(window) = app.get_webview_window("main") { + if let Ok(config) = config::read_stored_config() { + if let (Some(w), Some(h), Some(x), Some(y)) = (config.window_width, config.window_height, config.window_x, config.window_y) { + // 有保存的状态,恢复 + let _ = window.set_size(tauri::LogicalSize::new(w, h)); + let _ = window.set_position(tauri::PhysicalPosition::new(x.max(0), y.max(0))); + return Ok(()); + } + } + // 首次启动或无保存状态,定位到右下角 + let _ = tray::position_near_tray(&window); } - tray_builder.build(app)?; Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/modules/config.rs b/src-tauri/src/modules/config.rs new file mode 100644 index 0000000..76c9f7c --- /dev/null +++ b/src-tauri/src/modules/config.rs @@ -0,0 +1,430 @@ +//! 配置管理 + Windows DPAPI 凭据加密 +//! +//! 职责:配置路径、读写、DPAPI 加密/解密、开机自启注册表操作。 + +use std::{fs, path::PathBuf}; + +use crate::modules::types::AppConfig; +pub use crate::modules::types::StoredConfig; + +// ─── DPAPI 加密 ────────────────────────────────────────── + +#[repr(C)] +struct DataBlob { + cb_data: u32, + pb_data: *mut u8, +} + +#[link(name = "crypt32")] +extern "system" { + fn CryptProtectData( + pdata_in: *const DataBlob, + sz_data_descr: *const u16, + p_optional_entropy: *const DataBlob, + pv_reserved: *mut core::ffi::c_void, + p_prompt_struct: *const core::ffi::c_void, + dw_flags: u32, + pdata_out: *mut DataBlob, + ) -> i32; + fn CryptUnprotectData( + pdata_in: *const DataBlob, + p_sz_data_descr: *mut *mut u16, + p_optional_entropy: *const DataBlob, + pv_reserved: *mut core::ffi::c_void, + p_prompt_struct: *const core::ffi::c_void, + dw_flags: u32, + pdata_out: *mut DataBlob, + ) -> i32; +} + +#[link(name = "kernel32")] +extern "system" { + fn LocalFree(h_mem: isize) -> isize; +} + +fn dpapi_encrypt(plain: &[u8]) -> Result, String> { + let data_in = DataBlob { + cb_data: plain.len() as u32, + pb_data: plain.as_ptr() as *mut u8, + }; + let mut data_out = DataBlob { + cb_data: 0, + pb_data: std::ptr::null_mut(), + }; + // SAFETY: CryptProtectData is a Windows API that reads data_in and writes to data_out. + // Both structs are valid for the duration of the call. data_out.pb_data is allocated by the OS. + let result = unsafe { + CryptProtectData( + &data_in, + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + &mut data_out, + ) + }; + if result == 0 { + return Err("DPAPI 加密失败".to_string()); + } + // SAFETY: data_out.pb_data is guaranteed valid by CryptProtectData on success; + // cb_data matches the allocated length. We copy to a Vec immediately. + let encrypted = unsafe { + std::slice::from_raw_parts(data_out.pb_data, data_out.cb_data as usize).to_vec() + }; + // SAFETY: data_out.pb_data was allocated by CryptProtectData; LocalFree is the correct deallocator. + unsafe { + LocalFree(data_out.pb_data as isize); + } + Ok(encrypted) +} + +fn dpapi_decrypt(encrypted: &[u8]) -> Result, String> { + let data_in = DataBlob { + cb_data: encrypted.len() as u32, + pb_data: encrypted.as_ptr() as *mut u8, + }; + let mut data_out = DataBlob { + cb_data: 0, + pb_data: std::ptr::null_mut(), + }; + // SAFETY: CryptUnprotectData reads data_in (valid encrypted bytes) and writes to data_out. + // Both structs are valid for the duration of the call. + let result = unsafe { + CryptUnprotectData( + &data_in, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + &mut data_out, + ) + }; + if result == 0 { + return Err("DPAPI 解密失败,凭据可能由其他 Windows 用户加密".to_string()); + } + // SAFETY: data_out.pb_data is guaranteed valid by CryptUnprotectData on success; + // cb_data matches the allocated length. We copy to a Vec immediately. + let decrypted = unsafe { + std::slice::from_raw_parts(data_out.pb_data, data_out.cb_data as usize).to_vec() + }; + // SAFETY: data_out.pb_data was allocated by CryptUnprotectData; LocalFree is the correct deallocator. + unsafe { + LocalFree(data_out.pb_data as isize); + } + Ok(decrypted) +} + +fn hex_encode(data: &[u8]) -> String { + data.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("十六进制编码长度无效".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| format!("十六进制解码失败:{e}")) + }) + .collect() +} + +pub fn encrypt_credential(plain: &str) -> Result { + match dpapi_encrypt(plain.as_bytes()) { + Ok(encrypted) => Ok(format!("enc1:{}", hex_encode(&encrypted))), + Err(e) => { + log::error!("DPAPI 加密失败: {}", e); + Err(format!("DPAPI 加密失败,凭据未保存: {}", e)) + } + } +} + +pub fn decrypt_credential(stored: &str) -> Result { + if let Some(hex) = stored.strip_prefix("enc1:") { + let encrypted = hex_decode(hex)?; + let decrypted = dpapi_decrypt(&encrypted)?; + String::from_utf8(decrypted).map_err(|e| format!("解密凭据失败:{e}")) + } else { + Ok(stored.to_string()) // 向后兼容明文 + } +} + +// ─── 配置路径 ──────────────────────────────────────────── + +pub fn config_path() -> Result { + let appdata = std::env::var_os("APPDATA").ok_or("APPDATA is not available")?; + Ok(PathBuf::from(appdata) + .join("DeepSeekMonitorWindows") + .join("config.json")) +} + +// ─── 配置读写 ──────────────────────────────────────────── + +fn normalize_refresh_interval_seconds(value: u64) -> u64 { + match value { + 0 => 60, + v if v < 60 => 60, + _ => value, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_valid() { + assert_eq!(normalize_refresh_interval_seconds(60), 60); + assert_eq!(normalize_refresh_interval_seconds(300), 300); + assert_eq!(normalize_refresh_interval_seconds(1800), 1800); + assert_eq!(normalize_refresh_interval_seconds(3600), 3600); + } + + #[test] + fn normalize_invalid() { + assert_eq!(normalize_refresh_interval_seconds(0), 60); + assert_eq!(normalize_refresh_interval_seconds(1), 60); + assert_eq!(normalize_refresh_interval_seconds(59), 60); + // 自定义值应保留 + assert_eq!(normalize_refresh_interval_seconds(120), 120); + assert_eq!(normalize_refresh_interval_seconds(999), 999); + assert_eq!(normalize_refresh_interval_seconds(7200), 7200); + } + + #[test] + fn api_key_preview_long() { + let preview = api_key_preview("sk-abcde12345fghij67890"); + assert!(preview.contains("...")); + assert!(preview.starts_with("sk-abc")); + assert!(preview.ends_with("7890")); + } + + #[test] + fn api_key_preview_short() { + assert_eq!(api_key_preview("short"), "已保存"); + } + + #[test] + fn decrypt_passthrough_plain() { + let result = decrypt_credential("plain-text-token"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "plain-text-token"); + } + + #[test] + fn decrypt_invalid_hex() { + let result = decrypt_credential("enc1:invalid_hex"); + assert!(result.is_err()); + } + + #[test] + fn to_app_config_no_keys() { + let config = StoredConfig { + api_key: None, + usage_token: None, + provider: "deepseek".to_string(), + mimo_token: None, + mimo_ph: None, + refresh_interval_seconds: 60, + auto_refresh_enabled: false, + autostart: false, + ..Default::default() + }; + let app = to_app_config(config).unwrap(); + assert!(!app.api_key_configured); + assert!(app.api_key_preview.is_none()); + assert!(!app.usage_token_configured); + assert!(!app.mimo_token_configured); + assert_eq!(app.provider, "deepseek"); + } + + #[test] + fn to_app_config_with_keys() { + let config = StoredConfig { + api_key: Some("sk-abcde12345fghij67890".to_string()), + usage_token: Some("some-usage-token".to_string()), + provider: "mimo".to_string(), + mimo_token: Some("mimo-token".to_string()), + mimo_ph: None, + refresh_interval_seconds: 300, + auto_refresh_enabled: true, + autostart: true, + ..Default::default() + }; + let app = to_app_config(config).unwrap(); + assert!(app.api_key_configured); + assert!(app.api_key_preview.is_some()); + assert!(app.usage_token_configured); + assert!(app.mimo_token_configured); + assert_eq!(app.provider, "mimo"); + assert_eq!(app.refresh_interval_seconds, 300); + assert!(app.auto_refresh_enabled); + assert!(app.autostart); + } + + #[test] + fn to_app_config_empty_keys_not_configured() { + let config = StoredConfig { + api_key: Some("".to_string()), + usage_token: Some("".to_string()), + provider: "deepseek".to_string(), + mimo_token: None, + mimo_ph: None, + refresh_interval_seconds: 60, + auto_refresh_enabled: false, + autostart: false, + ..Default::default() + }; + let app = to_app_config(config).unwrap(); + assert!(!app.api_key_configured); + assert!(!app.low_balance_notify); + } +} + +pub fn read_stored_config() -> Result { + let path = config_path()?; + if !path.exists() { + return Ok(StoredConfig { + refresh_interval_seconds: 60, + ..StoredConfig::default() + }); + } + + let text = fs::read_to_string(&path).map_err(|error| error.to_string())?; + let mut config: StoredConfig = + serde_json::from_str(&text).map_err(|error| error.to_string())?; + config.refresh_interval_seconds = + normalize_refresh_interval_seconds(config.refresh_interval_seconds); + // 解密凭据(向后兼容明文) + if let Some(ref key) = config.api_key { + config.api_key = Some(decrypt_credential(key)?); + } + if let Some(ref token) = config.usage_token { + config.usage_token = Some(decrypt_credential(token)?); + } + if let Some(ref token) = config.mimo_token { + config.mimo_token = Some(decrypt_credential(token)?); + } + if let Some(ref ph) = config.mimo_ph { + config.mimo_ph = Some(decrypt_credential(ph)?); + } + Ok(config) +} + +pub fn write_stored_config(config: &StoredConfig) -> Result<(), String> { + let path = config_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + + // 加密凭据后写入 + let mut encrypted_config = config.clone(); + if let Some(ref key) = config.api_key { + encrypted_config.api_key = Some(encrypt_credential(key)?); + } + if let Some(ref token) = config.usage_token { + encrypted_config.usage_token = Some(encrypt_credential(token)?); + } + if let Some(ref token) = config.mimo_token { + encrypted_config.mimo_token = Some(encrypt_credential(token)?); + } + if let Some(ref ph) = config.mimo_ph { + encrypted_config.mimo_ph = Some(encrypt_credential(ph)?); + } + + let text = + serde_json::to_string_pretty(&encrypted_config).map_err(|error| error.to_string())?; + fs::write(path, text).map_err(|error| error.to_string()) +} + +// ─── AppConfig 转换 ────────────────────────────────────── + +fn api_key_preview(api_key: &str) -> String { + let chars: Vec = api_key.chars().collect(); + if chars.len() <= 12 { + return "已保存".to_string(); + } + let start: String = chars.iter().take(7).collect(); + let end: String = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{start}...{end}") +} + +pub fn to_app_config(config: StoredConfig) -> Result { + let path = config_path()?; + + let api_key_preview = config + .api_key + .as_ref() + .filter(|k| !k.is_empty()) + .map(|k| api_key_preview(k)); + + let usage_token_configured = config + .usage_token + .as_ref() + .filter(|t| !t.is_empty()) + .is_some(); + + let mimo_token_configured = config + .mimo_token + .as_ref() + .filter(|t| !t.is_empty()) + .is_some(); + + Ok(AppConfig { + api_key_configured: api_key_preview.is_some(), + api_key_preview, + usage_token_configured, + provider: config.provider, + mimo_token_configured, + refresh_interval_seconds: config.refresh_interval_seconds, + auto_refresh_enabled: config.auto_refresh_enabled, + autostart: config.autostart, + config_path: path.to_string_lossy().to_string(), + low_balance_notify: config.low_balance_notify, + low_balance_threshold: config.low_balance_threshold, + theme: config.theme, + currency: config.currency, + efficiency_unit: config.efficiency_unit, + default_provider: config.default_provider, + mimo_refresh_interval_seconds: config.mimo_refresh_interval_seconds, + notify_cooldown_minutes: config.notify_cooldown_minutes, + }) +} + +// ─── 开机自启 ──────────────────────────────────────────── + +pub fn apply_autostart(enabled: bool) -> Result<(), String> { + use winreg::enums::*; + use winreg::RegKey; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let run_key = hkcu + .open_subkey_with_flags( + r"Software\Microsoft\Windows\CurrentVersion\Run", + KEY_READ | KEY_WRITE, + ) + .map_err(|e| e.to_string())?; + + let value_name = "DeepSeekMonitorWindows"; + + if enabled { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let path = exe.to_string_lossy().to_string(); + run_key + .set_value(value_name, &path) + .map_err(|e| e.to_string())?; + } else { + let _ = run_key.delete_value(value_name); + } + Ok(()) +} diff --git a/src-tauri/src/modules/deepseek.rs b/src-tauri/src/modules/deepseek.rs new file mode 100644 index 0000000..069f21c --- /dev/null +++ b/src-tauri/src/modules/deepseek.rs @@ -0,0 +1,615 @@ +//! DeepSeek API 模块 +//! +//! 职责:余额查询、用量查询、Token 同步(WebView 登录 + 磁盘缓存扫描)。 + +use std::{ + fs, + io::Read, + os::windows::fs::OpenOptionsExt, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; + +use serde::Deserialize; +use tauri::{Emitter, Manager}; +use tauri::webview::PageLoadEvent; + +use crate::modules::types::{BalanceResult, UsageDaySummary, UsageModelSummary, UsageResult}; +use crate::modules::config::{read_stored_config, write_stored_config, to_app_config}; +use crate::modules::types::AppConfig; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"; +const REQUEST_TIMEOUT_SECS: u64 = 15; + +// ─── 余额查询 ──────────────────────────────────────────── + +pub async fn do_fetch_balance() -> Result { + let config = read_stored_config()?; + let api_key = config + .api_key + .filter(|value| !value.is_empty()) + .ok_or_else(|| "未配置 API Key".to_string())?; + + let client = reqwest::Client::new(); + let response = client + .get("https://api.deepseek.com/user/balance") + .bearer_auth(&api_key) + .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .send() + .await + .map_err(|error| format!("网络请求失败:{error}"))?; + + match response.status().as_u16() { + 200 => {} + 401 => return Err("API Key 无效或已过期".to_string()), + 429 => return Err("请求过于频繁,请稍后再试".to_string()), + code if code >= 500 => return Err(format!("DeepSeek 服务器错误:{code}")), + code => return Err(format!("请求失败:HTTP {code}")), + } + + #[derive(Deserialize)] + struct BalanceInfo { + currency: String, + total_balance: String, + granted_balance: String, + topped_up_balance: String, + } + #[derive(Deserialize)] + struct BalanceResponse { + is_available: bool, + balance_infos: Vec, + } + + let data: BalanceResponse = response + .json() + .await + .map_err(|error| format!("解析余额数据失败:{error}"))?; + + let info = data + .balance_infos + .into_iter() + .next() + .ok_or_else(|| "余额信息为空".to_string())?; + + Ok(BalanceResult { + is_available: data.is_available, + currency: info.currency, + total_balance: info.total_balance, + granted_balance: info.granted_balance, + topped_up_balance: info.topped_up_balance, + }) +} + +// ─── Token 管理 ────────────────────────────────────────── + +pub fn do_save_usage_token(usage_token: String) -> Result { + let value = usage_token.trim().to_string(); + if value.is_empty() { + return Err("用量 Token 不能为空".to_string()); + } + let mut config = read_stored_config()?; + config.usage_token = Some(value); + write_stored_config(&config)?; + to_app_config(config) +} + +pub fn do_clear_usage_token() -> Result { + let mut config = read_stored_config()?; + config.usage_token = None; + write_stored_config(&config)?; + to_app_config(config) +} + +const USAGE_TOKEN_TITLE_PREFIX: &str = "DSM_USAGE_TOKEN:"; + +pub fn capture_usage_token(app: &tauri::AppHandle, token: String) -> Result { + let value = token.trim().to_string(); + if value.is_empty() { + return Err("用量 Token 为空".to_string()); + } + let mut config = read_stored_config()?; + config.usage_token = Some(value); + write_stored_config(&config)?; + let app_config = to_app_config(config)?; + + if let Some(flag) = app.try_state::>() { + flag.store(true, Ordering::SeqCst); + } + + if let Some(window) = app.get_webview_window("login-sync") { + let _ = window.close(); + } + + let _ = app.emit("usage-token-captured", &app_config); + Ok(app_config) +} + +pub async fn verify_usage_token(token: &str, month: u32, year: u32) -> Result<(), String> { + let ua = USER_AGENT; + let url = + format!("https://platform.deepseek.com/api/v0/usage/amount?month={month}&year={year}"); + let resp = reqwest::Client::new() + .get(&url) + .bearer_auth(token) + .header("x-app-version", "1.0.0") + .header("Accept", "*/*") + .header("User-Agent", ua) + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .send() + .await + .map_err(|error| format!("验证 token 失败:{error}"))?; + if resp.status().as_u16() == 200 { + Ok(()) + } else { + Err(format!("token 无效:HTTP {}", resp.status().as_u16())) + } +} + +// ─── WebView 缓存扫描 ──────────────────────────────────── + +fn read_shared_text(path: &Path) -> Option { + let mut file = fs::OpenOptions::new() + .read(true) + // SHARE_READ | SHARE_WRITE | SHARE_DELETE + .share_mode(0x1 | 0x2 | 0x4) + .open(path) + .ok()?; + let metadata = file.metadata().ok()?; + if metadata.len() == 0 || metadata.len() > 20 * 1024 * 1024 { + return None; + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut bytes).ok()?; + Some(String::from_utf8_lossy(&bytes).replace('\0', "")) +} + +fn extract_user_api_token(text: &str) -> Option { + let mut search_from = 0; + let marker = "\"token\":\""; + while let Some(relative_index) = text[search_from..].find(marker) { + let token_start = search_from + relative_index + marker.len(); + let token_end = token_start + text[token_start..].find('"')?; + let token = &text[token_start..token_end]; + let context_end = (token_end + 1800).min(text.len()); + let context = &text[token_end..context_end]; + if token.len() > 20 + && context.contains("\"id_profile\"") + && context.contains("\"feature_gates\"") + { + return Some(token.to_string()); + } + search_from = token_end + 1; + } + None +} + +pub fn find_webview_cached_usage_token() -> Option { + let local_app_data = std::env::var_os("LOCALAPPDATA")?; + let cache_dir = PathBuf::from(local_app_data) + .join("com.deepseek.monitor.windows") + .join("EBWebView") + .join("Default") + .join("Cache") + .join("Cache_Data"); + let entries = fs::read_dir(cache_dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(text) = read_shared_text(&path) { + if let Some(token) = extract_user_api_token(&text) { + return Some(token); + } + } + } + None +} + +// ─── WebView Token 同步 ────────────────────────────────── + +pub fn start_usage_title_watcher(app: tauri::AppHandle) { + thread::spawn(move || { + thread::sleep(Duration::from_secs(3)); + for _i in 0..600 { + if let Some(token) = find_webview_cached_usage_token() { + let _ = capture_usage_token(&app, token); + return; + } + + let Some(window) = app.get_webview_window("login-sync") else { + let captured = app + .try_state::>() + .map(|flag| flag.load(Ordering::SeqCst)) + .unwrap_or(false); + if !captured { + let _ = app.emit("usage-sync-ended", ()); + } + return; + }; + + if let Ok(title) = window.title() { + if let Some(rest) = title.strip_prefix(USAGE_TOKEN_TITLE_PREFIX) { + let mut parts = rest.splitn(3, ':'); + if let (Some(y), Some(m), Some(tok)) = + (parts.next(), parts.next(), parts.next()) + { + if let (Ok(year), Ok(month)) = (y.parse::(), m.parse::()) { + let token = tok.to_string(); + let verified = tauri::async_runtime::block_on( + verify_usage_token(&token, month, year), + ); + if verified.is_ok() { + let _ = capture_usage_token(&app, token); + return; + } + } + } + } + } + + thread::sleep(Duration::from_millis(1500)); + } + let captured = app + .try_state::>() + .map(|flag| flag.load(Ordering::SeqCst)) + .unwrap_or(false); + if !captured { + let _ = app.emit("usage-sync-ended", ()); + } + }); +} + +/// 在登录窗口注入的 JS:hook fetch / XMLHttpRequest,从 Authorization 头抓 Bearer token。 +pub const USAGE_SYNC_POLL_JS: &str = r#" +(function() { + if (window.__dsm_token_hook__) return; + window.__dsm_token_hook__ = true; + var done = false; + var pending = false; + + function deliver(token) { + if (done) return; + if (!token || typeof token !== 'string') return; + token = token.trim(); + if (token.length < 20) return; + var now = new Date(); + var y = now.getFullYear(); + var m = now.getMonth() + 1; + try { document.title = 'DSM_USAGE_TOKEN:' + y + ':' + m + ':' + token; } catch (e) {} + try { + if (!pending && window.__TAURI__ && window.__TAURI__.core) { + pending = true; + window.__TAURI__.core.invoke('usage_token_captured', { + token: token, month: m, year: y + }).then(function() { done = true; }).catch(function() { pending = false; }); + } + } catch (e) {} + } + + function fromAuth(value) { + if (!value) return; + var m = /Bearer\s+(\S+)/i.exec(String(value)); + if (m && m[1]) deliver(m[1]); + } + + var origFetch = window.fetch; + if (typeof origFetch === 'function') { + window.fetch = function(input, init) { + try { + var headers = (init && init.headers) || (input && input.headers); + if (headers) { + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + fromAuth(headers.get('authorization')); + } else if (Array.isArray(headers)) { + for (var i = 0; i < headers.length; i++) { + if (headers[i] && String(headers[i][0]).toLowerCase() === 'authorization') { + fromAuth(headers[i][1]); + } + } + } else if (typeof headers === 'object') { + for (var k in headers) { + if (k.toLowerCase() === 'authorization') fromAuth(headers[k]); + } + } + } + } catch (e) {} + return origFetch.apply(this, arguments); + }; + } + + var origSet = XMLHttpRequest.prototype.setRequestHeader; + XMLHttpRequest.prototype.setRequestHeader = function(name, value) { + try { + if (name && String(name).toLowerCase() === 'authorization') fromAuth(value); + } catch (e) {} + return origSet.apply(this, arguments); + }; +})(); +"#; + +pub fn start_usage_sync(app: &tauri::AppHandle) -> Result { + if let Some(flag) = app.try_state::>() { + flag.store(false, Ordering::SeqCst); + } + + if let Some(token) = find_webview_cached_usage_token() { + capture_usage_token(app, token)?; + return Ok(true); + } + + if app.get_webview_window("login-sync").is_some() { + if let Some(window) = app.get_webview_window("login-sync") { + let _ = window.eval("location.reload();"); + } + return Ok(false); + } + + let url = tauri::WebviewUrl::External("https://platform.deepseek.com".parse().map_err(|_| "无效 URL".to_string())?); + tauri::WebviewWindowBuilder::new(app, "login-sync", url) + .title("DeepSeek 账号登录") + .inner_size(480.0, 720.0) + .min_inner_size(360.0, 480.0) + .resizable(true) + .center() + .visible(true) + .initialization_script(USAGE_SYNC_POLL_JS) + .on_navigation(|url| { + // Restrict navigation to DeepSeek domains only + url.host_str() + .is_some_and(|host| host == "platform.deepseek.com" || host == "chat.deepseek.com") + }) + .on_page_load(|window, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) + && payload + .url() + .host_str() + .is_some_and(|host| host == "platform.deepseek.com") + { + let _ = window.eval(USAGE_SYNC_POLL_JS); + } + }) + .build() + .map_err(|error| format!("打开登录窗口失败:{error}"))?; + start_usage_title_watcher(app.clone()); + Ok(false) +} + +pub async fn do_usage_token_captured( + app: &tauri::AppHandle, + token: String, + month: u32, + year: u32, +) -> Result { + let value = token.trim().to_string(); + if value.is_empty() { + return Err("用量 Token 为空".to_string()); + } + verify_usage_token(&value, month, year).await?; + capture_usage_token(app, value) +} + +// ─── 用量查询 ──────────────────────────────────────────── + +pub async fn do_fetch_usage(month: u32, year: u32) -> Result { + let config = read_stored_config()?; + let token = config + .usage_token + .filter(|value| !value.is_empty()) + .ok_or_else(|| "未配置用量 Token".to_string())?; + + #[derive(Deserialize)] + struct Entry { + #[serde(rename = "type")] + kind: String, + amount: String, + } + #[derive(Deserialize)] + struct ModelUsage { + model: String, + usage: Vec, + } + #[derive(Deserialize)] + struct DayUsage { + date: String, + data: Vec, + } + #[derive(Deserialize)] + struct AmountBiz { + total: Vec, + days: Vec, + } + #[derive(Deserialize)] + struct AmountData { + biz_data: AmountBiz, + } + #[derive(Deserialize)] + struct AmountResp { + data: AmountData, + } + #[derive(Deserialize)] + struct CostBiz { + total: Vec, + days: Vec, + } + #[derive(Deserialize)] + struct CostData { + biz_data: Vec, + } + #[derive(Deserialize)] + struct CostResp { + data: CostData, + } + + async fn get_json( + client: &reqwest::Client, + url: &str, + token: &str, + ) -> Result { + let ua = USER_AGENT; + let resp = client + .get(url) + .bearer_auth(token) + .header("x-app-version", "1.0.0") + .header("Accept", "*/*") + .header("User-Agent", ua) + .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .send() + .await + .map_err(|error| format!("用量请求失败:{error}"))?; + match resp.status().as_u16() { + 200 => {} + 401 => return Err("用量 Token 无效或已过期,请重新获取".to_string()), + 429 => return Err("请求过于频繁,请稍后再试".to_string()), + code => return Err(format!("用量接口错误:HTTP {code}")), + } + resp.json::() + .await + .map_err(|error| format!("解析用量数据失败:{error}")) + } + + fn token_breakdown(usage: &[Entry]) -> (u64, u64, u64, u64, u64) { + let mut total = 0u64; + let mut request = 0u64; + let mut hit = 0u64; + let mut miss = 0u64; + let mut response = 0u64; + for entry in usage { + let value = entry.amount.parse::().unwrap_or(0.0).max(0.0).min(u64::MAX as f64).round() as u64; + match entry.kind.as_str() { + "REQUEST" => request = value, + "PROMPT_CACHE_HIT_TOKEN" => { + hit = value; + total += value; + } + "PROMPT_CACHE_MISS_TOKEN" => { + miss = value; + total += value; + } + "RESPONSE_TOKEN" => { + response = value; + total += value; + } + "PROMPT_TOKEN" => total += value, + _ => {} + } + } + (total, request, hit, miss, response) + } + + fn cost_sum(usage: &[Entry]) -> f64 { + usage + .iter() + .filter(|entry| entry.kind != "REQUEST") + .map(|entry| entry.amount.parse::().unwrap_or(0.0)) + .sum() + } + + let client = reqwest::Client::new(); + let amount_url = + format!("https://platform.deepseek.com/api/v0/usage/amount?month={month}&year={year}"); + let cost_url = + format!("https://platform.deepseek.com/api/v0/usage/cost?month={month}&year={year}"); + + let amount: AmountResp = get_json(&client, &amount_url, &token).await?; + let cost: CostResp = get_json(&client, &cost_url, &token).await?; + + let cost_total = cost.data.biz_data.first(); + let cost_for_model = |model: &str| -> f64 { + cost_total + .and_then(|item| item.total.iter().find(|m| m.model == model)) + .map(|m| cost_sum(&m.usage)) + .unwrap_or(0.0) + }; + + let mut models = Vec::new(); + for model_usage in &amount.data.biz_data.total { + let label = match model_usage.model.as_str() { + "deepseek-v4-flash" => Some(("flash", "V4 Flash")), + "deepseek-v4-pro" => Some(("pro", "V4 Pro")), + _ => None, + }; + if let Some((key, name)) = label { + let (total, request, hit, miss, response) = token_breakdown(&model_usage.usage); + models.push(UsageModelSummary { + key: key.to_string(), + name: name.to_string(), + total_tokens: total, + request_count: request, + cache_hit_tokens: hit, + cache_miss_tokens: miss, + response_tokens: response, + cost: cost_for_model(&model_usage.model), + }); + } + } + + let mut cost_by_date: std::collections::HashMap = + std::collections::HashMap::new(); + if let Some(item) = cost_total { + for day in &item.days { + let day_cost: f64 = day.data.iter().map(|m| cost_sum(&m.usage)).sum(); + cost_by_date.insert(day.date.clone(), day_cost); + } + } + + let mut days = Vec::new(); + for day in &amount.data.biz_data.days { + let mut flash = 0u64; + let mut flash_hit = 0u64; + let mut flash_miss = 0u64; + let mut flash_resp = 0u64; + let mut pro = 0u64; + let mut pro_hit = 0u64; + let mut pro_miss = 0u64; + let mut pro_resp = 0u64; + let mut total = 0u64; + for model_usage in &day.data { + let (tokens, _, hit, miss, response) = token_breakdown(&model_usage.usage); + total += tokens; + match model_usage.model.as_str() { + "deepseek-v4-flash" => { + flash += tokens; + flash_hit += hit; + flash_miss += miss; + flash_resp += response; + } + "deepseek-v4-pro" => { + pro += tokens; + pro_hit += hit; + pro_miss += miss; + pro_resp += response; + } + _ => {} + } + } + days.push(UsageDaySummary { + date: day.date.clone(), + flash_tokens: flash, + flash_cache_hit: flash_hit, + flash_cache_miss: flash_miss, + flash_response: flash_resp, + pro_tokens: pro, + pro_cache_hit: pro_hit, + pro_cache_miss: pro_miss, + pro_response: pro_resp, + total_tokens: total, + total_cost: cost_by_date.get(&day.date).copied().unwrap_or(0.0), + }); + } + + let month_cost: f64 = cost_total + .map(|item| item.total.iter().map(|m| cost_sum(&m.usage)).sum()) + .unwrap_or(0.0); + + Ok(UsageResult { + models, + days, + month_cost, + }) +} diff --git a/src-tauri/src/modules/mimo.rs b/src-tauri/src/modules/mimo.rs new file mode 100644 index 0000000..7bd253a --- /dev/null +++ b/src-tauri/src/modules/mimo.rs @@ -0,0 +1,791 @@ +//! MiMo API 模块 +//! +//! 职责:WebView 代理、余额查询、用量查询、detail 提取、ph 管理。 +//! 架构:通过 WebView2 的 JS eval 执行 fetch 调用,利用 HttpOnly Cookie 实现认证。 + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use serde::Deserialize; +use tokio::sync::oneshot; + +const POLL_TIMEOUT_SECS: u64 = 30; +const LOG_TRUNCATE_LEN: usize = 500; +use tauri::{Emitter, Manager}; + +use crate::modules::types::{ + CallbackServerPort, MimoBalanceResult, MimoUsageDay, MimoUsageDayModel, MimoUsageModel, + MimoUsageResult, UsageDetailItem, +}; +use crate::modules::config::{read_stored_config, write_stored_config}; + +// ─── MiMo SPA 拦截脚本 ────────────────────────────────── + +/// 在页面脚本运行前注入,捕获 api-platform_ph 和 detail 响应 +pub const MIMO_INTERCEPT_JS: &str = r#" + (function() { + if (window.__mimo_hooked) return; + window.__mimo_ph = null; + window.__mimo_detail = null; + var ALLOWED = ['platform.xiaomimimo.com']; + function isAllowed(u) { try { return ALLOWED.indexOf(new URL(u, location.href).hostname) !== -1; } catch(e) { return false; } } + // 主动扫描 ph + function __extractPh() { + if (window.__mimo_ph) return window.__mimo_ph; + try { var v = localStorage.getItem('mimo_platform_ph'); if (v) { window.__mimo_ph = v; return v; } } catch(e) {} + try { var c = document.cookie.match(/(?:api-platform_ph|platform_ph)=([^;]+)/); if (c) { window.__mimo_ph = decodeURIComponent(c[1]); return window.__mimo_ph; } } catch(e) {} + try { var h = document.documentElement ? (document.documentElement.innerHTML || '') : ''; var m = h.match(/api-platform_ph[=:]["']?([^'"&\s,}]+)/); if (m) { window.__mimo_ph = decodeURIComponent(m[1]); return window.__mimo_ph; } } catch(e) {} + return null; + } + __extractPh(); + // Hook fetch + var __of = window.fetch; + window.fetch = function() { + var u = typeof arguments[0] === 'string' ? arguments[0] : (arguments[0] && arguments[0].url ? arguments[0].url : ''); + if (isAllowed(u) && u.indexOf('api-platform_ph=') !== -1) { + var m = u.match(/api-platform_ph=([^&]+)/); + if (m) { window.__mimo_ph = decodeURIComponent(m[1]); try { localStorage.setItem('mimo_platform_ph', window.__mimo_ph); } catch(e) {} } + if (u.indexOf('/usage/detail/list') !== -1) { + return __of.apply(this, arguments).then(function(r) { return r.clone().text().then(function(t) { window.__mimo_detail = t; return r; }).catch(function() { return r; }); }); + } + } + return __of.apply(this, arguments); + }; + // Hook XMLHttpRequest + var __oo = XMLHttpRequest.prototype.open; + var __os = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(m, u) { this.__mu = u; return __oo.apply(this, arguments); }; + XMLHttpRequest.prototype.send = function() { + var u = this.__mu || ''; + if (isAllowed(u) && u.indexOf('api-platform_ph=') !== -1) { + var m = u.match(/api-platform_ph=([^&]+)/); + if (m) { window.__mimo_ph = decodeURIComponent(m[1]); try { localStorage.setItem('mimo_platform_ph', window.__mimo_ph); } catch(e) {} } + if (u.indexOf('/usage/detail/list') !== -1) this.addEventListener('load', function() { window.__mimo_detail = this.responseText; }); + } + return __os.apply(this, arguments); + }; + // 定期扫描 ph + setInterval(function() { if (!window.__mimo_ph) __extractPh(); }, 1000); + window.__mimo_hooked = true; + })(); +"#; + +// ─── WebView 管理 ──────────────────────────────────────── + +use std::sync::Mutex as StdMutex; +static MIMO_WEBVIEW_LOCK: StdMutex<()> = StdMutex::new(()); + +pub fn ensure_mimo_webview_sync(app: &tauri::AppHandle) -> Result { + // 快速路径:窗口已存在,无需持锁 + if let Some(window) = app.get_webview_window("mimo-sync") { + return Ok(window); + } + // 仅在创建窗口时持锁 + let _guard = MIMO_WEBVIEW_LOCK.lock().unwrap(); + // 双重检查:等锁期间可能已被另一线程创建 + if let Some(window) = app.get_webview_window("mimo-sync") { + return Ok(window); + } + let url = tauri::WebviewUrl::External( + "https://platform.xiaomimimo.com/console/balance" + .parse() + .map_err(|_| "无效 URL".to_string())?, + ); + tauri::WebviewWindowBuilder::new(app, "mimo-sync", url) + .title("小米 MiMo 控制台") + .inner_size(480.0, 720.0) + .min_inner_size(360.0, 480.0) + .resizable(true) + .center() + .visible(false) + .on_navigation(|url| { + url.host_str().is_some_and(|host| { + host == "platform.xiaomimimo.com" + || host == "account.xiaomi.com" + || host == "xiaomimimo.com" + }) + }) + .initialization_script(MIMO_INTERCEPT_JS) + .build() + .map_err(|error| format!("打开 MiMo 页面失败:{error}")) +} + +// ─── 通用 API 调用 ────────────────────────────────────── + +pub async fn fetch_mimo_api( + app: &tauri::AppHandle, + path: &str, + timeout_secs: u64, +) -> Result { + fetch_mimo_api_with_method(app, path, "GET", timeout_secs).await +} + +pub async fn fetch_mimo_api_with_method( + app: &tauri::AppHandle, + path: &str, + method: &str, + timeout_secs: u64, +) -> Result { + let lock_guard = app.state::>>(); + let _lock = lock_guard.lock().await; + + let window = ensure_mimo_webview_sync(app)?; + + let cb_port = app + .state::>() + .lock() + .unwrap() + .0; + + let req_id = format!( + "__mimo_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + + let (tx, rx) = oneshot::channel(); + { + let state = app.state::>>>>(); + let mut map = state.lock().unwrap(); + map.insert(req_id.clone(), tx); + } + + let api_url = format!("https://platform.xiaomimimo.com{}", path); + // Use serde_json::to_string for proper JS string escaping of the URL + let safe_url = serde_json::to_string(&api_url).unwrap_or_else(|_| "\"\"".to_string()); + let safe_req_id = serde_json::to_string(&req_id).unwrap_or_else(|_| "\"\"".to_string()); + let safe_method = serde_json::to_string(method).unwrap_or_else(|_| "\"GET\"".to_string()); + let js = format!( + r#"(async function() {{ + try {{ + var r = await fetch({safe_url}, {{ + method: {safe_method}, + credentials: 'include', + headers: {{ 'Accept': 'application/json' }} + }}); + var t = await r.text(); + fetch('http://127.0.0.1:{port}/mimo-callback', {{ + method: 'POST', + mode: 'cors', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ reqId: {safe_req_id}, data: t }}) + }}); + }} catch(e) {{ + fetch('http://127.0.0.1:{port}/mimo-callback', {{ + method: 'POST', + mode: 'cors', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ reqId: {safe_req_id}, data: 'ERROR:' + e.message }}) + }}); + }} + }})()"#, + port = cb_port, + ); + window + .eval(&js) + .map_err(|e| format!("注入脚本失败:{e}"))?; + + let timeout = std::time::Duration::from_secs(timeout_secs); + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(data)) => { + if data.starts_with("ERROR:") { + return Err(format!("MiMo API 请求失败:{}", &data[6..])); + } + if data.is_empty() || data.starts_with('<') { + return Err("MiMo API 返回为空或 HTML,请确认已登录".to_string()); + } + if let Ok(val) = serde_json::from_str::(&data) { + if val.get("code").and_then(|v| v.as_i64()) == Some(401) { + let login_url = val + .get("loginUrl") + .and_then(|v| v.as_str()) + .or_else(|| { + val.get("data") + .and_then(|d| d.get("loginUrl")) + .and_then(|v| v.as_str()) + }); + if let Some(url) = login_url { + // Use serde_json::to_string for proper JS string escaping + let safe_url = serde_json::to_string(url).unwrap_or_default(); + let _ = window.eval(&format!( + "window.location.href={}", + safe_url + )); + } else { + let _ = window.eval("window.location.href='https://account.xiaomi.com/pass/serviceLogin?sid=platform.xiaomimimo.com'"); + } + let _ = app.emit("mimo-auth-required", ()); + return Err("MiMo 未登录,请在弹出的窗口中完成登录后重试".to_string()); + } + } + Ok(data) + } + Ok(Err(_)) => Err("数据接收通道关闭".to_string()), + Err(_) => { + let state = + app.state::>>>>(); + let mut map = state.lock().unwrap(); + map.remove(&req_id); + Err("MiMo API 请求超时".to_string()) + } + } +} + +fn parse_mimo_api_response(json: &str) -> Result { + #[derive(Deserialize)] + struct ApiEnvelope { + code: i32, + #[serde(default)] + #[allow(dead_code)] + message: String, + data: Option, + } + let envelope: ApiEnvelope = + serde_json::from_str(json).map_err(|e| format!("解析响应失败:{e}"))?; + if envelope.code != 0 { + return Err(format!( + "MiMo API 返回错误 code={}: {}", + envelope.code, envelope.message + )); + } + envelope + .data + .ok_or_else(|| "MiMo API 返回空数据".to_string()) +} + +// ─── 余额查询 ──────────────────────────────────────────── + +pub async fn do_fetch_mimo_balance(app: &tauri::AppHandle) -> Result { + let json = fetch_mimo_api(app, "/api/v1/balance", 15).await?; + log::debug!("[MiMo] /api/v1/balance response received ({} chars)", json.len()); + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct BalanceDataV1 { + #[allow(dead_code)] + #[serde(default)] + balance: String, + #[allow(dead_code)] + #[serde(default)] + frozen_balance: String, + #[serde(default)] + currency: String, + #[serde(default)] + cash_balance: String, + } + if let Ok(data) = parse_mimo_api_response::(&json) { + log::info!( + "[MiMo] balance V1 parsed: cash_balance={} currency={}", + data.cash_balance, + data.currency + ); + return Ok(MimoBalanceResult { + available_balance: data.cash_balance, + currency: if data.currency.is_empty() { + "CNY".to_string() + } else { + data.currency + }, + total_consumption: "—".to_string(), + monthly_expense: "—".to_string(), + }); + } + log::warn!("[MiMo] balance V1 parse failed, trying AccountOverview"); + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct CostUsageData { + #[serde(default)] + total_cost: String, + #[serde(default)] + current_month_cost: String, + } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct AccountOverview { + #[serde(default)] + cost_usage: Option, + } + if let Ok(overview) = parse_mimo_api_response::(&json) { + if let Some(cost) = overview.cost_usage { + log::info!( + "[MiMo] balance AccountOverview parsed: total_cost={} month_cost={}", + cost.total_cost, + cost.current_month_cost + ); + return Ok(MimoBalanceResult { + available_balance: cost.total_cost.clone(), + currency: "CNY".to_string(), + total_consumption: cost.total_cost, + monthly_expense: cost.current_month_cost, + }); + } + } + log::warn!("[MiMo] balance AccountOverview parse also failed"); + + Err("无法解析 MiMo 余额接口返回的数据".to_string()) +} + +// ─── 用量查询 ──────────────────────────────────────────── + +pub async fn do_fetch_mimo_usage( + app: &tauri::AppHandle, + _month: u32, + _year: u32, +) -> Result { + let overview_json = fetch_mimo_api(app, "/api/v1/usage", 15).await?; + log::debug!( + "[MiMo] /api/v1/usage response ({} bytes)", + overview_json.len() + ); + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct TokenUsageData { + #[serde(default)] + input_token: u64, + #[serde(default)] + output_token: u64, + #[serde(default)] + cache_token: u64, + #[serde(default)] + total_token: u64, + } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct CostUsageData { + #[serde(default)] + total_cost: String, + #[serde(default)] + current_month_cost: String, + } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct UsageOverview { + #[serde(default)] + token_usage: Option, + #[serde(default)] + cost_usage: Option, + } + + let overview = parse_mimo_api_response::(&overview_json)?; + log::info!( + "[MiMo] overview parsed: token_usage={:?}, cost_usage={:?}", + overview + .token_usage + .as_ref() + .map(|t| format!( + "input={} output={} cache={} total={}", + t.input_token, t.output_token, t.cache_token, t.total_token + )), + overview + .cost_usage + .as_ref() + .map(|c| format!("total={} month={}", c.total_cost, c.current_month_cost)) + ); + let month_cost = overview + .cost_usage + .as_ref() + .and_then(|c| c.current_month_cost.parse::().ok()) + .unwrap_or(0.0); + + // 尝试获取详细用量(按模型+日期分解) + let detail_items = { + let cache = app.state::>(); + let cached = cache + .lock() + .unwrap() + .get(std::time::Duration::from_secs(300)); + match cached { + Some(items) if !items.is_empty() => Some(items), + Some(_) => None, + None => { + let can_start = cache.lock().unwrap().mark_in_progress(); + if !can_start { + log::info!("[MiMo] detail extraction already in progress, skipping"); + return Ok(MimoUsageResult { + models: vec![], + days: vec![], + month_cost, + }); + } + match fetch_mimo_usage_detail(app).await { + Ok(items) if !items.is_empty() => { + cache.lock().unwrap().set(items.clone()); + Some(items) + } + _ => { + cache.lock().unwrap().clear_in_progress(); + None + } + } + } + } + }; + log::info!( + "[MiMo] detail_items count: {}", + detail_items.as_ref().map(|v| v.len()).unwrap_or(0) + ); + + match detail_items { + Some(items) if !items.is_empty() => { + let mut models_map: std::collections::HashMap = + std::collections::HashMap::new(); + let mut days_map: std::collections::HashMap< + String, + ( + MimoUsageDay, + std::collections::HashMap, + ), + > = std::collections::HashMap::new(); + let mut detail_month_cost: f64 = 0.0; + + for item in &items { + let model_entry = + models_map + .entry(item.model.clone()) + .or_insert_with(|| MimoUsageModel { + key: item.model.clone(), + name: item.model.clone(), + total_tokens: 0, + request_count: 0, + cache_hit_tokens: 0, + cache_miss_tokens: 0, + response_tokens: 0, + cost: 0.0, + }); + model_entry.total_tokens += item.total_token; + model_entry.request_count += item.request_count; + model_entry.cache_hit_tokens += item.input_hit_token; + model_entry.cache_miss_tokens += item.input_miss_token; + model_entry.response_tokens += item.output_token; + model_entry.cost += item.consumed_amount.parse::().unwrap_or(0.0); + + let (day_entry, day_models) = + days_map + .entry(item.date.clone()) + .or_insert_with(|| { + ( + MimoUsageDay { + date: item.date.clone(), + total_tokens: 0, + total_cost: 0.0, + models: vec![], + }, + std::collections::HashMap::new(), + ) + }); + day_entry.total_tokens += item.total_token; + day_entry.total_cost += item.consumed_amount.parse::().unwrap_or(0.0); + let day_model = + day_models + .entry(item.model.clone()) + .or_insert_with(|| MimoUsageDayModel { + key: item.model.clone(), + total_tokens: 0, + cache_hit_tokens: 0, + cache_miss_tokens: 0, + response_tokens: 0, + total_cost: 0.0, + }); + day_model.total_tokens += item.total_token; + day_model.cache_hit_tokens += item.input_hit_token; + day_model.cache_miss_tokens += item.input_miss_token; + day_model.response_tokens += item.output_token; + day_model.total_cost += item.consumed_amount.parse::().unwrap_or(0.0); + detail_month_cost += item.consumed_amount.parse::().unwrap_or(0.0); + } + + let mut days: Vec = Vec::new(); + for (_, (mut day, models_map)) in days_map { + day.models = models_map.into_values().collect(); + days.push(day); + } + days.sort_by(|a, b| a.date.cmp(&b.date)); + let models: Vec = models_map.into_values().collect(); + let result = MimoUsageResult { + models, + days, + month_cost: if detail_month_cost > 0.0 { + detail_month_cost + } else { + month_cost + }, + }; + log::info!( + "[MiMo] usage result (detail): {} models, {} days, month_cost={}", + result.models.len(), + result.days.len(), + result.month_cost + ); + for m in &result.models { + log::info!( + "[MiMo] model: key={} tokens={} cost={}", + m.key, + m.total_tokens, + m.cost + ); + } + for d in &result.days { + log::info!( + "[MiMo] day: date={} tokens={} cost={} models={}", + d.date, + d.total_tokens, + d.total_cost, + d.models.len() + ); + } + Ok(result) + } + _ => { + // fallback:总用量概览(detail API 不可用时) + log::info!("[MiMo] detail API unavailable, using overview fallback"); + let mut models = Vec::new(); + if let Some(tokens) = &overview.token_usage { + if tokens.input_token > 0 { + models.push(MimoUsageModel { + key: "mimo-v2.5-pro".to_string(), + name: "MiMo-V2.5-Pro".to_string(), + total_tokens: tokens.input_token + tokens.output_token, + request_count: 0, + cache_hit_tokens: tokens.cache_token, + cache_miss_tokens: tokens + .input_token + .saturating_sub(tokens.cache_token), + response_tokens: tokens.output_token, + cost: 0.0, + }); + } + } + Ok(MimoUsageResult { + models, + days: vec![], + month_cost, + }) + } + } +} + +// ─── Detail 提取 ───────────────────────────────────────── + +fn parse_detail_items(json: &str) -> Result, String> { + log::info!( + "[MiMo] parse_detail_items raw (first 1000): {}", + &json[..json.len().min(1000)] + ); + #[derive(Deserialize)] + struct R { + #[serde(default)] + code: i32, + #[serde(default)] + data: Option>, + } + let r: R = serde_json::from_str(json).map_err(|e| { + log::warn!("[MiMo] parse_detail_items error: {}", e); + e.to_string() + })?; + if r.code != 0 { + return Err(format!("code={}", r.code)); + } + let items = r.data.unwrap_or_default(); + log::debug!("[MiMo] parse_detail_items: {} items parsed", items.len()); + Ok(items) +} + +async fn fetch_mimo_usage_detail( + app: &tauri::AppHandle, +) -> Result, String> { + // 1. 先用缓存的 ph 尝试直接调用 API(快速路径) + { + let config = read_stored_config()?; + if let Some(ref ph) = config.mimo_ph { + log::debug!("[MiMo] detail: trying cached ph"); + let api_url = format!("/api/v1/usage/detail/list?api-platform_ph={}", ph); + if let Ok(json) = fetch_mimo_api_with_method(app, &api_url, "POST", 10).await { + log::info!( + "[MiMo] detail fast-path response (first 500): {}", + &json[..json.len().min(LOG_TRUNCATE_LEN)] + ); + if let Ok(items) = parse_detail_items(&json) { + if !items.is_empty() { + log::info!("[MiMo] detail fast-path OK: {} items", items.len()); + return Ok(items); + } + } + if json.contains("\"code\":401") { + log::warn!("[MiMo] detail fast-path 401, clearing cached ph"); + if let Ok(mut config) = read_stored_config() { + config.mimo_ph = None; + let _ = write_stored_config(&config); + } + } + } else { + log::warn!( + "[MiMo] detail fast-path API call failed, clearing cached ph" + ); + if let Ok(mut config) = read_stored_config() { + config.mimo_ph = None; + let _ = write_stored_config(&config); + } + } + log::info!("[MiMo] detail fast-path failed, falling back to page extraction"); + } + } + + // 2. 缓存的 ph 失效或不存在 → 导航到用量页面 + let lock_guard = app.state::>>(); + let _lock = lock_guard.lock().await; + + let window = ensure_mimo_webview_sync(app)?; + + // 复用主 CallbackServer 端口,不再创建独立 HTTP 服务器 + let cb_port = app.state::>().lock().unwrap().0; + + log::info!("[MiMo] detail: navigating to usage page (on_page_load hook active)"); + let _ = window.eval("window.__mimo_detail = null; window.__mimo_ph = null;"); + let usage_url: tauri::Url = "https://platform.xiaomimimo.com/console/usage" + .parse() + .map_err(|_| "无效 URL".to_string())?; + let _ = window.navigate(usage_url); + + let start = std::time::Instant::now(); + let mut auth_401_count = 0u32; + while start.elapsed() < std::time::Duration::from_secs(POLL_TIMEOUT_SECS) { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let req_id = format!( + "__chk_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let (tx, rx) = oneshot::channel(); + { + let state = + app.state::>>>>(); + let mut map = state.lock().unwrap(); + map.insert(req_id.clone(), tx); + } + + let check_js = format!( + r#"try{{(async()=>{{ + var d=window.__mimo_detail||null; + var ph=window.__mimo_ph||localStorage.getItem('mimo_platform_ph')||null; + if(d){{ + fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{req_id}',data:d}})}}); + }} else if(ph){{ + try{{var u='https://platform.xiaomimimo.com/api/v1/usage/detail/list?api-platform_ph='+encodeURIComponent(ph);var r=await fetch(u,{{method:'POST',credentials:'include',headers:{{'Accept':'application/json'}}}});var t=await r.text();fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{req_id}',data:t}})}});}}catch(e){{fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{req_id}',data:'ERR:'+e.message}})}});}} + }} else {{ + fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{req_id}',data:'WAITING'}})}}); + }} + }})()}}catch(e){{fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{req_id}',data:'EXC:'+e.message}})}});}}"#, + port = cb_port, req_id = req_id, + ); + let _ = window.eval(&check_js); + + if let Ok(Ok(data)) = + tokio::time::timeout(std::time::Duration::from_secs(5), rx).await + { + log::info!( + "[MiMo] detail check (first 200): {}", + &data[..data.len().min(200)] + ); + if data == "WAITING" { + log::info!("[MiMo] detail: waiting for hook to capture data..."); + continue; + } else if data.starts_with("ERR:") || data.starts_with("EXC:") { + log::warn!("[MiMo] detail error: {}", data); + continue; + } else if data.contains("\"code\":401") { + auth_401_count += 1; + if auth_401_count <= 2 { + log::info!( + "[MiMo] detail: 401 detected ({}/2), showing login window", + auth_401_count + ); + if let Some(w) = app.get_webview_window("mimo-sync") { + let _ = w.show(); + let _ = w.set_focus(); + } + let _ = app.emit("mimo-auth-required", ()); + } else { + log::info!( + "[MiMo] detail: 401 persisted after {} retries, giving up detail extraction", + auth_401_count + ); + } + continue; + } else if !data.is_empty() && !data.starts_with('<') { + if let Ok(items) = parse_detail_items(&data) { + if !items.is_empty() { + log::info!("[MiMo] detail OK: {} items", items.len()); + // 缓存 ph + let ph_req = format!("__ph_{}", req_id); + let (ptx, prx) = oneshot::channel(); + { + let state = app + .state::>>>>(); + let mut map = state.lock().unwrap(); + map.insert(ph_req.clone(), ptx); + } + let _ = window.eval(&format!( + r#"try{{var p=window.__mimo_ph||localStorage.getItem('mimo_platform_ph')||'';fetch('http://127.0.0.1:{port}/mimo-callback',{{method:'POST',mode:'cors',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{reqId:'{ph_req}',data:p}})}});}}catch(e){{}}"#, + port = cb_port, ph_req = ph_req, + )); + if let Ok(Ok(ph_val)) = + tokio::time::timeout(std::time::Duration::from_secs(2), prx).await + { + if !ph_val.is_empty() { + if let Ok(mut config) = read_stored_config() { + config.mimo_ph = Some(ph_val); + let _ = write_stored_config(&config); + } + } + } + return Ok(items); + } + } + } + } + } + + Err("无法获取用量详情,请确认已登录 MiMo".to_string()) +} + +// ─── Sync ──────────────────────────────────────────────── + +pub fn do_start_mimo_sync(app: &tauri::AppHandle) -> Result { + if let Some(window) = app.get_webview_window("mimo-sync") { + let _ = window.show(); + let _ = window.set_focus(); + return Ok(true); + } + let window = ensure_mimo_webview_sync(app)?; + let _ = window.show(); + let _ = window.set_focus(); + let _ = app.emit("mimo-sync-started", ()); + Ok(false) +} + +pub fn do_ensure_mimo_webview(app: &tauri::AppHandle) -> Result<(), String> { + ensure_mimo_webview_sync(app).map(|_| ()) +} + +pub fn do_mimo_api_response( + app: &tauri::AppHandle, + req_id: String, + json: String, +) -> Result<(), String> { + let state = + app.state::>>>>(); + let mut map = state.lock().unwrap(); + if let Some(tx) = map.remove(&req_id) { + let _ = tx.send(json); + } + Ok(()) +} diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs new file mode 100644 index 0000000..77244c0 --- /dev/null +++ b/src-tauri/src/modules/mod.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod deepseek; +pub mod mimo; +pub mod tray; +pub mod types; diff --git a/src-tauri/src/modules/tray.rs b/src-tauri/src/modules/tray.rs new file mode 100644 index 0000000..64444b7 --- /dev/null +++ b/src-tauri/src/modules/tray.rs @@ -0,0 +1,85 @@ +//! 窗口管理与系统托盘 +//! +//! 职责:窗口定位(右下角)、显示/隐藏、托盘图标初始化。 + +use tauri::{ + menu::{Menu, MenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Manager, PhysicalPosition, Position, WebviewWindow, +}; + +/// 将窗口定位到屏幕右下角(靠近托盘图标区域) +pub fn position_near_tray(window: &WebviewWindow) -> tauri::Result<()> { + let cursor = window.cursor_position()?; + let monitor = window + .monitor_from_point(cursor.x, cursor.y)? + .or(window.current_monitor()?) + .or(window.primary_monitor()?) + .ok_or_else(|| tauri::Error::WindowNotFound)?; + + let work_area = monitor.work_area(); + let scale_factor = monitor.scale_factor(); + let size = window.outer_size()?; + let margin = (12.0 * scale_factor).round() as i32; + let width = size.width as i32; + let height = size.height as i32; + let right = work_area.position.x + work_area.size.width as i32; + let bottom = work_area.position.y + work_area.size.height as i32; + let x = (right - width - margin).max(work_area.position.x); + let y = (bottom - height - margin).max(work_area.position.y); + + window.set_position(Position::Physical(PhysicalPosition::new(x, y))) +} + +/// 显示主窗口并定位到右下角 +pub fn show_main_window(window: &WebviewWindow) { + // 先显示窗口,确保可见 + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + // 定位到右下角(失败不影响显示) + let _ = position_near_tray(window); +} + +/// 初始化系统托盘图标和菜单 +pub fn setup_tray(app: &tauri::App) -> Result<(), Box> { + let show_item = MenuItem::with_id(app, "show", "显示主面板", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&show_item, &quit_item])?; + + let mut tray_builder = TrayIconBuilder::new() + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id().as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + show_main_window(&window); + } + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + // Always show on tray click — user hides via title bar button + let _ = window.unminimize(); + show_main_window(&window); + } + } + }); + + if let Some(icon) = app.default_window_icon() { + tray_builder = tray_builder.icon(icon.clone()); + } + + tray_builder.build(app)?; + Ok(()) +} diff --git a/src-tauri/src/modules/types.rs b/src-tauri/src/modules/types.rs new file mode 100644 index 0000000..1ee44a9 --- /dev/null +++ b/src-tauri/src/modules/types.rs @@ -0,0 +1,254 @@ +//! 共享数据结构定义 +//! +//! 所有模块共用的类型定义集中在此,避免循环依赖。 + +use serde::{Deserialize, Serialize}; + +// ─── 配置 ───────────────────────────────────────────────── + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct StoredConfig { + pub api_key: Option, + #[serde(default)] + pub usage_token: Option, + #[serde(default)] + pub provider: String, // "deepseek" | "mimo" + #[serde(default)] + pub mimo_token: Option, + #[serde(default)] + pub mimo_ph: Option, + pub refresh_interval_seconds: u64, + #[serde(default)] + pub auto_refresh_enabled: bool, + pub autostart: bool, + #[serde(default)] + pub window_width: Option, + #[serde(default)] + pub window_height: Option, + #[serde(default)] + pub window_x: Option, + #[serde(default)] + pub window_y: Option, + #[serde(default)] + pub low_balance_notify: bool, + #[serde(default)] + pub low_balance_threshold: f64, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_currency")] + pub currency: String, // "cny" | "usd" + #[serde(default = "default_efficiency_unit")] + pub efficiency_unit: String, // "token_per_currency" | "currency_per_token" + #[serde(default = "default_provider")] + pub default_provider: String, // "deepseek" | "mimo" + #[serde(default)] + pub mimo_refresh_interval_seconds: u64, // 0 = use global + #[serde(default = "default_notify_cooldown")] + pub notify_cooldown_minutes: u64, +} + +fn default_theme() -> String { "light".to_string() } +fn default_currency() -> String { "cny".to_string() } +fn default_efficiency_unit() -> String { "token_per_currency".to_string() } +fn default_provider() -> String { "deepseek".to_string() } +fn default_notify_cooldown() -> u64 { 30 } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + pub api_key_configured: bool, + pub api_key_preview: Option, + pub usage_token_configured: bool, + pub provider: String, + pub mimo_token_configured: bool, + pub refresh_interval_seconds: u64, + pub auto_refresh_enabled: bool, + pub autostart: bool, + pub config_path: String, + #[serde(default)] + pub low_balance_notify: bool, + #[serde(default)] + pub low_balance_threshold: f64, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_currency")] + pub currency: String, + #[serde(default = "default_efficiency_unit")] + pub efficiency_unit: String, + #[serde(default = "default_provider")] + pub default_provider: String, + #[serde(default)] + pub mimo_refresh_interval_seconds: u64, + #[serde(default = "default_notify_cooldown")] + pub notify_cooldown_minutes: u64, +} + +// ─── DeepSeek ───────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BalanceResult { + pub is_available: bool, + pub currency: String, + pub total_balance: String, + pub granted_balance: String, + pub topped_up_balance: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageModelSummary { + pub key: String, + pub name: String, + pub total_tokens: u64, + pub request_count: u64, + pub cache_hit_tokens: u64, + pub cache_miss_tokens: u64, + pub response_tokens: u64, + pub cost: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageDaySummary { + pub date: String, + pub flash_tokens: u64, + pub flash_cache_hit: u64, + pub flash_cache_miss: u64, + pub flash_response: u64, + pub pro_tokens: u64, + pub pro_cache_hit: u64, + pub pro_cache_miss: u64, + pub pro_response: u64, + pub total_tokens: u64, + pub total_cost: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageResult { + pub models: Vec, + pub days: Vec, + pub month_cost: f64, +} + +// ─── MiMo ───────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MimoBalanceResult { + pub available_balance: String, + pub currency: String, + pub total_consumption: String, + pub monthly_expense: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MimoUsageModel { + pub key: String, + pub name: String, + pub total_tokens: u64, + pub request_count: u64, + pub cache_hit_tokens: u64, + pub cache_miss_tokens: u64, + pub response_tokens: u64, + pub cost: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MimoUsageDayModel { + pub key: String, + pub total_tokens: u64, + pub cache_hit_tokens: u64, + pub cache_miss_tokens: u64, + pub response_tokens: u64, + pub total_cost: f64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MimoUsageDay { + pub date: String, + pub total_tokens: u64, + pub total_cost: f64, + pub models: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MimoUsageResult { + pub models: Vec, + pub days: Vec, + pub month_cost: f64, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UsageDetailItem { + #[serde(default)] + pub date: String, + #[serde(default)] + pub model: String, + #[serde(default)] + pub total_token: u64, + #[serde(default)] + pub input_hit_token: u64, + #[serde(default)] + pub input_miss_token: u64, + #[serde(default)] + pub output_token: u64, + #[serde(default)] + pub request_count: u64, + #[serde(default)] + pub consumed_amount: String, +} + +// ─── Callback Server ────────────────────────────────────── + +pub struct CallbackServerPort(pub u16); + +// ─── Detail Cache ───────────────────────────────────────── + +pub struct MimoDetailCache { + items: Option<(std::time::Instant, Vec)>, + in_progress: bool, +} + +impl MimoDetailCache { + pub fn new() -> Self { + Self { + items: None, + in_progress: false, + } + } + pub fn get(&self, max_age: std::time::Duration) -> Option> { + if self.in_progress { + return None; + } + self.items + .as_ref() + .and_then(|(ts, items)| { + if ts.elapsed() < max_age { + Some(items.to_vec()) + } else { + None + } + }) + } + pub fn set(&mut self, items: Vec) { + self.items = Some((std::time::Instant::now(), items)); + self.in_progress = false; + } + pub fn mark_in_progress(&mut self) -> bool { + if self.in_progress { + return false; + } + self.in_progress = true; + true + } + pub fn clear_in_progress(&mut self) { + self.in_progress = false; + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1ae87f1..71bc62e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "DeepSeekMonitorWindows", - "version": "1.1.0", + "version": "2.5.3", "identifier": "com.deepseek.monitor.windows", "build": { "frontendDist": "../dist", @@ -10,14 +10,18 @@ "beforeBuildCommand": "powershell -ExecutionPolicy Bypass -File scripts/build.ps1" }, "app": { - "withGlobalTauri": true, + "withGlobalTauri": false, "windows": [ { "label": "main", - "title": "DeepSeek Monitor Windows", - "width": 356, - "height": 600, - "resizable": false, + "title": "DeepSeek / MiMo Monitor", + "width": 463, + "height": 660, + "minWidth": 340, + "minHeight": 500, + "maxWidth": 700, + "maxHeight": 1200, + "resizable": true, "fullscreen": false, "decorations": false, "transparent": true, @@ -26,12 +30,13 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' https://asset.localhost https://tauri.localhost data:; connect-src 'self' http://127.0.0.1:* ipc: http://ipc.localhost https://api.github.com https://open.er-api.com" } }, "bundle": { "active": true, "targets": ["nsis"], + "createUpdaterArtifacts": true, "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -47,5 +52,16 @@ "android": { "debugApplicationIdSuffix": ".debug" } + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDExQjVCMzkwNThBQ0FBREIKUldUYnFxeFlrTE8xRVdrSVozK0ZjL3lyZ2ZpVUE4UllnWnU4TFBKQ3BJSjZtY25sUXg1R0hBeGcK", + "endpoints": [ + "https://github.com/HaoyueQin/DeepSeekMonitorWindows/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/src/components/DashboardPanel.tsx b/src/components/DashboardPanel.tsx new file mode 100644 index 0000000..e9f05b2 --- /dev/null +++ b/src/components/DashboardPanel.tsx @@ -0,0 +1,310 @@ +import React from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { BarChart3, Brain, CalendarDays, CreditCard, Settings, Shirt, SunMedium, X, Zap, RefreshCw } from "lucide-react"; +import type { Provider, BalanceData, MimoBalanceData, BalanceState, UsageResult, MimoUsageResult, MimoUsageModel, UsageModel, AppConfig } from "../types"; +import { fmtInt, fmtTokensShort, fmtMoney, mmdd, todayStr, dateKey, addDays, modelDisplayName, modelIcon } from "../utils"; + +// ─── BalanceCard ─────────────────────────────────────────── +export function BalanceCard({ balance, state, error, todayCost, monthCost, provider, currency, exchangeRate }: { + balance: BalanceData | MimoBalanceData | null; + state: BalanceState; + error: string; + todayCost: number | null; + monthCost: number | null; + provider: Provider; + currency?: "cny" | "usd"; + exchangeRate?: number; +}) { + const isDeepSeek = provider === "deepseek"; + const dsBalance = isDeepSeek ? (balance as BalanceData | null) : null; + const mimoBalance = !isDeepSeek ? (balance as MimoBalanceData | null) : null; + + const symbol = isDeepSeek + ? (dsBalance?.currency === "USD" ? "$" : "¥") + : (mimoBalance?.currency === "USD" ? "$" : "¥"); + const amount = + state === "loading" ? "查询中…" + : state === "nokey" ? "未配置" + : state === "error" ? "查询失败" + : isDeepSeek ? `${symbol}${dsBalance?.totalBalance ?? "0.00"}` + : `${symbol}${mimoBalance?.availableBalance ?? "0.00"}`; + const statusText = state === "ok" ? (isDeepSeek && dsBalance?.isAvailable === false ? "余额不足" : "可用") : "—"; + const statusOff = state === "ok" && isDeepSeek && dsBalance != null && !dsBalance.isAvailable; + + return ( +
+
+
账户余额
+
{statusText}
+
+
{amount}
+ {state === "error" &&
{error}
} +
+
+
当日消耗
+ {todayCost != null ? fmtMoney(todayCost, currency, exchangeRate) : "—"} +
+
+
本月消费
+ {monthCost != null ? fmtMoney(monthCost, currency, exchangeRate) : "—"} +
+
+
+ ); +} + +// ─── UsageRow ────────────────────────────────────────────── +export function UsageRow({ modelKey, data, maxTokens, state, onClick, modelDisplay, currency, exchangeRate, efficiencyUnit }: { + modelKey: string; + data: UsageModel | null; + maxTokens: number; + state: BalanceState; + onClick: () => void; + modelDisplay?: string; + currency?: "cny" | "usd"; + exchangeRate?: number; + efficiencyUnit?: "token_per_currency" | "currency_per_token"; +}) { + const isFlash = modelKey === "flash"; + const name = modelDisplay ?? (isFlash ? "V4 Flash" : "V4 Pro"); + const tokensText = data ? `${fmtInt(data.totalTokens)} Tokens` + : state === "loading" ? "查询中…" + : state === "nokey" ? "未配置 Token" + : state === "error" ? "用量不可用" : "—"; + const cost = data ? fmtMoney(data.cost, currency, exchangeRate) : "—"; + const sym = currency === "usd" ? "$" : "¥"; + const displayCost = currency === "usd" && exchangeRate && exchangeRate > 0 ? data ? data.cost * exchangeRate : 0 : data ? data.cost : 0; + const ratio = data && data.cost > 0 + ? efficiencyUnit === "token_per_currency" + ? `${(data.totalTokens / displayCost / 1_000_000).toFixed(2)} MT/${sym}` + : `${(displayCost * 1_000_000 / data.totalTokens).toFixed(3)} ${sym}/MT` + : "—"; + const width = data ? `${Math.max(2, (data.totalTokens / maxTokens) * 100)}%` : "0%"; + + return ( + + ); +} + +// ─── UsageChart ──────────────────────────────────────────── +export function UsageChart({ usage, state, error, provider, currency, exchangeRate, efficiencyUnit }: { + usage: UsageResult | MimoUsageResult | null; + state: BalanceState; + error: string; + provider: Provider; + currency?: "cny" | "usd"; + exchangeRate?: number; + efficiencyUnit?: "token_per_currency" | "currency_per_token"; +}) { + const [hoveredIdx, setHoveredIdx] = React.useState(null); + const [weekOffset, setWeekOffset] = React.useState(0); + const MIN_BAR = 3; + const DAYS_PER_WEEK = 7; + + const isDeepSeek = provider === "deepseek"; + const dsUsage = isDeepSeek ? (usage as UsageResult | null) : null; + const mimoUsage = !isDeepSeek ? (usage as MimoUsageResult | null) : null; + + const today = new Date(); + const weekStart = addDays(today, weekOffset * DAYS_PER_WEEK - DAYS_PER_WEEK + 1); + const days = Array.from({ length: DAYS_PER_WEEK }, (_, i) => dateKey(addDays(weekStart, i))); + const dsMap = new Map((dsUsage?.days ?? []).map((d) => [d.date, d])); + const mimoMap = new Map((mimoUsage?.days ?? []).map((d) => [d.date, d])); + + const points = days.map((date) => { + if (isDeepSeek) { + const d = dsMap.get(date); + if (!d) return { date, hit: 0, miss: 0, response: 0, total: 0, cost: 0 }; + const hit = d.flashCacheHit + d.proCacheHit; + const miss = d.flashCacheMiss + d.proCacheMiss; + const response = d.flashResponse + d.proResponse; + return { date, hit, miss, response, total: hit + miss + response, cost: d.totalCost }; + } else { + const d = mimoMap.get(date); + if (!d) return { date, hit: 0, miss: 0, response: 0, total: 0, cost: 0 }; + const hit = d.models.reduce((s, m) => s + m.cacheHitTokens, 0); + const miss = d.models.reduce((s, m) => s + m.cacheMissTokens, 0); + const response = d.models.reduce((s, m) => s + m.responseTokens, 0); + return { date, hit, miss, response, total: hit + miss + response, cost: d.totalCost }; + } + }); + + const maxVal = Math.max(...points.map((p) => p.total), 1); + const sumHit = points.reduce((s, p) => s + p.hit, 0); + const sumMiss = points.reduce((s, p) => s + p.miss, 0); + const sumTotal = points.reduce((s, p) => s + p.total, 0); + const hitRate = sumHit + sumMiss > 0 ? ((sumHit / (sumHit + sumMiss)) * 100).toFixed(3) : "0"; + const sym = currency === "usd" ? "$" : "¥"; + const sumCost = points.reduce((s, p) => s + p.cost, 0); + const displayCost = currency === "usd" && exchangeRate && exchangeRate > 0 ? sumCost * exchangeRate : sumCost; + const ratio = sumCost > 0 + ? efficiencyUnit === "token_per_currency" + ? `${(sumTotal / displayCost / 1_000_000).toFixed(2)} MT/${sym}` + : `${(displayCost * 1_000_000 / sumTotal).toFixed(3)} ${sym}/MT` + : "—"; + const canGoForward = weekOffset < 0; + const weekLabel = weekOffset === 0 ? "本周" : weekOffset === -1 ? "上周" : `${-weekOffset}周前`; + const placeholder = state === "loading" ? "查询中…" : state === "nokey" ? "未配置用量 Token" : state === "error" ? error : "暂无数据"; + + return ( +
+
+
缓存命中明细
+
+ + {weekLabel} + +
+ {state === "ok" ? `${hitRate}% · ${fmtTokensShort(sumTotal)} · ${ratio}` : "—"} +
+ {state === "ok" && points.length > 0 ? ( + <> +
setHoveredIdx(null)}> + {points.map((point, idx) => ( +
setHoveredIdx(idx)}> + {hoveredIdx === idx && ( +
= points.length - 2 ? " align-right" : ""}`}> +
{point.date}{fmtInt(point.total)} tokens
+ 输入(命中缓存){fmtInt(point.hit)} tokens + 输入(未命中缓存){fmtInt(point.miss)} tokens + 输出{fmtInt(point.response)} tokens + 缓存命中 {point.hit + point.miss > 0 ? ((point.hit / (point.hit + point.miss)) * 100).toFixed(3) : "0"}% + 平均单价 + {point.cost > 0 && point.total > 0 + ? efficiencyUnit === "token_per_currency" + ? `${(point.total / point.cost / 1_000_000).toFixed(2)} MT/${sym}` + : `${(point.cost * 1_000_000 / point.total).toFixed(3)} ${sym}/MT` + : "—"} + +
+ )} + {point.total > 0 ? fmtTokensShort(point.total) : "0"} +
+
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%` }}> + {point.total > 0 ? ( + <> + {point.hit > 0 && } + {point.miss > 0 && } + {point.response > 0 && } + + ) : } +
+
+ {mmdd(point.date)} +
+ ))} +
+
+ 命中 + 未命中 + 输出 +
+ + ) :
{placeholder}
} +
+ ); +} + +// ─── MiMo Default Models (module-level constant) ────────── +// modelDisplayName(m.key) 在渲染时统一获取名称,此处的 name 仅用于类型满足 +const MIMO_DEFAULT_MODELS: MimoUsageModel[] = [ + { key: "mimo-v2.5", name: "MiMo-V2.5", totalTokens: 0, requestCount: 0, cacheHitTokens: 0, cacheMissTokens: 0, responseTokens: 0, cost: 0 }, + { key: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", totalTokens: 0, requestCount: 0, cacheHitTokens: 0, cacheMissTokens: 0, responseTokens: 0, cost: 0 }, +]; + +// ─── DashboardPanel ──────────────────────────────────────── +export function DashboardPanel({ provider, onProviderChange, balance, balanceState, balanceError, usage, usageState, usageError, onRefresh, onClose, onSettings, onDetail, currency, exchangeRate, efficiencyUnit }: { + provider: Provider; + onProviderChange: (p: Provider) => void; + balance: BalanceData | MimoBalanceData | null; + balanceState: BalanceState; + balanceError: string; + usage: UsageResult | MimoUsageResult | null; + usageState: BalanceState; + usageError: string; + onRefresh: () => void; + onClose: () => void; + onSettings: () => void; + onDetail: (model: string) => void; + currency: "cny" | "usd"; + exchangeRate: number; + efficiencyUnit: "token_per_currency" | "currency_per_token"; +}) { + // Theme is managed by SettingsPanel via config; just ensure data-theme is set on mount + React.useEffect(() => { + const stored = localStorage.getItem("ui-theme") || "light"; + document.documentElement.setAttribute("data-theme", stored); + }, []); + + const isDeepSeek = provider === "deepseek"; + const dsUsage = isDeepSeek ? (usage as UsageResult | null) : null; + const mimoUsage = !isDeepSeek ? (usage as MimoUsageResult | null) : null; + const flash = dsUsage?.models.find((item) => item.key === "flash") ?? null; + const pro = dsUsage?.models.find((item) => item.key === "pro") ?? null; + const maxTokens = Math.max(flash?.totalTokens ?? 0, pro?.totalTokens ?? 0, ...(mimoUsage?.models.map((m) => m.totalTokens) ?? []), 1); + const today = dsUsage?.days.find((day) => day.date === todayStr()) ?? null; + const mimoToday = mimoUsage?.days.find((day) => day.date === todayStr()) ?? null; + const todayCost = usageState === "ok" ? (today ? today.totalCost : mimoToday ? mimoToday.totalCost : null) : null; + const monthCost = usageState === "ok" && usage ? usage.monthCost : null; + const topModels = mimoUsage ? MIMO_DEFAULT_MODELS.map((def) => mimoUsage.models.find((m) => m.key === def.key) ?? def) : MIMO_DEFAULT_MODELS; + + return ( +
+
+
+ +
+
+ +
+ +
+ + +
+
+ +
+ {isDeepSeek ? ( + <> + onDetail("flash")} currency={currency} exchangeRate={exchangeRate} efficiencyUnit={efficiencyUnit} /> + onDetail("pro")} currency={currency} exchangeRate={exchangeRate} efficiencyUnit={efficiencyUnit} /> + + ) : topModels.map((m) => ( + onDetail(m.key)} modelDisplay={modelDisplayName(m.key)} currency={currency} exchangeRate={exchangeRate} efficiencyUnit={efficiencyUnit} /> + ))} +
+ +
+ ); +} + +// ─── ProviderSelect ──────────────────────────────────────── +function ProviderSelect({ provider, onChange }: { provider: Provider; onChange: (p: Provider) => void }) { + return ( + + ); +} diff --git a/src/components/ModelDetailPanel.tsx b/src/components/ModelDetailPanel.tsx new file mode 100644 index 0000000..003532e --- /dev/null +++ b/src/components/ModelDetailPanel.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { Brain, X, Zap } from "lucide-react"; +import type { ModelName, BalanceState, UsageResult, MimoUsageResult, Provider } from "../types"; +import { fmtInt, fmtTokensShort, fmtMoney, mmdd, addDays, dateKey, modelDisplayName, modelIcon } from "../utils"; + +// ─── ModelDetailPanel ────────────────────────────────────── +export function ModelDetailPanel({ model, usage, usageState, onBack, provider, currency, exchangeRate, efficiencyUnit }: { + model: ModelName; usage: UsageResult | MimoUsageResult | null; + usageState: BalanceState; onBack: () => void; provider: Provider; + currency?: "cny" | "usd"; + exchangeRate?: number; + efficiencyUnit?: "token_per_currency" | "currency_per_token"; +}) { + const isDeepSeek = provider === "deepseek"; + const isFlash = model === "flash"; + const mimoUsage = !isDeepSeek ? (usage as MimoUsageResult | null) : null; + const dsUsage = isDeepSeek ? (usage as UsageResult | null) : null; + + let title: string; let tintClass: string; let cost: string; let totalText: string; + if (isDeepSeek) { + const data = dsUsage?.models.find((i) => i.key === model) ?? null; + title = isFlash ? "V4 Flash" : "V4 Pro"; + tintClass = isFlash ? "flash" : "pro"; + cost = data ? fmtMoney(data.cost, currency, exchangeRate) : "—"; + totalText = data ? fmtTokensShort(data.totalTokens) : "—"; + } else { + title = modelDisplayName(model); + tintClass = modelIcon(model); + const md = mimoUsage?.models.find((m) => m.key === model); + cost = md ? fmtMoney(md.cost, currency, exchangeRate) : "—"; + totalText = md ? fmtTokensShort(md.totalTokens) : "—"; + } + + const detailModelData = isDeepSeek + ? (dsUsage?.models.find((i) => i.key === model) ?? null) + : (mimoUsage?.models.find((m) => m.key === model) ?? null); + + const [hoveredIdx, setHoveredIdx] = React.useState(null); + const [weekOffset, setWeekOffset] = React.useState(0); + const MIN_BAR = 3; + const DAYS_PER_WEEK = 7; + + const today = new Date(); + const weekStart = addDays(today, weekOffset * DAYS_PER_WEEK - DAYS_PER_WEEK + 1); + const dayKeys = Array.from({ length: DAYS_PER_WEEK }, (_, i) => dateKey(addDays(weekStart, i))); + + const dsMap = new Map((dsUsage?.days ?? []).map((d) => [d.date, d])); + const mimoMap = new Map((mimoUsage?.days ?? []).map((d) => [d.date, d])); + + const points = dayKeys.map((date) => { + if (isDeepSeek) { + const d = dsMap.get(date); + if (!d) return { date, hit: 0, miss: 0, response: 0, total: 0, cost: 0 }; + const hit = isFlash ? d.flashCacheHit : d.proCacheHit; + const miss = isFlash ? d.flashCacheMiss : d.proCacheMiss; + const response = isFlash ? d.flashResponse : d.proResponse; + const total = hit + miss + response; + // 按 token 占比估算当日该模型成本 + const cost = d.totalTokens > 0 && d.totalCost > 0 ? (total / d.totalTokens) * d.totalCost : 0; + return { date, hit, miss, response, total, cost }; + } else { + const d = mimoMap.get(date); + if (!d) return { date, hit: 0, miss: 0, response: 0, total: 0, cost: 0 }; + const md = d.models.find((m) => m.key === model); + return { + date, + hit: md?.cacheHitTokens ?? 0, + miss: md?.cacheMissTokens ?? 0, + response: md?.responseTokens ?? 0, + total: md?.totalTokens ?? 0, + cost: md?.totalCost ?? 0, + }; + } + }); + + const maxVal = Math.max(...points.map((p) => p.total), 1); + const sym = currency === "usd" ? "$" : "¥"; + + // 整体统计 + const modelData = detailModelData; + const sumTokens = modelData?.totalTokens ?? 0; + const sumHit = modelData?.cacheHitTokens ?? 0; + const sumMiss = modelData?.cacheMissTokens ?? 0; + const sumCost = modelData?.cost ?? 0; + const displayCost = currency === "usd" && exchangeRate && exchangeRate > 0 ? sumCost * exchangeRate : sumCost; + const avgHitRate = sumHit + sumMiss > 0 ? ((sumHit / (sumHit + sumMiss)) * 100).toFixed(3) : "0"; + const avgRatio = sumCost > 0 && sumTokens > 0 + ? efficiencyUnit === "token_per_currency" + ? `${(sumTokens / displayCost / 1_000_000).toFixed(2)} MT/${sym}` + : `${(displayCost * 1_000_000 / sumTokens).toFixed(3)} ${sym}/MT` + : "—"; + + const rangeText = `${mmdd(points[0]?.date ?? "")} - ${mmdd(points[points.length - 1]?.date ?? "")}`; + const canGoForward = weekOffset < 0; + const weekLabel = weekOffset === 0 ? "本周" : weekOffset === -1 ? "上周" : `${-weekOffset}周前`; + + return ( +
+ +
+
+ {isDeepSeek ? (isFlash ? : ) : } +
+

{title}

{cost} · 命中 {avgHitRate}% · {avgRatio}

+
+
+
API 请求次数{detailModelData ? fmtInt(detailModelData.requestCount) : "—"}
+
Tokens{totalText}
+
+
+
+

按日 Token 消耗

{rangeText}
+
+ + {weekLabel} + +
+
+ {usageState === "ok" && points.length > 0 ? ( + <> +
setHoveredIdx(null)}> + {points.map((point, idx) => ( +
setHoveredIdx(idx)}> + {hoveredIdx === idx && ( +
= points.length - 2 ? " align-right" : ""}`}> +
{point.date}{fmtInt(point.total)} tokens
+ 输入(命中缓存){fmtInt(point.hit)} tokens + 输入(未命中缓存){fmtInt(point.miss)} tokens + 输出{fmtInt(point.response)} tokens + 缓存命中 {point.hit + point.miss > 0 ? ((point.hit / (point.hit + point.miss)) * 100).toFixed(3) : "0"}% + 平均单价 + {point.cost > 0 && point.total > 0 + ? efficiencyUnit === "token_per_currency" + ? `${(point.total / point.cost / 1_000_000).toFixed(2)} MT/${sym}` + : `${(point.cost * 1_000_000 / point.total).toFixed(3)} ${sym}/MT` + : "—"} + +
+ )} + {point.total > 0 ? fmtTokensShort(point.total) : ""} +
+
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%` }}> + {point.total > 0 ? ( + <> + {point.hit > 0 && } + {point.miss > 0 && } + {point.response > 0 && } + + ) : } +
+
+ {mmdd(point.date)} +
+ ))} +
+
+ 命中 + 未命中 + 输出 +
+ + ) : ( +
+ {usageState === "nokey" ? "未配置用量 Token" : usageState === "loading" ? "查询中…" : "暂无数据"} +
+ )} +
+
+ ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..88f7dd7 --- /dev/null +++ b/src/components/SettingsPanel.tsx @@ -0,0 +1,728 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { getVersion } from "@tauri-apps/api/app"; +import { + BarChart3, CheckCircle2, Info, KeyRound, Power, Settings, X, + User, Monitor, Bell, Palette, Globe, ChevronRight, ChevronLeft, +} from "lucide-react"; +import type { Provider, AppConfig, BalanceData, MimoBalanceData, BalanceState, UsageResult, MimoUsageResult } from "../types"; +import { fmtMoney, addDays, previousMonth } from "../utils"; +import { t, getLang, setLang, LANG_OPTIONS } from "../i18n"; +import { marked } from "marked"; + +// ─── SettingsPanel ───────────────────────────────────────── +export function SettingsPanel({ provider, onProviderChange, onBack, onUsageLoaded, onUsageCleared, onRefreshIntervalChanged, onAutoRefreshChanged, onCurrencyChanged, onEfficiencyUnitChanged }: { + provider: Provider; onProviderChange: (p: Provider) => void; onBack: () => void; + onUsageLoaded: (usage: UsageResult | MimoUsageResult) => void; onUsageCleared: () => void; + onRefreshIntervalChanged: (seconds: number) => void; onAutoRefreshChanged: (enabled: boolean) => void; + onCurrencyChanged: (currency: "cny" | "usd") => void; + onEfficiencyUnitChanged: (unit: "token_per_currency" | "currency_per_token") => void; +}) { + const [apiKey, setApiKey] = React.useState(""); + const [config, setConfig] = React.useState(null); + const [status, setStatus] = React.useState("正在读取本地配置"); + const [busy, setBusy] = React.useState(false); + const [refresh, setRefresh] = React.useState(60); + const [autoRefresh, setAutoRefresh] = React.useState(false); + const [autostart, setAutostart] = React.useState(false); + const [lowBalanceNotify, setLowBalanceNotify] = React.useState(false); + const [lowBalanceThreshold, setLowBalanceThreshold] = React.useState("5.00"); + const [usageToken, setUsageToken] = React.useState(""); + const [usageStatus, setUsageStatus] = React.useState(""); + const [usageSyncing, setUsageSyncing] = React.useState(false); + const [showManualPaste, setShowManualPaste] = React.useState(false); + const [appVersion, setAppVersion] = React.useState("1.1.0"); + const [mimoStatus, setMimoStatus] = React.useState(""); + const [mimoSyncing, setMimoSyncing] = React.useState(false); + const [checkingUpdate, setCheckingUpdate] = React.useState(false); + const [updateInfo, setUpdateInfo] = React.useState<{ version: string; date: string; body: string } | null>(null); + const [updateError, setUpdateError] = React.useState(""); + const [downloading, setDownloading] = React.useState(false); + const [downloadProgress, setDownloadProgress] = React.useState<{ downloaded: number; total: number | null }>({ downloaded: 0, total: null }); + const [downloadDone, setDownloadDone] = React.useState(false); + const [changelogLoading, setChangelogLoading] = React.useState(false); + const [changelogHtml, setChangelogHtml] = React.useState(""); + const [changelogError, setChangelogError] = React.useState(""); + const [activeCategory, setActiveCategory] = React.useState(null); + const [customDsRefresh, setCustomDsRefresh] = React.useState(false); + const [customMimoRefresh, setCustomMimoRefresh] = React.useState(false); + const [customCooldown, setCustomCooldown] = React.useState(false); + const [theme, setTheme] = React.useState<"light" | "dark" | "system">("light"); + const [currency, setCurrency] = React.useState<"cny" | "usd">("cny"); + const [efficiencyUnit, setEfficiencyUnit] = React.useState<"token_per_currency" | "currency_per_token">("token_per_currency"); + const configPath = config?.configPath ?? "%APPDATA%\\DeepSeekMonitorWindows\\config.json"; + + const PRESET_REFRESH = [60, 300, 1800, 3600]; + const PRESET_COOLDOWN = [10, 30, 60, 180, 360]; + + React.useEffect(() => { + void invoke("get_app_config").then((c) => { setConfig(c); const ri = c.refreshIntervalSeconds || 60; setRefresh(ri); setCustomDsRefresh(!PRESET_REFRESH.includes(ri)); setCustomMimoRefresh(!PRESET_REFRESH.includes(c.mimoRefreshIntervalSeconds || 0) && (c.mimoRefreshIntervalSeconds || 0) > 0); setCustomCooldown(!PRESET_COOLDOWN.includes(c.notifyCooldownMinutes || 30)); setAutoRefresh(c.autoRefreshEnabled); setAutostart(c.autostart); setLowBalanceNotify(c.lowBalanceNotify || false); setLowBalanceThreshold(String(c.lowBalanceThreshold || 5.00)); setStatus(c.apiKeyConfigured ? `已配置 ${c.apiKeyPreview}` : "未配置 API Key"); setUsageStatus(c.usageTokenConfigured ? "用量 Token 已配置" : "未配置用量 Token"); setTheme(c.theme || "light"); setCurrency(c.currency || "cny"); setEfficiencyUnit(c.efficiencyUnit || "token_per_currency"); }).catch(() => setStatus("浏览器预览模式")); + }, []); + React.useEffect(() => { void getVersion().then(setAppVersion).catch(() => setAppVersion("1.1.0")); }, []); + + const fetchCurrentUsage = React.useCallback(async () => { + const now = new Date(); + const current: UsageResult = await invoke("fetch_usage", { month: now.getMonth() + 1, year: now.getFullYear() }); + const needsPrev = addDays(now, -6).getMonth() !== now.getMonth(); + if (!needsPrev) return current; + try { + const prev = previousMonth(now); + const prevUsage: UsageResult = await invoke("fetch_usage", { month: prev.month, year: prev.year }); + return { ...current, days: [...prevUsage.days, ...current.days] }; + } catch { return current; } + }, []); + + const refreshUsageAfterToken = React.useCallback((prefix: string) => { + setUsageStatus(`${prefix},正在刷新用量数据…`); + return fetchCurrentUsage().then((u) => { onUsageLoaded(u); setUsageStatus(`${prefix},本月消费 ${fmtMoney(u.monthCost)}`); return u; }).catch((e) => { setUsageStatus(`${prefix},但用量刷新失败:${typeof e === "string" ? e : "刷新失败"}`); throw e; }); + }, [onUsageLoaded, fetchCurrentUsage]); + + React.useEffect(() => { const p = listen("usage-token-captured", (e) => { setConfig(e.payload); setUsageSyncing(false); void refreshUsageAfterToken("已通过网页登录自动同步用量 Token"); }); return () => { void p.then((u) => u()); }; }, [refreshUsageAfterToken]); + React.useEffect(() => { const p = listen("usage-sync-ended", () => { setUsageSyncing(false); setUsageStatus("登录窗口已关闭,Token 未获取到。可重新点击同步或使用方式二手动粘贴。"); }); return () => { void p.then((u) => u()); }; }, []); + React.useEffect(() => { const p = listen("mimo-sync-started", () => { setMimoStatus("请在打开的窗口中登录小米账号,登录后保持窗口打开"); }); return () => { void p.then((u) => u()); }; }, []); + + const pasteApiKey = React.useCallback(async () => { try { setApiKey((await navigator.clipboard.readText()).trim()); setStatus("已从剪贴板读取"); } catch { setStatus("剪贴板读取失败"); } }, []); + const saveApiKey = React.useCallback(() => { setBusy(true); void invoke("save_api_key", { apiKey }).then((c) => { setConfig(c); setApiKey(""); setStatus("已保存,正在验证 Key…"); return invoke("fetch_balance"); }).then((b) => { setStatus(`验证通过,当前余额 ${b.currency === "USD" ? "$" : "¥"}${b.totalBalance}${b.isAvailable ? "" : "(余额不足)"}`); }).catch((e) => { setStatus(typeof e === "string" ? e : "保存或验证失败"); }).finally(() => setBusy(false)); }, [apiKey]); + const clearApiKey = React.useCallback(() => { setBusy(true); void invoke("clear_api_key").then((c) => { setConfig(c); setApiKey(""); setStatus("已清除 API Key"); }).catch((e) => { setStatus(typeof e === "string" ? e : "清除失败"); }).finally(() => setBusy(false)); }, []); + const pasteUsageToken = React.useCallback(async () => { try { setUsageToken((await navigator.clipboard.readText()).trim()); setUsageStatus("已从剪贴板读取"); } catch { setUsageStatus("剪贴板读取失败"); } }, []); + const startUsageSync = React.useCallback(() => { setUsageSyncing(true); setUsageStatus("正在打开登录窗口…"); void invoke("start_usage_sync").then((s) => { if (!s) setUsageStatus("登录完成后,再次点击本按钮即可同步用量(可多点几次)"); }).catch((e) => { setUsageStatus(typeof e === "string" ? e : "打开登录窗口失败"); }).finally(() => { window.setTimeout(() => setUsageSyncing(false), 2500); }); }, []); + const saveUsageToken = React.useCallback(() => { setBusy(true); void invoke("save_usage_token", { usageToken }).then((c) => { setConfig(c); setUsageToken(""); setUsageStatus("已保存,正在验证用量 Token…"); return refreshUsageAfterToken("手动 Token 已保存"); }).catch((e) => { setUsageStatus(typeof e === "string" ? e : "保存或验证失败"); }).finally(() => setBusy(false)); }, [refreshUsageAfterToken, usageToken]); + const clearUsageToken = React.useCallback(() => { setBusy(true); void invoke("clear_usage_token").then((c) => { setConfig(c); setUsageToken(""); setUsageStatus("已清除用量 Token"); onUsageCleared(); }).catch((e) => { setUsageStatus(typeof e === "string" ? e : "清除失败"); }).finally(() => setBusy(false)); }, [onUsageCleared]); + const startMimoSync = React.useCallback(() => { setMimoSyncing(true); setMimoStatus("正在打开 MiMo 页面…"); void invoke("start_mimo_sync").then((a) => { setMimoStatus(a ? "登录窗口已打开,请确认已登录小米账号" : "请在打开的窗口中登录小米账号,登录后保持窗口打开"); setMimoSyncing(false); }).catch((e) => { setMimoStatus(typeof e === "string" ? e : "启动同步失败"); setMimoSyncing(false); }); }, []); + const saveRefreshInterval = React.useCallback((s: number) => { const p = refresh; setRefresh(s); onRefreshIntervalChanged(s); void invoke("save_refresh_interval", { refreshIntervalSeconds: s }).then((c) => { setConfig(c); setRefresh(c.refreshIntervalSeconds || 60); onRefreshIntervalChanged(c.refreshIntervalSeconds || 60); }).catch(() => { setRefresh(p); onRefreshIntervalChanged(p); }); }, [onRefreshIntervalChanged, refresh]); + const saveAutoRefreshEnabled = React.useCallback((e: boolean) => { const p = autoRefresh; setAutoRefresh(e); onAutoRefreshChanged(e); void invoke("save_auto_refresh_enabled", { autoRefreshEnabled: e }).then((c) => { setConfig(c); setAutoRefresh(c.autoRefreshEnabled); onAutoRefreshChanged(c.autoRefreshEnabled); }).catch(() => { setAutoRefresh(p); onAutoRefreshChanged(p); }); }, [autoRefresh, onAutoRefreshChanged]); + const saveAutostart = React.useCallback((e: boolean) => { const p = autostart; setAutostart(e); void invoke("save_autostart", { autostart: e }).then((c) => { setConfig(c); setAutostart(c.autostart); }).catch(() => setAutostart(p)); }, [autostart]); + + const saveLowBalanceNotify = React.useCallback((e: boolean) => { + const p = lowBalanceNotify; setLowBalanceNotify(e); + void invoke("save_low_balance_notify", { enabled: e }).then((c) => { setConfig(c); setLowBalanceNotify(c.lowBalanceNotify); }).catch(() => setLowBalanceNotify(p)); + }, [lowBalanceNotify]); + const saveLowBalanceThreshold = React.useCallback((val: string) => { + setLowBalanceThreshold(val); + const num = parseFloat(val); + if (!isNaN(num) && num >= 0) { + void invoke("save_low_balance_threshold", { threshold: num }).then((c) => { setConfig(c); }).catch(() => {}); + } + }, []); + + const saveTheme = React.useCallback((val: "light" | "dark" | "system") => { + const prev = theme; setTheme(val); + // Apply theme immediately + const apply = val === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : val; + document.documentElement.setAttribute("data-theme", apply); + localStorage.setItem("ui-theme", apply); // sync with DashboardPanel + void invoke("save_theme", { theme: val }).then((c) => { setConfig(c); }).catch(() => setTheme(prev)); + }, [theme]); + + const saveCurrency = React.useCallback((val: "cny" | "usd") => { + const prev = currency; setCurrency(val); + onCurrencyChanged(val); + void invoke("save_currency", { currency: val }).then((c) => { setConfig(c); }).catch(() => { setCurrency(prev); onCurrencyChanged(prev); }); + }, [currency, onCurrencyChanged]); + + const saveEfficiencyUnit = React.useCallback((val: "token_per_currency" | "currency_per_token") => { + const prev = efficiencyUnit; setEfficiencyUnit(val); + onEfficiencyUnitChanged(val); + void invoke("save_efficiency_unit", { unit: val }).then((c) => { setConfig(c); }).catch(() => { setEfficiencyUnit(prev); onEfficiencyUnitChanged(prev); }); + }, [efficiencyUnit, onEfficiencyUnitChanged]); + + // Apply theme on mount and when theme changes + React.useEffect(() => { + const apply = theme === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : theme; + document.documentElement.setAttribute("data-theme", apply); + localStorage.setItem("ui-theme", apply); // sync with DashboardPanel + if (theme === "system") { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => { + document.documentElement.setAttribute("data-theme", e.matches ? "dark" : "light"); + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + } + }, [theme]); + + const handleCheckUpdate = React.useCallback(() => { + setCheckingUpdate(true); + setUpdateError(""); + setUpdateInfo(null); + setDownloadDone(false); + setDownloadProgress({ downloaded: 0, total: null }); + void invoke<{ version: string; date: string; body: string } | null>("check_update") + .then((info) => { + setUpdateInfo(info); + if (!info) setUpdateError(""); + }) + .catch((err) => { + setUpdateInfo(null); + const msg = typeof err === "string" ? err : String(err); + setUpdateError(msg); + console.warn("检查更新失败:", err); + }) + .finally(() => setCheckingUpdate(false)); + }, []); + + const handleInstallUpdate = React.useCallback(async () => { + setDownloading(true); + setDownloadProgress({ downloaded: 0, total: null }); + setDownloadDone(false); + try { + const { Channel } = await import("@tauri-apps/api/core"); + const onEvent = new Channel<{ event: string; data?: { contentLength?: number; chunkLength?: number; downloaded?: number } }>(); + onEvent.onmessage = (msg) => { + if (msg.event === "Started") { + setDownloadProgress({ downloaded: 0, total: msg.data?.contentLength ?? null }); + } else if (msg.event === "Progress") { + // Use server-side cumulative downloaded value directly + setDownloadProgress((prev) => ({ downloaded: msg.data?.downloaded ?? prev?.downloaded ?? 0, total: prev?.total ?? null })); + } else if (msg.event === "Finished") { + setDownloadDone(true); + setDownloading(false); + } + }; + await invoke("install_update", { onEvent }); + // On Windows/NSIS, the process exits during install — this line is unreachable. + // The NSIS installer handles restart via its /UPDATE flag. + } catch (e) { + console.warn("下载安装失败:", e); + setDownloading(false); + setDownloadDone(false); + setDownloadProgress({ downloaded: 0, total: null }); + } + }, []); + + const handleViewChangelog = React.useCallback(async () => { + if (changelogHtml) { setChangelogHtml(""); return; } + setChangelogLoading(true); + setChangelogError(""); + setChangelogHtml(""); + try { + const repos = [ + { owner: "HaoyueQin", label: "" }, + { owner: "Joyi-code", label: " (原作者)" }, + ]; + let html = ""; + for (const repo of repos) { + try { + let allReleases: Array<{ tag_name: string; published_at: string; body: string }> = []; + let page = 1; + while (true) { + const res = await fetch(`https://api.github.com/repos/${repo.owner}/DeepSeekMonitorWindows/releases?per_page=100&page=${page}`); + if (!res.ok) break; + const pageReleases = await res.json(); + if (!Array.isArray(pageReleases) || pageReleases.length === 0) break; + allReleases = allReleases.concat(pageReleases); + if (pageReleases.length < 100) break; + page++; + } + html += `

${repo.owner}${repo.label}

`; + for (const r of allReleases) { + const date = new Date(r.published_at).toLocaleDateString("zh-CN"); + html += `
${r.tag_name} (${date})`; + html += await marked.parse(r.body || ""); + html += `
`; + } + } catch (e) { + html += `

无法获取 ${repo.owner} 的更新日志...

`; + } + } + setChangelogHtml(html); + } catch (e) { + setChangelogError(`获取更新日志失败: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setChangelogLoading(false); + } + }, [changelogHtml]); + + const [lang, setLangState] = React.useState(getLang()); + + const [langOpen, setLangOpen] = React.useState(false); + const langRef = React.useRef(null); + const langBtnRef = React.useRef(null); + const langDropdownRef = React.useRef(null); + React.useEffect(() => { + const handler = (e: MouseEvent) => { + const t = e.target as Node; + if ((langRef.current && langRef.current.contains(t)) || + (langDropdownRef.current && langDropdownRef.current.contains(t))) return; + setLangOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const currentLangLabel = LANG_OPTIONS.find(l => l.code === lang)?.label || '简体中文'; + + // Category list view + const categories = [ + { key: "account", icon: , label: t('settings.cat_account') }, + { key: "general", icon: , label: t('settings.cat_general') }, + { key: "display", icon: , label: t('settings.cat_display') }, + { key: "notify", icon: , label: t('notify.title') }, + { key: "data", icon: , label: t('settings.cat_data') }, + { key: "about", icon: , label: t('settings.cat_about') }, + ]; + + // Account category content - always show both platforms + const accountContent = ( + <> + {/* DeepSeek Account */} +
+
DeepSeek
+ } title="API Key"> +

用于调用 DeepSeek API 获取余额和用量数据。

+

API Key 只在当前这台 Windows 电脑本地保留。

+
setApiKey(e.target.value)} />
+
+ + {config?.apiKeyConfigured ? "已配置" : "未配置"} + +
+
+ } title="用量同步 Token"> +

用于同步 Token 用量、消费和趋势图。DeepSeek 无官方用量 API,需网页登录 token(与 API Key 不同)。

+
+ + {config?.usageTokenConfigured ? "已配置" : "未配置"} + +
+

{usageStatus}

+ + {showManualPaste && (<> +

获取:浏览器登录 platform.deepseek.com,按 F12 打开控制台,输入 JSON.parse(localStorage.userToken).value 回车,复制返回的字符串。

+
setUsageToken(e.target.value)} />
+
+ )} +
+
+ + {/* MiMo Account */} +
+
MiMo
+ } title="MiMo 登录"> +

通过小米账号登录 MiMo 平台,登录成功后即可查看余额和用量数据。

+
+ {mimoStatus &&

{mimoStatus}

} +
+
+ + ); + const generalContent = ( + <> + } title={t('settings.general')}> + +

{t('settings.autostart_desc')}

+ +

{t('settings.auto_refresh_desc')}

+ {autoRefresh && ( +
+
+ DeepSeek 刷新间隔 +
+ + {customDsRefresh && ( + <> + { if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) saveRefreshInterval(val * 60); } }} /> + 分钟 + + )} +
+
+
+ MiMo 刷新间隔 +
+ + {customMimoRefresh && ( + <> + { if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) void invoke("save_mimo_refresh_interval", { seconds: val * 60 }).then(setConfig).catch(() => {}); } }} /> + 分钟 + + )} +
+
+
+ )} +

{t('settings.default_provider')}

+ +
+ } title={t('settings.language')}> +
+

{t('settings.language_desc')}

+
+ + {langOpen && createPortal( +
+ {LANG_OPTIONS.map((opt) => ( + + ))} +
, + document.body + )} +
+
+
+ + ); + + // Display category content + const displayContent = ( + <> + } title={t('settings.currency')}> +

选择金额显示的货币。

+
+ {(["cny", "usd"] as const).map((opt) => ( + + ))} +
+

效率指标显示方式:

+
+ {(["token_per_currency", "currency_per_token"] as const).map((opt) => ( + + ))} +
+
+ } title={t('settings.theme')}> +

选择应用的外观主题。

+
+ {(["light", "dark", "system"] as const).map((opt) => ( + + ))} +
+
+ } title={t('settings.window_size')}> +

{t('settings.window_desc')}

+
+ {[{ label: t('settings.compact'), w: 380, h: 600 }, { label: t('settings.standard'), w: 463, h: 660 }, { label: t('settings.wide'), w: 600, h: 700 }, { label: t('settings.large'), w: 660, h: 900 }].map((preset) => ( + + ))} +
+
+ + ); + + // Notify category content + const notifyContent = ( + } title={t('notify.title')}> + +

{t('notify.desc')}

+ {lowBalanceNotify && ( + <> +
+ {t('notify.threshold')}: + saveLowBalanceThreshold(e.target.value)} style={{ width: 100 }} /> + {currency === "usd" ? "$" : "¥"} +
+

{t('settings.notify_cooldown')}

+

{t('settings.notify_cooldown_desc')}

+
+ + {customCooldown && ( + <> + { if (e.key === 'Enter') { const val = parseInt((e.target as HTMLInputElement).value); if (val > 0) void invoke("save_notify_cooldown", { minutes: val }).then(setConfig).catch(() => {}); } }} /> + 分钟 + + )} +
+ + )} +
+ ); + + // About category content + const aboutContent = ( + } title={t('settings.about')}> +
{t('settings.version')}v{appVersion}
+
+ {!updateInfo && ( + + )} + {updateInfo && !downloading && !downloadDone && ( + + )} + {downloading && ( +
+
+
+
+ + {downloadProgress?.total + ? `${(downloadProgress.downloaded / 1024 / 1024).toFixed(1)} / ${(downloadProgress.total / 1024 / 1024).toFixed(1)} MB (${Math.min(100, (downloadProgress.downloaded / downloadProgress.total) * 100).toFixed(1)}%)` + : t('settings.downloading_update')} + +
+ )} + {downloadDone && {t('settings.update_installed')}} + {!updateInfo && !updateError && !checkingUpdate && {t('settings.latest')}} + {updateError && ⚠ {updateError}} +
+ + {changelogError &&

{changelogError}

} + {changelogHtml &&
} + {updateInfo && !downloading && !downloadDone && ( +
+

v{updateInfo.version} {updateInfo.date ? `(${updateInfo.date})` : ""}

+
+ )} +
+ 配置文件:{configPath} +
+ + ); + + // Data management category content + const [exportFormat, setExportFormat] = React.useState<"json" | "csv">("json"); + const [exportPlatform, setExportPlatform] = React.useState<"all" | "deepseek" | "mimo">("all"); + + const handleExport = () => { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith('dsm-')) continue; + if (exportPlatform === "deepseek" && key.includes('mimo')) continue; + if (exportPlatform === "mimo" && !key.includes('mimo') && !key.includes('platform')) continue; + keys.push(key); + } + + if (exportFormat === "json") { + const data: Record = {}; + for (const key of keys) { + try { data[key] = JSON.parse(localStorage.getItem(key) || ''); } catch { data[key] = localStorage.getItem(key); } + } + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `dsm-data-${exportPlatform}.json`; a.click(); URL.revokeObjectURL(url); + } else { + // CSV: flatten each key-value pair + const rows = [["key", "value"]]; + for (const key of keys) { + const val = localStorage.getItem(key) || ''; + try { + const parsed = JSON.parse(val); + if (typeof parsed === 'object' && parsed !== null) { + // Flatten nested objects + for (const [k, v] of Object.entries(parsed)) { + rows.push([`${key}.${k}`, typeof v === 'object' ? JSON.stringify(v) : String(v)]); + } + } else { + rows.push([key, String(parsed)]); + } + } catch { rows.push([key, val]); } + } + const csv = rows.map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob(['\uFEFF' + csv], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `dsm-data-${exportPlatform}.csv`; a.click(); URL.revokeObjectURL(url); + } + }; + + const dataContent = ( + <> + } title={t('settings.clear_cache')}> +

{t('settings.clear_cache_desc')}

+
+ +
+
+ } title="导出使用数据"> +

导出缓存的使用数据(余额、用量等),支持多种格式和平台过滤。

+
+ 格式: +
+ {(["json", "csv"] as const).map((opt) => ( + + ))} +
+
+
+ 平台: +
+ {(["all", "deepseek", "mimo"] as const).map((opt) => ( + + ))} +
+
+
+ +
+
+ } title="导入使用数据"> +

从 JSON 文件导入使用数据,将覆盖当前缓存。

+
+ +
+
+ + ); + + const categoryContent: Record = { + account: accountContent, + general: generalContent, + display: displayContent, + notify: notifyContent, + data: dataContent, + about: aboutContent, + }; + + return ( +
+ +
+
+ DeepSeek / MiMo Monitor +

{t('settings.title')}

+
+ +
+ {categories.map((cat) => ( + + +
+
+ {categoryContent[cat.key]} +
+
+
+ ))} +
+
+
+ ); +} + +// ─── Shared ──────────────────────────────────────────────── +function SettingsSection({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) { + return (

{icon}{title}

{children}
); +} +function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) { + return (); +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..538c86b --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,152 @@ +// ─── i18n 国际化支持 ────────────────────────────────────── +// 中文 / English 双语切换 + +export type Lang = 'zh' | 'en'; + +export const LANG_OPTIONS: { code: Lang; label: string }[] = [ + { code: 'zh', label: '简体中文' }, + { code: 'en', label: 'English' }, +]; + +const translations: Record> = { + // 通用 + 'app.loading': { zh: '查询中…', en: 'Loading…' }, + 'app.error': { zh: '查询失败', en: 'Query failed' }, + 'app.unconfigured': { zh: '未配置', en: 'Not configured' }, + 'app.unconfigured_token': { zh: '未配置 Token', en: 'Token not configured' }, + 'app.unavailable': { zh: '不可用', en: 'Unavailable' }, + 'app.no_data': { zh: '暂无数据', en: 'No data' }, + 'app.tokens': { zh: 'tokens', en: 'tokens' }, + + // 余额 + 'balance.title': { zh: '账户余额', en: 'Account Balance' }, + 'balance.available': { zh: '可用', en: 'Available' }, + 'balance.insufficient': { zh: '余额不足', en: 'Insufficient' }, + 'balance.today': { zh: '当日消耗', en: 'Today' }, + 'balance.monthly': { zh: '本月消费', en: 'This Month' }, + + // 设置 + 'settings.title': { zh: '设置', en: 'Settings' }, + 'settings.api_key': { zh: 'API Key', en: 'API Key' }, + 'settings.api_key_desc': { zh: '用于调用 API 获取余额和用量数据。', en: 'Used to call API for balance and usage data.' }, + 'settings.save': { zh: '验证并保存', en: 'Verify & Save' }, + 'settings.clear': { zh: '清除 Key', en: 'Clear Key' }, + 'settings.verified': { zh: '已配置', en: 'Configured' }, + 'settings.not_configured': { zh: '未配置', en: 'Not configured' }, + 'settings.general': { zh: '通用', en: 'General' }, + 'settings.autostart': { zh: '开机自启', en: 'Auto Start' }, + 'settings.autostart_desc': { zh: '开启后,每次登录 Windows 时自动启动应用。', en: 'Auto start on Windows login.' }, + 'settings.auto_refresh': { zh: '自动刷新', en: 'Auto Refresh' }, + 'settings.auto_refresh_desc': { zh: '开启后,按设定周期自动拉取最新数据。', en: 'Automatically fetch latest data at set intervals.' }, + 'settings.window_size': { zh: '窗口大小', en: 'Window Size' }, + 'settings.window_desc': { zh: '选择预设窗口尺寸,或拖拽窗口边缘自由调整。', en: 'Choose a preset size or drag window edges.' }, + 'settings.compact': { zh: '紧凑', en: 'Compact' }, + 'settings.standard': { zh: '标准', en: 'Standard' }, + 'settings.wide': { zh: '宽屏', en: 'Wide' }, + 'settings.large': { zh: '大屏', en: 'Large' }, + 'settings.about': { zh: '关于', en: 'About' }, + 'settings.version': { zh: '当前版本', en: 'Version' }, + 'settings.check_update': { zh: '检查更新', en: 'Check for Updates' }, + 'settings.checking': { zh: '检查中…', en: 'Checking…' }, + 'settings.latest': { zh: '已是最新版本', en: 'Up to date' }, + 'settings.update_found': { zh: '发现新版本', en: 'Update available' }, + 'settings.download_update': { zh: '下载更新', en: 'Download Update' }, + 'settings.downloading_update': { zh: '正在下载更新…', en: 'Downloading update…' }, + 'settings.update_installed': { zh: '更新已下载,即将安装', en: 'Update downloaded, installing soon' }, + 'settings.cat_account': { zh: '账户', en: 'Account' }, + 'settings.cat_general': { zh: '通用', en: 'General' }, + 'settings.cat_display': { zh: '显示', en: 'Display' }, + 'settings.cat_data': { zh: '数据', en: 'Data' }, + 'settings.cat_about': { zh: '关于', en: 'About' }, + 'settings.theme': { zh: '主题', en: 'Theme' }, + 'settings.theme_desc': { zh: '选择深色、浅色或跟随系统主题。', en: 'Choose dark, light, or follow system theme.' }, + 'settings.theme_light': { zh: '浅色', en: 'Light' }, + 'settings.theme_dark': { zh: '深色', en: 'Dark' }, + 'settings.theme_system': { zh: '跟随系统', en: 'System' }, + 'settings.currency': { zh: '货币单位', en: 'Currency' }, + 'settings.currency_desc': { zh: '选择显示金额的货币类型。', en: 'Choose currency for amounts displayed.' }, + 'settings.efficiency': { zh: '效率单位', en: 'Efficiency Unit' }, + 'settings.efficiency_desc': { zh: '选择显示效率指标的方向。', en: 'Choose efficiency metric direction.' }, + 'settings.token_per_currency': { zh: 'MT/¥', en: 'MT/$' }, + 'settings.currency_per_token': { zh: '¥/MT', en: '$/MT' }, + 'settings.language': { zh: '语言', en: 'Language' }, + 'settings.language_desc': { zh: '选择界面显示语言。', en: 'Choose display language.' }, + 'settings.default_provider': { zh: '默认平台', en: 'Default Provider' }, + 'settings.currency_cny': { zh: '人民币 (¥)', en: 'CNY (¥)' }, + 'settings.currency_usd': { zh: '美元 ($)', en: 'USD ($)' }, + 'settings.clear_cache': { zh: '清除缓存', en: 'Clear Cache' }, + 'settings.clear_cache_desc': { zh: '清除本地缓存的使用数据,下次启动时重新获取。', en: 'Clear local cached usage data, refresh on next launch.' }, + 'settings.notify_cooldown': { zh: '通知冷却时间', en: 'Notification Cooldown' }, + 'settings.notify_cooldown_desc': { zh: '两次余额不足通知之间的最小间隔。', en: 'Minimum interval between low balance notifications.' }, + + // 通知 + 'notify.title': { zh: '通知', en: 'Notifications' }, + 'notify.toggle': { zh: '余额不足时发送 Windows 通知', en: 'Notify on low balance' }, + 'notify.desc': { zh: '当 API 余额低于设定阈值时,通过 Windows 通知提醒。', en: 'Send Windows notification when balance drops below threshold.' }, + 'notify.threshold': { zh: '阈值', en: 'Threshold' }, + + // DeepSeek 用量同步 + 'usage.title': { zh: '用量同步 Token', en: 'Usage Sync Token' }, + 'usage.desc': { zh: '用于同步 Token 用量、消费和趋势图。需网页登录 token。', en: 'Sync token usage and trends. Requires web login token.' }, + 'usage.auto_sync': { zh: '网页登录自动同步', en: 'Web Login Auto Sync' }, + 'usage.waiting': { zh: '等待登录', en: 'Waiting for login' }, + 'usage.manual': { zh: '方式二:手动粘贴 token', en: 'Method 2: Paste token manually' }, + 'usage.manual_collapse': { zh: '收起手动粘贴', en: 'Collapse manual paste' }, + 'usage.save_token': { zh: '保存 Token', en: 'Save Token' }, + 'usage.clear_token': { zh: '清除 Token', en: 'Clear Token' }, + + // MiMo + 'mimo.login': { zh: 'MiMo 登录', en: 'MiMo Login' }, + 'mimo.login_desc': { zh: '通过小米账号登录 MiMo 平台,登录成功后即可查看余额和用量数据。', en: 'Login to MiMo with Xiaomi account to view balance and usage.' }, + 'mimo.login_btn': { zh: '打开 MiMo 登录', en: 'Open MiMo Login' }, + 'mimo.opening': { zh: '正在打开…', en: 'Opening…' }, + 'mimo.no_key': { zh: 'MiMo 平台通过小米账号登录认证,无需 API Key。', en: 'MiMo uses Xiaomi account login, no API Key needed.' }, + 'mimo.not_logged_in': { zh: 'MiMo 未登录,请在设置中重新登录小米账号', en: 'MiMo not logged in, please re-login in settings' }, + + // 图表 + 'chart.cache_hit': { zh: '缓存命中明细', en: 'Cache Hit Details' }, + 'chart.hit': { zh: '命中', en: 'Hit' }, + 'chart.miss': { zh: '未命中', en: 'Miss' }, + 'chart.output': { zh: '输出', en: 'Output' }, + 'chart.hit_rate': { zh: '命中率', en: 'Hit Rate' }, + 'chart.total': { zh: '合计', en: 'Total' }, + 'chart.this_week': { zh: '本周', en: 'This week' }, + 'chart.last_week': { zh: '上周', en: 'Last week' }, + 'chart.weeks_ago': { zh: '周前', en: 'weeks ago' }, + 'chart.input_hit': { zh: '输入(命中缓存)', en: 'Input (cache hit)' }, + 'chart.input_miss': { zh: '输入(未命中缓存)', en: 'Input (cache miss)' }, + + // 模型详情 + 'detail.requests': { zh: 'API 请求次数', en: 'API Requests' }, + 'detail.daily': { zh: '按日 Token 消耗', en: 'Daily Token Usage' }, + 'detail.back': { zh: '返回主面板', en: 'Back to Dashboard' }, + + // 导航 + 'nav.refresh': { zh: '刷新', en: 'Refresh' }, + 'nav.settings': { zh: '设置', en: 'Settings' }, + 'nav.close': { zh: '关闭', en: 'Close' }, +}; + +let currentLang: Lang = 'zh'; + +export function setLang(lang: Lang) { + currentLang = lang; + try { localStorage.setItem('dsm-lang', lang); } catch {} +} + +export function getLang(): Lang { + return currentLang; +} + +export function initLang() { + try { + const saved = localStorage.getItem('dsm-lang'); + if (saved === 'en' || saved === 'zh') currentLang = saved as Lang; + } catch {} +} + +export function t(key: string): string { + const entry = translations[key]; + if (!entry) return key; + return entry[currentLang] || entry['zh'] || key; +} diff --git a/src/main.tsx b/src/main.tsx index 963e14f..4d7fa17 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,1162 +2,178 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; -import { getVersion } from "@tauri-apps/api/app"; -import { - BarChart3, - Brain, - CalendarDays, - CheckCircle2, - Clipboard, - CreditCard, - Info, - KeyRound, - Power, - RefreshCw, - Settings, - Shirt, - SunMedium, - X, - Zap, -} from "lucide-react"; -import "./styles.css"; - -type ViewName = "dashboard" | "settings" | "detail"; -type ModelName = "flash" | "pro"; -type AppConfig = { - apiKeyConfigured: boolean; - apiKeyPreview: string | null; - usageTokenConfigured: boolean; - refreshIntervalSeconds: number; - autoRefreshEnabled: boolean; - autostart: boolean; - configPath: string; -}; -type BalanceData = { - isAvailable: boolean; - currency: string; - totalBalance: string; - grantedBalance: string; - toppedUpBalance: string; -}; -type BalanceState = "loading" | "ok" | "error" | "nokey"; -type UsageModel = { - key: string; - name: string; - totalTokens: number; - requestCount: number; - cacheHitTokens: number; - cacheMissTokens: number; - responseTokens: number; - cost: number; -}; -type UsageDay = { - date: string; - flashTokens: number; - flashCacheHit: number; - flashCacheMiss: number; - flashResponse: number; - proTokens: number; - proCacheHit: number; - proCacheMiss: number; - proResponse: number; - totalTokens: number; - totalCost: number; -}; -type UsageResult = { - models: UsageModel[]; - days: UsageDay[]; - monthCost: number; -}; +import "./styles.css"; -const fmtInt = (n: number) => Math.round(n).toLocaleString("en-US"); -const fmtTokensShort = (n: number) => { - if (n >= 1e8) return (n / 1e6).toFixed(0) + "M"; - if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; - if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; - return String(Math.round(n)); -}; -const fmtMoney = (n: number) => "¥" + n.toFixed(2); -const mmdd = (date: string) => { - const parts = date.split("-"); - return parts.length === 3 ? `${Number(parts[1])}/${Number(parts[2])}` : date; -}; -const todayStr = () => { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; -}; -const dateKey = (date: Date) => - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; -const addDays = (date: Date, offset: number) => { - const next = new Date(date); - next.setDate(next.getDate() + offset); - return next; -}; -const recentUsageDays = (days: UsageDay[], count = 7): UsageDay[] => { - const source = new Map(days.filter((day) => day.date <= todayStr()).map((day) => [day.date, day])); - const today = new Date(); - return Array.from({ length: count }, (_, index) => { - const date = dateKey(addDays(today, index - count + 1)); - return ( - source.get(date) ?? { - date, - flashTokens: 0, - flashCacheHit: 0, - flashCacheMiss: 0, - flashResponse: 0, - proTokens: 0, - proCacheHit: 0, - proCacheMiss: 0, - proResponse: 0, - totalTokens: 0, - totalCost: 0, - } - ); - }); -}; -const previousMonth = (date: Date) => { - const previous = new Date(date.getFullYear(), date.getMonth() - 1, 1); - return { month: previous.getMonth() + 1, year: previous.getFullYear() }; -}; -const fetchMonthUsage = (month: number, year: number) => { - return invoke("fetch_usage", { month, year }); -}; -const fetchCurrentUsage = async () => { - const now = new Date(); - const current = await fetchMonthUsage(now.getMonth() + 1, now.getFullYear()); - const needsPreviousMonth = addDays(now, -6).getMonth() !== now.getMonth(); - if (!needsPreviousMonth) { - return current; - } - try { - const previous = previousMonth(now); - const previousUsage = await fetchMonthUsage(previous.month, previous.year); - return { - ...current, - days: [...previousUsage.days, ...current.days], - }; - } catch { - return current; - } -}; +import type { ViewName, ModelName, Provider, AppConfig, BalanceData, MimoBalanceData, BalanceState, UsageResult, MimoUsageResult } from "./types"; +import { addDays, previousMonth, fetchWithCache } from "./utils"; +import { initLang } from "./i18n"; +import { DashboardPanel } from "./components/DashboardPanel"; +import { SettingsPanel } from "./components/SettingsPanel"; +import { ModelDetailPanel } from "./components/ModelDetailPanel"; -const refreshOptions = [ - { label: "1 分钟", value: 60 }, - { label: "5 分钟", value: 300 }, - { label: "30 分钟", value: 1800 }, - { label: "1 小时", value: 3600 }, -]; +initLang(); +// ─── App ─────────────────────────────────────────────────── function App() { const [view, setView] = React.useState("dashboard"); const [model, setModel] = React.useState("flash"); - - const [balance, setBalance] = React.useState(null); + const [provider, setProviderState] = React.useState("deepseek"); + const [balance, setBalance] = React.useState(null); const [balanceState, setBalanceState] = React.useState("loading"); const [balanceError, setBalanceError] = React.useState(""); - - const [usage, setUsage] = React.useState(null); + const [usage, setUsage] = React.useState(null); const [usageState, setUsageState] = React.useState("loading"); const [usageError, setUsageError] = React.useState(""); const [refreshIntervalSeconds, setRefreshIntervalSeconds] = React.useState(60); const [autoRefreshEnabled, setAutoRefreshEnabled] = React.useState(false); + const [currency, setCurrency] = React.useState<"cny" | "usd">("cny"); + const [exchangeRate, setExchangeRate] = React.useState(0.137); + const [efficiencyUnit, setEfficiencyUnit] = React.useState<"token_per_currency" | "currency_per_token">("currency_per_token"); - const loadBalance = React.useCallback(() => { + const loadBalance = React.useCallback((p?: Provider) => { + const active = p ?? provider; setBalanceState("loading"); - void invoke("fetch_balance") - .then((data) => { - setBalance(data); - setBalanceState("ok"); - }) + const cmd = active === "deepseek" ? "fetch_balance" : "fetch_mimo_balance"; + void fetchWithCache(`dsm-balance-${active}`, () => invoke(cmd)) + .then((data) => { setBalance(data); setBalanceState("ok"); }) .catch((error) => { const message = typeof error === "string" ? error : "查询失败"; - setBalanceError(message); - setBalanceState(message.includes("未配置") ? "nokey" : "error"); + setBalance(null); setBalanceError(message); setBalanceState(message.includes("未配置") ? "nokey" : "error"); }); - }, []); + }, [provider]); - const loadUsage = React.useCallback(() => { + const loadUsage = React.useCallback((p?: Provider) => { + const active = p ?? provider; setUsageState("loading"); - void fetchCurrentUsage() - .then((data) => { - setUsage(data); - setUsageState("ok"); - setUsageError(""); - }) - .catch((error) => { - const message = typeof error === "string" ? error : "查询失败"; - setUsageError(message); - setUsage(null); - setUsageState(message.includes("未配置") ? "nokey" : "error"); - }); + if (active === "deepseek") { + void fetchWithCache("dsm-usage-deepseek", () => invoke("fetch_usage", { month: new Date().getMonth() + 1, year: new Date().getFullYear() }).then(async (current) => { + const now = new Date(); + const needsPrev = addDays(now, -6).getMonth() !== now.getMonth(); + if (!needsPrev) return current; + try { + const prev = previousMonth(now); + const prevUsage = await invoke("fetch_usage", { month: prev.month, year: prev.year }); + return { ...current, days: [...prevUsage.days, ...current.days] }; + } catch { return current; } + })) + .then((data) => { setUsage(data); setUsageState("ok"); setUsageError(""); }) + .catch((error) => { + const message = typeof error === "string" ? error : "查询失败"; setUsageError(message); setUsage(null); setUsageState(message.includes("未配置") ? "nokey" : "error"); + }); + } else { + const now = new Date(); + void fetchWithCache("dsm-usage-mimo", () => invoke("fetch_mimo_usage", { month: now.getMonth() + 1, year: now.getFullYear() })) + .then((data) => { setUsage(data); setUsageState("ok"); setUsageError(""); }) + .catch((error) => { + const message = typeof error === "string" ? error : "查询失败"; setUsageError(message); setUsage(null); setUsageState(message.includes("未配置") ? "nokey" : "error"); + }); + } + }, [provider]); + + const refreshAll = React.useCallback(() => { loadBalance(); loadUsage(); }, [loadBalance, loadUsage]); + + const setProvider = React.useCallback((next: Provider) => { + setProviderState(next); + setBalance(null); setBalanceState("loading"); + setUsage(null); setUsageState("loading"); + if (next === "mimo") void invoke("ensure_mimo_webview").catch(console.warn); + void invoke("set_provider", { provider: next }).catch(console.warn); + // loadBalance/loadUsage 由 useEffect 监听 provider 变化后统一调用,避免双重调用竞态 }, []); - const refreshAll = React.useCallback(() => { - loadBalance(); - loadUsage(); - }, [loadBalance, loadUsage]); + const providerRef = React.useRef(provider); + const initialLoadDone = React.useRef(false); React.useEffect(() => { - refreshAll(); - }, [refreshAll]); + if (providerRef.current !== provider) { providerRef.current = provider; loadBalance(provider); loadUsage(provider); } + }, [provider, loadBalance, loadUsage]); React.useEffect(() => { void invoke("get_app_config") .then((config) => { - setRefreshIntervalSeconds(config.refreshIntervalSeconds || 60); - setAutoRefreshEnabled(config.autoRefreshEnabled); + if (!initialLoadDone.current) { + initialLoadDone.current = true; + if (config.provider !== providerRef.current) { setBalance(null); setBalanceState("loading"); setUsage(null); setUsageState("loading"); } + providerRef.current = config.defaultProvider || config.provider; setProviderState(config.defaultProvider || config.provider); + setRefreshIntervalSeconds(config.refreshIntervalSeconds || 60); setAutoRefreshEnabled(config.autoRefreshEnabled); + setCurrency(config.currency || "cny"); + setEfficiencyUnit(config.efficiencyUnit || "currency_per_token"); + // Fetch exchange rate with localStorage cache (24h TTL) + const cached = localStorage.getItem("dsm-exrate-v2"); + if (cached) { + try { + const { rate, ts } = JSON.parse(cached); + if (Date.now() - ts < 24 * 3600 * 1000 && rate > 0) { setExchangeRate(rate); } + else { throw new Error("invalid or expired"); } + } catch { localStorage.removeItem("dsm-exrate-v2"); } + } + if (!localStorage.getItem("dsm-exrate-v2")) { + void fetch("https://open.er-api.com/v6/latest/CNY") + .then(r => r.json()) + .then(data => { + if (data?.rates?.USD) { + const rate = data.rates.USD; // e.g. 7.25 CNY per 1 USD + setExchangeRate(rate); + localStorage.setItem("dsm-exrate-v2", JSON.stringify({ rate, ts: Date.now() })); + } + }) + .catch(() => { /* keep default 7.25 */ }); + } + loadBalance(config.provider); loadUsage(config.provider); + } }) - .catch(() => { - setRefreshIntervalSeconds(60); - setAutoRefreshEnabled(false); - }); - }, []); + .catch(() => { if (!initialLoadDone.current) { initialLoadDone.current = true; setRefreshIntervalSeconds(60); setAutoRefreshEnabled(false); loadBalance(); loadUsage(); } }); + }, [loadBalance, loadUsage]); React.useEffect(() => { - if (!autoRefreshEnabled) { - return; - } + if (!autoRefreshEnabled) return; const timer = window.setInterval(refreshAll, refreshIntervalSeconds * 1000); return () => window.clearInterval(timer); }, [autoRefreshEnabled, refreshAll, refreshIntervalSeconds]); - const hideWindow = React.useCallback(() => { - void invoke("hide_main_window").catch(() => { - // Browser preview has no Tauri IPC. Keep it non-blocking for visual checks. + React.useEffect(() => { + const unlistenPromise = listen("mimo-auth-required", () => { + setUsageState("error"); setUsageError("MiMo 未登录,请在设置中重新登录小米账号"); + setBalanceState("error"); setBalanceError("MiMo 未登录"); }); + return () => { void unlistenPromise.then((unlisten) => unlisten()); }; }, []); + const hideWindow = React.useCallback(() => { void invoke("hide_main_window").catch(() => {}); }, []); + return (
{view === "dashboard" && ( setView("settings")} - onDetail={(nextModel) => { - setModel(nextModel); - setView("detail"); - }} + onDetail={(nextModel) => { setModel(nextModel); setView("detail"); }} + currency={currency} + exchangeRate={exchangeRate} + efficiencyUnit={efficiencyUnit} /> )} {view === "settings" && ( { - setUsage(nextUsage); - setUsageState("ok"); - setUsageError(""); - }} - onUsageCleared={() => { - setUsage(null); - setUsageState("nokey"); - setUsageError("未配置用量 Token"); - }} - onRefreshIntervalChanged={setRefreshIntervalSeconds} - onAutoRefreshChanged={setAutoRefreshEnabled} - onBack={() => setView("dashboard")} + provider={provider} onProviderChange={setProvider} onBack={() => setView("dashboard")} + onUsageLoaded={(nextUsage) => { setUsage(nextUsage); setUsageState("ok"); }} + onUsageCleared={() => { setUsage(null); setUsageState("loading"); }} + onRefreshIntervalChanged={setRefreshIntervalSeconds} onAutoRefreshChanged={setAutoRefreshEnabled} + onCurrencyChanged={setCurrency} + onEfficiencyUnitChanged={setEfficiencyUnit} /> )} {view === "detail" && ( - setView("dashboard")} /> + setView("dashboard")} provider={provider} currency={currency} exchangeRate={exchangeRate} efficiencyUnit={efficiencyUnit} /> )}
); } -function BrandIcon({ size = 32 }: { size?: number }) { - return ( -
- DeepSeek -
- ); -} - -function DashboardPanel({ - balance, - balanceState, - balanceError, - usage, - usageState, - usageError, - onRefresh, - onClose, - onSettings, - onDetail, -}: { - balance: BalanceData | null; - balanceState: BalanceState; - balanceError: string; - usage: UsageResult | null; - usageState: BalanceState; - usageError: string; - onRefresh: () => void; - onClose: () => void; - onSettings: () => void; - onDetail: (model: ModelName) => void; -}) { - const [theme, setTheme] = React.useState( - () => localStorage.getItem("ui-theme") || "dark", - ); - const toggleTheme = () => { - const next = theme === "dark" ? "light" : "dark"; - setTheme(next); - localStorage.setItem("ui-theme", next); - document.documentElement.setAttribute("data-theme", next); - }; - const flash = usage?.models.find((item) => item.key === "flash") ?? null; - const pro = usage?.models.find((item) => item.key === "pro") ?? null; - const maxTokens = Math.max(flash?.totalTokens ?? 0, pro?.totalTokens ?? 0, 1); - const today = usage?.days.find((day) => day.date === todayStr()) ?? null; - const todayCost = usageState === "ok" && today ? today.totalCost : null; - const monthCost = usageState === "ok" && usage ? usage.monthCost : null; - - return ( -
-
-
- -

DeepSeek Monitor

-
-
- -
- -
- - -
-
- - - -
- onDetail("flash")} - /> - onDetail("pro")} - /> -
- - -
- ); -} - -function BalanceCard({ - balance, - state, - error, - todayCost, - monthCost, -}: { - balance: BalanceData | null; - state: BalanceState; - error: string; - todayCost: number | null; - monthCost: number | null; -}) { - const symbol = balance?.currency === "USD" ? "$" : "¥"; - const amount = - state === "loading" - ? "查询中…" - : state === "nokey" - ? "未配置" - : state === "error" - ? "查询失败" - : `${symbol}${balance?.totalBalance ?? "0.00"}`; - const statusText = state === "ok" ? (balance?.isAvailable ? "可用" : "余额不足") : "—"; - const statusOff = state === "ok" && balance != null && !balance.isAvailable; - - return ( -
-
-
- - 账户余额 -
-
- - {statusText} -
-
-
{amount}
- {state === "error" &&
{error}
} -
-
-
- - 当日消耗 -
- {todayCost != null ? fmtMoney(todayCost) : "—"} -
-
-
- - 本月消费 -
- {monthCost != null ? fmtMoney(monthCost) : "—"} -
-
-
- ); -} - -function UsageRow({ - modelKey, - data, - maxTokens, - state, - onClick, -}: { - modelKey: ModelName; - data: UsageModel | null; - maxTokens: number; - state: BalanceState; - onClick: () => void; -}) { - const isFlash = modelKey === "flash"; - const name = isFlash ? "V4 Flash" : "V4 Pro"; - const tokensText = data - ? `${fmtInt(data.totalTokens)} Tokens` - : state === "loading" - ? "查询中…" - : state === "nokey" - ? "未配置 Token" - : state === "error" - ? "用量不可用" - : "—"; - const cost = data ? fmtMoney(data.cost) : "—"; - const ratio = data && data.cost > 0 ? `${fmtTokensShort(data.totalTokens / data.cost)} T/¥` : "—"; - const width = data ? `${Math.max(2, (data.totalTokens / maxTokens) * 100)}%` : "0%"; - - return ( - - ); -} - -function UsageChart({ - usage, - state, - error, -}: { - usage: UsageResult | null; - state: BalanceState; - error: string; -}) { - const [hoveredIdx, setHoveredIdx] = React.useState(null); - const MIN_BAR = 3; - const days = recentUsageDays(usage?.days ?? []); - const points = days.map((day) => { - // Flash 与 Pro 合并,不分模型 - const hit = day.flashCacheHit + day.proCacheHit; - const miss = day.flashCacheMiss + day.proCacheMiss; - const response = day.flashResponse + day.proResponse; - return { date: day.date, hit, miss, response, total: hit + miss + response }; - }); - const maxVal = Math.max(...points.map((point) => point.total), 1); - const sumHit = points.reduce((sum, point) => sum + point.hit, 0); - const sumMiss = points.reduce((sum, point) => sum + point.miss, 0); - const sumTotal = points.reduce((sum, point) => sum + point.total, 0); - const hitRate = sumHit + sumMiss > 0 ? ((sumHit / (sumHit + sumMiss)) * 100).toFixed(0) : "0"; - const placeholder = - state === "loading" - ? "查询中…" - : state === "nokey" - ? "未配置用量 Token" - : state === "error" - ? error - : "暂无数据"; - - return ( -
-
-
- - 缓存命中明细 -
- - {state === "ok" ? `命中率 ${hitRate}% · 合计 ${fmtTokensShort(sumTotal)}` : "—"} - -
- {state === "ok" && points.length > 0 ? ( - <> -
setHoveredIdx(null)}> - {points.map((point, idx) => ( -
- {hoveredIdx === idx && point.total > 0 && ( -
= points.length - 2 ? " align-right" : "" - }`} - > -
- {point.date} - {fmtInt(point.total)} tokens -
- - 输入(命中缓存) - {fmtInt(point.hit)} tokens - - - 输入(未命中缓存) - {fmtInt(point.miss)} tokens - - - 输出 - {fmtInt(point.response)} tokens - -
- )} - - {point.total > 0 ? fmtTokensShort(point.total) : "0"} - -
-
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%`, - }} - onMouseEnter={() => setHoveredIdx(idx)} - onMouseLeave={() => setHoveredIdx(null)} - > - {point.total > 0 ? ( - <> - {point.hit > 0 && } - {point.miss > 0 && } - {point.response > 0 && ( - - )} - - ) : ( - - )} -
-
- {mmdd(point.date)} -
- ))} -
-
- - 命中 - - - 未命中 - - - 输出 - -
- - ) : ( -
{placeholder}
- )} -
- ); -} - -function SettingsPanel({ - onBack, - onUsageLoaded, - onUsageCleared, - onRefreshIntervalChanged, - onAutoRefreshChanged, -}: { - onBack: () => void; - onUsageLoaded: (usage: UsageResult) => void; - onUsageCleared: () => void; - onRefreshIntervalChanged: (seconds: number) => void; - onAutoRefreshChanged: (enabled: boolean) => void; -}) { - const [apiKey, setApiKey] = React.useState(""); - const [config, setConfig] = React.useState(null); - const [status, setStatus] = React.useState("正在读取本地配置"); - const [busy, setBusy] = React.useState(false); - const [refresh, setRefresh] = React.useState(60); - const [autoRefresh, setAutoRefresh] = React.useState(false); - const [autostart, setAutostart] = React.useState(false); - const [usageToken, setUsageToken] = React.useState(""); - const [usageStatus, setUsageStatus] = React.useState(""); - const [usageSyncing, setUsageSyncing] = React.useState(false); - const [showManualPaste, setShowManualPaste] = React.useState(false); - const [appVersion, setAppVersion] = React.useState("1.1.0"); - const configPath = config?.configPath ?? "%APPDATA%\\DeepSeekMonitorWindows\\config.json"; - - React.useEffect(() => { - void invoke("get_app_config") - .then((nextConfig) => { - setConfig(nextConfig); - setRefresh(nextConfig.refreshIntervalSeconds || 60); - setAutoRefresh(nextConfig.autoRefreshEnabled); - setAutostart(nextConfig.autostart); - setStatus(nextConfig.apiKeyConfigured ? `已配置 ${nextConfig.apiKeyPreview}` : "未配置 API Key"); - setUsageStatus(nextConfig.usageTokenConfigured ? "用量 Token 已配置" : "未配置用量 Token"); - }) - .catch(() => { - setStatus("浏览器预览模式,未连接本地配置"); - }); - }, []); - - React.useEffect(() => { - void getVersion() - .then(setAppVersion) - .catch(() => setAppVersion("1.1.0")); - }, []); - - const refreshUsageAfterToken = React.useCallback( - (prefix: string) => { - setUsageStatus(`${prefix},正在刷新用量数据…`); - return fetchCurrentUsage() - .then((usage) => { - onUsageLoaded(usage); - setUsageStatus(`${prefix},本月消费 ${fmtMoney(usage.monthCost)}`); - return usage; - }) - .catch((error) => { - const message = typeof error === "string" ? error : "用量刷新失败"; - setUsageStatus(`${prefix},但用量刷新失败:${message}`); - throw error; - }); - }, - [onUsageLoaded], - ); - - React.useEffect(() => { - const unlistenPromise = listen("usage-token-captured", (event) => { - setConfig(event.payload); - setUsageSyncing(false); - void refreshUsageAfterToken("已通过网页登录自动同步用量 Token"); - }); - return () => { - void unlistenPromise.then((unlisten) => unlisten()); - }; - }, [refreshUsageAfterToken]); - - React.useEffect(() => { - const unlistenPromise = listen("usage-sync-ended", () => { - setUsageSyncing(false); - setUsageStatus("登录窗口已关闭,Token 未获取到。可重新点击同步或使用方式二手动粘贴。"); - }); - return () => { - void unlistenPromise.then((unlisten) => unlisten()); - }; - }, []); - - const pasteApiKey = React.useCallback(async () => { - try { - const text = await navigator.clipboard.readText(); - setApiKey(text.trim()); - setStatus("已从剪贴板读取"); - } catch { - setStatus("剪贴板读取失败"); - } - }, []); - - const saveApiKey = React.useCallback(() => { - setBusy(true); - void invoke("save_api_key", { apiKey }) - .then((nextConfig) => { - setConfig(nextConfig); - setApiKey(""); - setStatus("已保存,正在验证 Key…"); - return invoke("fetch_balance"); - }) - .then((balance) => { - const symbol = balance.currency === "USD" ? "$" : "¥"; - const tip = balance.isAvailable ? "" : "(余额不足)"; - setStatus(`验证通过,当前余额 ${symbol}${balance.totalBalance}${tip}`); - }) - .catch((error) => { - setStatus(typeof error === "string" ? error : "保存或验证失败"); - }) - .finally(() => setBusy(false)); - }, [apiKey]); - - const clearApiKey = React.useCallback(() => { - setBusy(true); - void invoke("clear_api_key") - .then((nextConfig) => { - setConfig(nextConfig); - setApiKey(""); - setStatus("已清除 API Key"); - }) - .catch((error) => { - setStatus(typeof error === "string" ? error : "清除失败"); - }) - .finally(() => setBusy(false)); - }, []); - - const pasteUsageToken = React.useCallback(async () => { - try { - const text = await navigator.clipboard.readText(); - setUsageToken(text.trim()); - setUsageStatus("已从剪贴板读取"); - } catch { - setUsageStatus("剪贴板读取失败"); - } - }, []); - - const startUsageSync = React.useCallback(() => { - setUsageSyncing(true); - setUsageStatus("正在打开登录窗口…"); - void invoke("start_usage_sync") - .then((synced) => { - if (!synced) { - setUsageStatus("登录完成后,再次点击本按钮即可同步用量(可多点几次)"); - } - // synced=true 时由 usage-token-captured 事件刷新数据并更新状态 - }) - .catch((error) => { - setUsageStatus(typeof error === "string" ? error : "打开登录窗口失败"); - }) - .finally(() => { - // 短暂忙碌后自动恢复可点击,允许用户登录后反复点击触发同步 - window.setTimeout(() => setUsageSyncing(false), 2500); - }); - }, []); - - const saveUsageToken = React.useCallback(() => { - setBusy(true); - void invoke("save_usage_token", { usageToken }) - .then((nextConfig) => { - setConfig(nextConfig); - setUsageToken(""); - setUsageStatus("已保存,正在验证用量 Token…"); - return refreshUsageAfterToken("手动 Token 已保存"); - }) - .catch((error) => { - setUsageStatus(typeof error === "string" ? error : "保存或验证失败"); - }) - .finally(() => setBusy(false)); - }, [refreshUsageAfterToken, usageToken]); - - const clearUsageToken = React.useCallback(() => { - setBusy(true); - void invoke("clear_usage_token") - .then((nextConfig) => { - setConfig(nextConfig); - setUsageToken(""); - setUsageStatus("已清除用量 Token"); - onUsageCleared(); - }) - .catch((error) => { - setUsageStatus(typeof error === "string" ? error : "清除失败"); - }) - .finally(() => setBusy(false)); - }, [onUsageCleared]); - - const saveRefreshInterval = React.useCallback( - (seconds: number) => { - const previous = refresh; - setRefresh(seconds); - onRefreshIntervalChanged(seconds); - void invoke("save_refresh_interval", { refreshIntervalSeconds: seconds }) - .then((nextConfig) => { - setConfig(nextConfig); - setRefresh(nextConfig.refreshIntervalSeconds || 60); - onRefreshIntervalChanged(nextConfig.refreshIntervalSeconds || 60); - }) - .catch(() => { - setRefresh(previous); - onRefreshIntervalChanged(previous); - }); - }, - [onRefreshIntervalChanged, refresh], - ); - - const saveAutoRefreshEnabled = React.useCallback( - (enabled: boolean) => { - const previous = autoRefresh; - setAutoRefresh(enabled); - onAutoRefreshChanged(enabled); - void invoke("save_auto_refresh_enabled", { autoRefreshEnabled: enabled }) - .then((nextConfig) => { - setConfig(nextConfig); - setAutoRefresh(nextConfig.autoRefreshEnabled); - onAutoRefreshChanged(nextConfig.autoRefreshEnabled); - }) - .catch(() => { - setAutoRefresh(previous); - onAutoRefreshChanged(previous); - }); - }, - [autoRefresh, onAutoRefreshChanged], - ); - - const saveAutostart = React.useCallback((enabled: boolean) => { - const previous = autostart; - setAutostart(enabled); - void invoke("save_autostart", { autostart: enabled }) - .then((nextConfig) => { - setConfig(nextConfig); - setAutostart(nextConfig.autostart); - }) - .catch(() => setAutostart(previous)); - }, [autostart]); - - return ( -
- -
-
- -
-

DeepSeek Monitor

-

设置

-
-
- - } title="API Key"> -

用于调用 DeepSeek API 获取余额和用量数据。当前 Windows 版本会保存在应用本地设置中。

-

API Key 只在当前这台 Windows 电脑本地保留。

-

- 本地位置: - {configPath} -

-
- setApiKey(event.target.value)} - /> -
-
- - - - {config?.apiKeyConfigured ? "已配置" : "未配置"} - - -
-
- - } title="用量同步 Token"> -

用于同步 Token 用量、消费和趋势图。DeepSeek 无官方用量 API,需网页登录 token(与上面的 API Key 不同)。

-

方式一网页登录自动同步

-
- - - - {config?.usageTokenConfigured ? "已配置" : "未配置"} - - -
-

{usageStatus}

- - {showManualPaste && ( - <> -

- 获取:浏览器登录 platform.deepseek.com,按 F12 打开控制台,输入 - JSON.parse(localStorage.userToken).value 回车,复制返回的字符串。 -

-

token 会过期,用量查询失败时重新获取一次即可。

-
- setUsageToken(event.target.value)} - /> -
-
- -
- - )} -
- - } title="开机自启"> -

开启后,每次登录 Windows 时自动启动 DeepSeek Monitor。

- -
- - } title="自动刷新"> -

开启后,按设定周期自动从 DeepSeek API 拉取最新数据。

- - {autoRefresh && ( -
- {refreshOptions.map((option) => ( - - ))} -
- )} -
- - } title="关于"> -
- 当前版本 - v{appVersion} -
-
- -
-
- ); -} - -function SettingsSection({ - icon, - title, - children, -}: { - icon: React.ReactNode; - title: string; - children: React.ReactNode; -}) { - return ( -
-

- {icon} - {title} -

- {children} -
- ); -} - -function Toggle({ - label, - checked, - onChange, -}: { - label: string; - checked: boolean; - onChange: (checked: boolean) => void; -}) { - return ( - - ); -} - -function ModelDetailPanel({ - model, - usage, - usageState, - onBack, -}: { - model: ModelName; - usage: UsageResult | null; - usageState: BalanceState; - onBack: () => void; -}) { - const isFlash = model === "flash"; - const data = usage?.models.find((item) => item.key === model) ?? null; - const title = isFlash ? "V4 Flash" : "V4 Pro"; - const tintClass = isFlash ? "flash" : "pro"; - const cost = data ? fmtMoney(data.cost) : "—"; - const totalText = data ? fmtTokensShort(data.totalTokens) : "—"; - - const days = recentUsageDays(usage?.days ?? []); - const points = days.map((day) => { - const hit = isFlash ? day.flashCacheHit : day.proCacheHit; - const miss = isFlash ? day.flashCacheMiss : day.proCacheMiss; - const response = isFlash ? day.flashResponse : day.proResponse; - return { date: day.date, hit, miss, response, total: hit + miss + response }; - }); - const maxVal = Math.max(...points.map((point) => point.total), 1); - const rangeText = - points.length > 0 ? `${mmdd(points[0].date)} - ${mmdd(points[points.length - 1].date)}` : ""; - - const [hoveredIdx, setHoveredIdx] = React.useState(null); - const MIN_BAR = 3; // 整根柱子的最小可见高度百分比(含空数据占位) - - return ( -
- -
-
- {isFlash ? : } -
-
-

{title}

-

{cost}

-
-
- -
-
- API 请求次数 - {data ? fmtInt(data.requestCount) : "—"} -
-
- Tokens - {totalText} -
-
- -
-
-
-

按日 Token 消耗

- {rangeText} -
-
- {usageState === "ok" && points.length > 0 ? ( - <> -
setHoveredIdx(null)}> - {points.map((point, idx) => ( -
- {hoveredIdx === idx && point.total > 0 && ( -
= points.length - 2 ? " align-right" : "" - }`} - > -
- {point.date} - {fmtInt(point.total)} tokens -
- - 输入(命中缓存) - {fmtInt(point.hit)} tokens - - - 输入(未命中缓存) - {fmtInt(point.miss)} tokens - - - 输出 - {fmtInt(point.response)} tokens - -
- )} - {point.total > 0 ? fmtTokensShort(point.total) : ""} -
- {/* 柱高按当天合计占最大值的比例;内部三段用 flex-grow 按真实 token 数分配,比例精确且永不溢出裁剪 */} -
0 ? Math.max(MIN_BAR, (point.total / maxVal) * 100) : MIN_BAR}%`, - }} - onMouseEnter={() => setHoveredIdx(idx)} - onMouseLeave={() => setHoveredIdx(null)} - > - {point.total > 0 ? ( - <> - {point.hit > 0 && } - {point.miss > 0 && } - {point.response > 0 && } - - ) : ( - - )} -
-
- {mmdd(point.date)} -
- ))} -
-
- 命中 - 未命中 - 输出 -
- - ) : ( -
- {usageState === "nokey" ? "未配置用量 Token" : usageState === "loading" ? "查询中…" : "暂无数据"} -
- )} -
-
- ); -} - -// Apply the saved theme before first render to avoid a flash of the wrong skin. -document.documentElement.setAttribute("data-theme", localStorage.getItem("ui-theme") || "dark"); -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +// ─── Mount ───────────────────────────────────────────────── +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/src/styles.css b/src/styles.css index 991c184..a7a8eeb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,6 +1,8 @@ :root { font-family: "Microsoft YaHei UI", "Segoe UI", system-ui, sans-serif; color: var(--text-strong); + + /* ---- 原始配色变量 ---- */ --fg: 246, 239, 222; --text-strong: rgba(255, 255, 255, 0.92); --brand: #4d6bfe; @@ -9,12 +11,31 @@ --pro: #da38f0; --orange: #ff9c2b; --skin-accent: #e0a838; - --panel-bg: rgba(105, 86, 38, 0.82); - --panel-bg-deep: rgba(49, 58, 18, 0.72); - --card-bg: rgba(38, 35, 18, 0.58); - --card-bg-light: rgba(110, 91, 42, 0.52); --text-muted: rgba(var(--fg), 0.62); --text-faint: rgba(var(--fg), 0.42); + + /* ---- 液态玻璃参数 ---- */ + --glass-blur: 42px; + --glass-radius: 22px; + --glass-border-width: 1px; + + --glass-panel-tint: rgba(110, 90, 45, 0.48); + --glass-panel-blend: rgba(160, 130, 60, 0.06); + --glass-panel-border: rgba(255, 235, 180, 0.32); + --glass-panel-highlight: rgba(255, 240, 200, 0.30); + --glass-panel-shadow-1: rgba(0, 0, 0, 0.15); + --glass-panel-shadow-2: rgba(0, 0, 0, 0.20); + --glass-panel-shadow-3: rgba(0, 0, 0, 0.08); + + --glass-card-tint: rgba(70, 60, 30, 0.40); + --glass-card-blend: rgba(120, 100, 50, 0.04); + --glass-card-border: rgba(255, 235, 180, 0.14); + --glass-card-highlight: rgba(255, 240, 210, 0.10); + --glass-card-shadow-1: rgba(0, 0, 0, 0.08); + --glass-card-shadow-2: rgba(0, 0, 0, 0.06); + + --glass-tooltip-tint: rgba(40, 36, 20, 0.82); + --glass-tooltip-blur: 16px; } * { @@ -50,20 +71,42 @@ button { .panel, .settings-panel { position: relative; - border: 1px solid rgba(255, 255, 255, 0.24); + isolation: isolate; + background-clip: padding-box; + + border: var(--glass-border-width) solid var(--glass-panel-border); background: - linear-gradient(145deg, rgba(147, 120, 55, 0.72), rgba(61, 76, 33, 0.68)), - rgba(97, 78, 36, 0.76); - box-shadow: inset 0 1px rgba(255, 255, 255, 0.12); - backdrop-filter: blur(26px) saturate(1.12); + radial-gradient(140% 120% at 30% 20%, transparent 35%, var(--glass-panel-blend) 100%), + var(--glass-panel-tint); + + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.15); + backdrop-filter: blur(var(--glass-blur)) saturate(1.15); + + box-shadow: + 0 0 0 1px var(--glass-panel-highlight) inset, + 0 8px 40px var(--glass-panel-shadow-1), + 0 2px 10px var(--glass-panel-shadow-2), + 0 1px 4px var(--glass-panel-shadow-3); +} + +.panel::before, +.settings-panel::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient(120% 100% at 15% 10%, rgba(255,248,230,0.08) 0%, transparent 65%); + pointer-events: none; + z-index: -1; } .panel { - width: min(356px, 100vw); - height: min(600px, 100vh); + width: 100%; + height: 100%; border-radius: 22px; padding: 0 16px 16px; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .dashboard-panel { @@ -93,12 +136,64 @@ button { letter-spacing: 0; } -/* 标题区子元素穿透点击,让 data-tauri-drag-region 拖拽生效(按钮不在这些容器内,不受影响) */ +.provider-toggle { + background: none; + border: none; + color: var(--fg); + font-size: 15px; + font-weight: 800; + letter-spacing: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + margin: 0; + pointer-events: auto; + transition: opacity 0.15s; +} + +.provider-toggle:hover { + opacity: 0.7; +} + +.provider-arrow { + font-size: 13px; + opacity: 0.5; + transition: opacity 0.15s; +} + +.provider-toggle:hover .provider-arrow { + opacity: 0.8; +} + +/* 设置页标题(静态,不可点击) */ +.settings-provider-title { + color: var(--fg); + font-size: 15px; + font-weight: 800; + letter-spacing: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + margin: 0; + pointer-events: auto; + user-select: none; +} + +/* 标题区子元素穿透点击,让 data-tauri-drag-region 拖拽生效 */ .title-lockup > *, .settings-header > *, .detail-hero > * { pointer-events: none; } +.title-lockup > .provider-toggle, +.settings-header > .provider-toggle, +.title-lockup > .settings-provider-title, +.settings-header > .settings-provider-title { + pointer-events: auto; +} .brand-icon { display: grid; @@ -133,16 +228,51 @@ button { height: 24px; padding: 0; border: 0; + border-radius: 8px; background: transparent; color: rgba(255, 255, 255, 0.9); cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; +} + +.header-actions button:hover { + background: rgba(var(--fg), 0.14); + box-shadow: inset 0 0 0 1px rgba(var(--fg), 0.12); +} + +.header-actions button:active { + background: rgba(var(--fg), 0.22); } .card { - background: linear-gradient(180deg, rgba(44, 38, 16, 0.68), rgba(36, 35, 16, 0.62)); - border: 1px solid rgba(255, 255, 255, 0.025); + position: relative; + isolation: isolate; + background-clip: padding-box; + + border: var(--glass-border-width) solid var(--glass-card-border); border-radius: 12px; - box-shadow: inset 0 1px rgba(255, 255, 255, 0.025); + + background: + radial-gradient(150% 120% at 30% 20%, transparent 40%, var(--glass-card-blend) 100%), + var(--glass-card-tint); + + -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.5)) saturate(1.06); + backdrop-filter: blur(calc(var(--glass-blur) * 0.5)) saturate(1.06); + + box-shadow: + 0 0 0 1px var(--glass-card-highlight) inset, + 0 4px 16px var(--glass-card-shadow-1), + 0 1px 4px var(--glass-card-shadow-2); +} + +.card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient(100% 80% at 20% 10%, rgba(255,248,230,0.04) 0%, transparent 60%); + pointer-events: none; + z-index: -1; } .balance-card { @@ -234,7 +364,8 @@ button { min-height: 56px; padding: 9px 12px; border-radius: 10px; - background: rgba(122, 101, 50, 0.46); + background: rgba(140, 118, 58, 0.38); + border: var(--glass-border-width) solid rgba(255,235,180,0.10); min-width: 0; overflow: hidden; display: flex; @@ -333,8 +464,8 @@ button { } .token-line span { - min-width: 88px; - max-width: 98px; + min-width: 130px; + max-width: 145px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -364,7 +495,7 @@ button { } .usage-price { - width: 55px; + width: 80px; text-align: right; } @@ -474,6 +605,10 @@ button { background: #a78bfa; } +.cache-bar .seg.mimo-tokens { + background: #60a5fa; +} + .cache-bar .seg.empty { flex-grow: 1; min-height: 0; @@ -486,11 +621,60 @@ button { font-weight: 800; } +/* 周导航按钮 */ +.chart-nav { + display: flex; + align-items: center; + gap: 4px; +} +.chart-nav-btn { + background: none; + border: 1px solid rgba(var(--fg), 0.15); + border-radius: 4px; + color: var(--text-strong); + font-size: 14px; + line-height: 1; + width: 22px; + height: 22px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.15s; +} +.chart-nav-btn:hover:not(:disabled) { + background: rgba(var(--fg), 0.1); +} +.chart-nav-btn:disabled { + opacity: 0.3; + cursor: default; +} +.chart-nav-label { + font-size: 10px; + color: var(--text-faint); + min-width: 36px; + text-align: center; +} + +/* 柱状图列整体可悬停(解决矮柱子难以 hover 的问题) */ +.bar-column, +.detail-bar-column { + cursor: default; +} + .settings-panel { - width: min(420px, 100vw); - height: min(620px, 100vh); + width: 100%; + height: 100%; border-radius: 22px; overflow: hidden; + + --text-strong: rgba(var(--fg), 0.90); + --text-muted: rgba(var(--fg), 0.75); + --text-faint: rgba(var(--fg), 0.60); + + --glass-panel-tint: rgba(110, 90, 45, 0.70); + --glass-card-tint: rgba(70, 60, 30, 0.60); } .settings-inner { @@ -556,6 +740,17 @@ button { color: var(--text-faint); } +.changelog-body h3 { margin: 14px 0 8px; font-size: 1.15em; color: var(--text-strong); } +.changelog-body summary { cursor: pointer; margin: 8px 0 4px; font-size: 1em; font-weight: 700; color: var(--text-strong); user-select: none; } +.changelog-body summary:hover { opacity: 0.8; } +.changelog-body details { margin-bottom: 6px; } +.changelog-body p { margin: 2px 0; } +.changelog-body ul { margin: 2px 0 6px; padding-left: 18px; } +.changelog-body li { margin: 1px 0; } +.changelog-body code { font-size: 0.92em; background: rgba(var(--fg), 0.1); padding: 1px 5px; border-radius: 4px; } +.changelog-body a { color: var(--brand-light); } +.changelog-body blockquote { border-left: 3px solid rgba(var(--fg), 0.25); margin: 6px 0; padding-left: 10px; color: var(--text-faint); } + .config-path { display: flex; flex-wrap: wrap; @@ -725,30 +920,6 @@ button:disabled { left: 24px; } -.segmented { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; - margin-top: 12px; -} - -.segmented button { - border: 0; - border-radius: 8px; - min-width: 0; - padding: 8px 6px; - background: rgba(var(--fg), 0.18); - color: white; - font-size: 13px; - font-weight: 900; - cursor: pointer; - white-space: nowrap; -} - -.segmented .selected { - background: var(--brand); -} - .back-button { width: 100%; margin-top: 2px; @@ -842,7 +1013,10 @@ button:disabled { flex-direction: column; border-radius: 18px; padding: 14px 18px 16px; - background: linear-gradient(180deg, rgba(26, 50, 21, 0.58), rgba(34, 41, 17, 0.62)); + background: rgba(50, 55, 25, 0.42); + border: var(--glass-border-width) solid rgba(255,235,180,0.10); + -webkit-backdrop-filter: blur(calc(var(--glass-blur) * 0.4)) saturate(1.04); + backdrop-filter: blur(calc(var(--glass-blur) * 0.4)) saturate(1.04); } .detail-chart-head { @@ -879,8 +1053,8 @@ button:disabled { grid-template-rows: 18px 1fr 20px; /* 柱顶数值 / 柱子区 / 底部日期 */ justify-items: center; align-items: stretch; + position: relative; } - .detail-bar-column span { color: rgba(var(--fg), 0.68); max-width: 100%; @@ -920,6 +1094,7 @@ button:disabled { .detail-bar-stacked .seg.hit { background: #34d399; } .detail-bar-stacked .seg.miss { background: var(--orange); } .detail-bar-stacked .seg.response { background: #a78bfa; } +.detail-bar-stacked .seg.mimo-tokens { background: #60a5fa; } .detail-bar-stacked .seg.empty { flex-grow: 1; /* 空数据占位段填满 MIN_BAR 高度的柱子 */ min-height: 0; @@ -928,10 +1103,6 @@ button:disabled { } /* ---------- 磨玻璃浮窗 ---------- */ -.detail-bar-column { - position: relative; -} - .bar-tooltip { position: absolute; bottom: 100%; @@ -945,9 +1116,10 @@ button:disabled { padding: 10px 12px; margin-bottom: 6px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.18); - background: rgba(30, 28, 16, 0.78); - backdrop-filter: blur(14px) saturate(1.2); + border: var(--glass-border-width) solid rgba(255, 255, 255, 0.18); + background: var(--glass-tooltip-tint); + -webkit-backdrop-filter: blur(var(--glass-tooltip-blur)) saturate(1.2); + backdrop-filter: blur(var(--glass-tooltip-blur)) saturate(1.2); font-size: 11px; font-weight: 700; line-height: 1.5; @@ -1047,6 +1219,10 @@ button:disabled { background: #a78bfa; } +.bar-tooltip-row .dot.mimo-tokens { + background: #60a5fa; +} + .bar-tooltip-row strong { float: none; margin-left: auto; @@ -1084,6 +1260,7 @@ button:disabled { .chart-legend-item .dot.hit { background: #34d399; } .chart-legend-item .dot.miss { background: var(--orange); } .chart-legend-item .dot.response { background: #a78bfa; } +.chart-legend-item .dot.mimo-tokens { background: #60a5fa; } .detail-bar-column em { color: var(--text-faint); @@ -1102,21 +1279,23 @@ button:disabled { --pro: #c02fde; --orange: #ef8400; --skin-accent: #2d6cf6; -} -[data-theme="light"] .panel, -[data-theme="light"] .settings-panel { - border-color: rgba(255, 255, 255, 0.55); - background: - linear-gradient(150deg, rgba(150, 205, 236, 0.85), rgba(172, 224, 206, 0.82)), - rgba(216, 233, 239, 0.92); - box-shadow: inset 0 1px rgba(255, 255, 255, 0.6); -} + --glass-panel-tint: rgba(210, 230, 245, 0.68); + --glass-panel-blend: rgba(180, 220, 240, 0.12); + --glass-panel-border: rgba(255, 255, 255, 0.55); + --glass-panel-highlight: rgba(255, 255, 255, 0.50); + --glass-panel-shadow-1: rgba(40, 80, 120, 0.10); + --glass-panel-shadow-2: rgba(40, 80, 120, 0.14); + --glass-panel-shadow-3: rgba(40, 80, 120, 0.05); -[data-theme="light"] .card { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.42)); - border-color: rgba(255, 255, 255, 0.6); - box-shadow: inset 0 1px rgba(255, 255, 255, 0.7), 0 2px 8px rgba(40, 80, 120, 0.08); + --glass-card-tint: rgba(235, 245, 252, 0.55); + --glass-card-blend: rgba(255, 255, 255, 0.08); + --glass-card-border: rgba(255, 255, 255, 0.55); + --glass-card-highlight: rgba(255, 255, 255, 0.55); + --glass-card-shadow-1: rgba(40, 80, 120, 0.06); + --glass-card-shadow-2: rgba(40, 80, 120, 0.04); + + --glass-tooltip-tint: rgba(245, 248, 252, 0.94); } [data-theme="light"] .header-actions button, @@ -1151,6 +1330,7 @@ button:disabled { } [data-theme="light"] .mini-card { background: rgba(255, 255, 255, 0.52); + border-color: rgba(255, 255, 255, 0.40); } [data-theme="light"] .cache-hit-rate.flash { @@ -1185,13 +1365,20 @@ button:disabled { background: #8b5cf6; } +[data-theme="light"] .detail-bar-stacked .seg.mimo-tokens, +[data-theme="light"] .cache-bar .seg.mimo-tokens, +[data-theme="light"] .chart-legend-item .dot.mimo-tokens, +[data-theme="light"] .bar-tooltip-row .dot.mimo-tokens { + background: #3b82f6; +} + [data-theme="light"] .detail-chart { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.34)); + background: rgba(230, 242, 250, 0.50); + border-color: rgba(255, 255, 255, 0.40); } [data-theme="light"] .bar-tooltip { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(40, 70, 100, 0.14); + border-color: rgba(40, 70, 100, 0.10); } [data-theme="light"] .bar-tooltip-total { border-top-color: rgba(40, 70, 100, 0.14); @@ -1203,6 +1390,11 @@ button:disabled { border-top-color: rgba(40, 70, 100, 0.12); } +[data-theme="light"] .settings-panel { + --glass-panel-tint: rgba(210, 230, 245, 0.85); + --glass-card-tint: rgba(235, 245, 252, 0.75); +} + [data-theme="light"] .settings-section { border-top-color: rgba(40, 70, 100, 0.14); } @@ -1220,12 +1412,7 @@ button:disabled { [data-theme="light"] .toggle-row input:checked + i { background: #2d6cf6; } -[data-theme="light"] .segmented button { - color: #1a2a40; -} -[data-theme="light"] .segmented .selected { - color: #fff; -} + /* 皮肤选择按钮与弹窗 */ .skin-menu-wrap { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..120f2b3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,102 @@ +export type ViewName = "dashboard" | "settings" | "detail"; +export type ModelName = "flash" | "pro" | (string & {}); +export type Provider = "deepseek" | "mimo"; + +export type AppConfig = { + apiKeyConfigured: boolean; + apiKeyPreview: string | null; + usageTokenConfigured: boolean; + provider: Provider; + mimoTokenConfigured: boolean; + refreshIntervalSeconds: number; + autoRefreshEnabled: boolean; + autostart: boolean; + configPath: string; + lowBalanceNotify: boolean; + lowBalanceThreshold: number; + theme: "light" | "dark" | "system"; + currency: "cny" | "usd"; + efficiencyUnit: "token_per_currency" | "currency_per_token"; + defaultProvider: Provider; + mimoRefreshIntervalSeconds: number; + notifyCooldownMinutes: number; +}; + +export type BalanceData = { + isAvailable: boolean; + currency: string; + totalBalance: string; + grantedBalance: string; + toppedUpBalance: string; +}; + +export type BalanceState = "loading" | "ok" | "error" | "nokey"; + +export type UsageModel = { + key: string; + name: string; + totalTokens: number; + requestCount: number; + cacheHitTokens: number; + cacheMissTokens: number; + responseTokens: number; + cost: number; +}; + +export type UsageDay = { + date: string; + flashTokens: number; + flashCacheHit: number; + flashCacheMiss: number; + flashResponse: number; + proTokens: number; + proCacheHit: number; + proCacheMiss: number; + proResponse: number; + totalTokens: number; + totalCost: number; +}; + +export type UsageResult = { + models: UsageModel[]; + days: UsageDay[]; + monthCost: number; +}; + +export type MimoBalanceData = { + availableBalance: string; + currency: string; + totalConsumption: string; + monthlyExpense: string; +}; + +export type MimoUsageModel = { + key: string; + name: string; + totalTokens: number; + requestCount: number; + cacheHitTokens: number; + cacheMissTokens: number; + responseTokens: number; + cost: number; +}; + +export type MimoUsageDay = { + date: string; + totalTokens: number; + totalCost: number; + models: Array<{ + key: string; + totalTokens: number; + cacheHitTokens: number; + cacheMissTokens: number; + responseTokens: number; + totalCost: number; + }>; +}; + +export type MimoUsageResult = { + models: MimoUsageModel[]; + days: MimoUsageDay[]; + monthCost: number; +}; diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..3b27b0f --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { fmtInt, fmtTokensShort, fmtMoney, mmdd, todayStr, dateKey, addDays, modelDisplayName, modelIcon } from "./utils"; + +describe("fmtInt", () => { + it("formats integers with locale separators", () => { + expect(fmtInt(102614051)).toBe("102,614,051"); + expect(fmtInt(0)).toBe("0"); + expect(fmtInt(999)).toBe("999"); + }); + it("rounds decimals", () => { + expect(fmtInt(1234.6)).toBe("1,235"); + expect(fmtInt(1234.4)).toBe("1,234"); + }); +}); + +describe("fmtTokensShort", () => { + it("formats millions", () => { + expect(fmtTokensShort(102614051)).toBe("103M"); + expect(fmtTokensShort(1500000)).toBe("1.5M"); + }); + it("formats thousands", () => { + expect(fmtTokensShort(425581)).toBe("425.6K"); + expect(fmtTokensShort(5000)).toBe("5.0K"); + }); + it("formats small numbers", () => { + expect(fmtTokensShort(100)).toBe("100"); + expect(fmtTokensShort(0)).toBe("0"); + }); +}); + +describe("fmtMoney", () => { + it("formats with yen symbol and 2 decimals", () => { + expect(fmtMoney(4.637495)).toBe("¥4.64"); + expect(fmtMoney(0)).toBe("¥0.00"); + expect(fmtMoney(42.55)).toBe("¥42.55"); + }); +}); + +describe("mmdd", () => { + it("extracts month/day from YYYY-MM-DD", () => { + expect(mmdd("2026-06-25")).toBe("6/25"); + expect(mmdd("2026-01-01")).toBe("1/1"); + }); + it("returns original if format is wrong", () => { + expect(mmdd("invalid")).toBe("invalid"); + }); +}); + +describe("todayStr", () => { + it("returns YYYY-MM-DD format", () => { + const result = todayStr(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe("dateKey", () => { + it("formats Date to YYYY-MM-DD", () => { + expect(dateKey(new Date(2026, 5, 25))).toBe("2026-06-25"); + expect(dateKey(new Date(2026, 0, 1))).toBe("2026-01-01"); + }); +}); + +describe("addDays", () => { + it("adds days correctly", () => { + const base = new Date(2026, 5, 25); + expect(dateKey(addDays(base, 1))).toBe("2026-06-26"); + expect(dateKey(addDays(base, -1))).toBe("2026-06-24"); + expect(dateKey(addDays(base, 0))).toBe("2026-06-25"); + }); + it("handles month boundaries", () => { + const base = new Date(2026, 5, 1); + expect(dateKey(addDays(base, -1))).toBe("2026-05-31"); + }); +}); + +describe("modelDisplayName", () => { + it("maps known keys", () => { + expect(modelDisplayName("mimo-v2.5")).toBe("V2.5"); + expect(modelDisplayName("mimo-v2.5-pro")).toBe("V2.5 Pro"); + }); + it("returns original for unknown keys", () => { + expect(modelDisplayName("unknown")).toBe("unknown"); + }); +}); + +describe("modelIcon", () => { + it("returns pro for pro models", () => { + expect(modelIcon("mimo-v2.5-pro")).toBe("pro"); + expect(modelIcon("flash-pro")).toBe("pro"); + }); + it("returns flash for non-pro models", () => { + expect(modelIcon("mimo-v2.5")).toBe("flash"); + expect(modelIcon("flash")).toBe("flash"); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..2d2db62 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,101 @@ +import type { UsageDay } from "./types"; + +export const REFRESH_OPTIONS = [ + { label: "1 分钟", value: 60 }, + { label: "5 分钟", value: 300 }, + { label: "30 分钟", value: 1800 }, + { label: "1 小时", value: 3600 }, +]; + +export const fmtInt = (n: number) => Math.round(n).toLocaleString("en-US"); + +export const fmtTokensShort = (n: number) => { + if (n >= 1e8) return (n / 1e6).toFixed(0) + "M"; + if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; + return String(Math.round(n)); +}; + +export const fmtMoney = (n: number, currency?: string, rate?: number) => { + if (currency === "usd" && rate && rate > 0) { + return "$" + (n * rate).toFixed(2); + } + return "¥" + n.toFixed(2); +}; + +export const mmdd = (date: string) => { + const parts = date.split("-"); + return parts.length === 3 ? `${Number(parts[1])}/${Number(parts[2])}` : date; +}; + +export const todayStr = () => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +}; + +export const dateKey = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + +export const addDays = (date: Date, offset: number) => { + const next = new Date(date); + next.setDate(next.getDate() + offset); + return next; +}; + +export const recentUsageDays = (days: UsageDay[], count = 7): UsageDay[] => { + const source = new Map(days.filter((day) => day.date <= todayStr()).map((day) => [day.date, day])); + const today = new Date(); + return Array.from({ length: count }, (_, index) => { + const date = dateKey(addDays(today, index - count + 1)); + return ( + source.get(date) ?? { + date, + flashTokens: 0, + flashCacheHit: 0, + flashCacheMiss: 0, + flashResponse: 0, + proTokens: 0, + proCacheHit: 0, + proCacheMiss: 0, + proResponse: 0, + totalTokens: 0, + totalCost: 0, + } + ); + }); +}; + +export const previousMonth = (date: Date) => { + const previous = new Date(date.getFullYear(), date.getMonth() - 1, 1); + return { month: previous.getMonth() + 1, year: previous.getFullYear() }; +}; + +export const modelDisplayName = (key: string): string => { + const map: Record = { + "mimo-v2.5": "V2.5", + "mimo-v2.5-pro": "V2.5 Pro", + }; + return map[key] ?? key; +}; + +export const modelIcon = (key: string): "flash" | "pro" | "mimo" => { + if (key.startsWith("mimo-")) return "mimo"; + if (key.includes("pro")) return "pro"; + return "flash"; +}; + +/** 通用缓存工具:成功写入 localStorage,失败时回退到缓存 */ +export async function fetchWithCache(key: string, fetcher: () => Promise): Promise { + try { + const data = await fetcher(); + try { localStorage.setItem(key, JSON.stringify(data)); } catch {} + return data; + } catch (error) { + try { + const cached = localStorage.getItem(key); + if (cached) return JSON.parse(cached) as T; + } catch {} + throw error; + } +} +