mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
feat: 桌面客户端打包 (pywebview + PyInstaller) + 系统通知
- 桌面入口: desktop.py (单实例锁 + 端口探测 + uvicorn + pywebview 窗口) - 配置层: config.py 加 sys.frozen 检测, frozen 下 data_dir 指向用户目录 - 系统通知: notify_adapter.py 三平台 (Win winotify / mac osascript / Linux notify-send) 接入 quote_service 告警出口, preferences 开关控制, 前端监控设置加 toggle - 前端: 监控设置加系统通知开关, 系统设置加检查更新入口 - 打包: packaging/tickflow.spec (onedir, collect-all 原生库, 排除 vectorbt) - CI: .github/workflows/release.yml 手动触发 + 平台可选 (windows/macos/linux) - 依赖: platformdirs/winotify/plyer 入主依赖, pywebview 入 desktop extras - README 加桌面客户端下载章节 - bump 版本号到 0.1.33
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
name: 桌面客户端发布
|
||||
|
||||
# 触发方式: 手动 (workflow_dispatch)。
|
||||
# 在 GitHub Actions 页面点 "Run workflow", 选择要构建的平台 + 版本号,
|
||||
# 构建完成后自动创建/更新 Release 并上传安装包。
|
||||
#
|
||||
# 为什么不用 tag 自动触发:
|
||||
# - 多平台并行构建, mac/linux 未就绪时会自动失败污染 Release
|
||||
# - 手动触发可精确控制 "今天只发 Windows, 明天补 mac"
|
||||
# - 版本号通过 input 传入, 与 tag 解耦, 更灵活
|
||||
#
|
||||
# 使用:
|
||||
# gh workflow run release.yml -f version=v0.2.0 -f platforms=windows
|
||||
# 或 GitHub 网页 Actions → 桌面客户端发布 → Run workflow
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: '版本号 (如 v0.1.33, 用作 Release tag 和标题)'
|
||||
required: true
|
||||
default: 'v0.1.33'
|
||||
platforms:
|
||||
description: '要构建的平台 (逗号分隔: windows,macos,linux)'
|
||||
required: true
|
||||
default: 'windows'
|
||||
prerelease:
|
||||
description: '是否为预发布 (勾选则标记为 Pre-release)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write # 创建 Release 需要写权限
|
||||
|
||||
jobs:
|
||||
# 第一步: 过滤出本次要构建的平台
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.filter.outputs.matrix }}
|
||||
steps:
|
||||
- id: filter
|
||||
run: |
|
||||
REQUESTED="${{ github.event.inputs.platforms }}"
|
||||
ALL='[{"os":"windows-latest","artifact":"TickFlowStockPanel-win-x64.zip","platform":"windows"},{"os":"macos-latest","artifact":"TickFlowStockPanel-macos.zip","platform":"macos"},{"os":"ubuntu-latest","artifact":"TickFlowStockPanel-linux-x64.tar.gz","platform":"linux"}]'
|
||||
# 按 input 过滤平台
|
||||
python3 -c "
|
||||
import json, sys
|
||||
requested = '${{ github.event.inputs.platforms }}'.split(',')
|
||||
requested = [r.strip() for r in requested if r.strip()]
|
||||
all_platforms = json.loads('''$ALL''')
|
||||
selected = [p for p in all_platforms if p['platform'] in requested]
|
||||
print('matrix=' + json.dumps({'include': selected}))
|
||||
with open('$GITHUB_OUTPUT', 'a') as f:
|
||||
f.write('matrix=' + json.dumps({'include': selected}) + '\n')
|
||||
"
|
||||
|
||||
# 第二步: 各平台并行构建
|
||||
build:
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false # 单平台失败不影响其他平台
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 安装 pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: 设置 Node 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: 构建前端
|
||||
working-directory: frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: 安装 uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: 设置 Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: 安装后端依赖 (含 desktop, 不含 backtest)
|
||||
working-directory: backend
|
||||
# --no-dev 排除 pytest/ruff/mypy; --extra desktop 装 pywebview
|
||||
# 不加 --extra backtest, 主包不含 vectorbt/numba/llvmlite
|
||||
run: uv sync --no-dev --extra desktop
|
||||
|
||||
- name: 安装 PyInstaller
|
||||
run: uv pip install pyinstaller
|
||||
|
||||
- name: 打包 (PyInstaller)
|
||||
run: uv run pyinstaller packaging/tickflow.spec --noconfirm
|
||||
|
||||
- name: 压缩产物 (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
cd dist
|
||||
7z a -tzip ../${{ matrix.artifact }} TickFlowStockPanel
|
||||
shell: bash
|
||||
|
||||
- name: 压缩产物 (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
cd dist
|
||||
zip -r -q ../${{ matrix.artifact }} TickFlowStockPanel
|
||||
|
||||
- name: 压缩产物 (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
cd dist
|
||||
tar -czf ../${{ matrix.artifact }} TickFlowStockPanel
|
||||
|
||||
- name: 上传产物为构建产物 (备查)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.artifact }}
|
||||
|
||||
- name: 上传到 GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version }}
|
||||
name: ${{ github.event.inputs.version }}
|
||||
files: ${{ matrix.artifact }}
|
||||
prerelease: ${{ github.event.inputs.prerelease }}
|
||||
generate_release_notes: true # 自动从 commit 生成 changelog
|
||||
body: |
|
||||
## 桌面客户端 ${{ github.event.inputs.version }}
|
||||
|
||||
### 下载
|
||||
选择对应平台的安装包, 解压后双击运行:
|
||||
|
||||
| 平台 | 文件 |
|
||||
| :--- | :--- |
|
||||
| **Windows** | `TickFlowStockPanel-win-x64.zip` → 解压运行 `TickFlowStockPanel.exe` |
|
||||
| **macOS** | `TickFlowStockPanel-macos.zip` → 解压后右键打开 (绕过 Gatekeeper) |
|
||||
| **Linux** | `TickFlowStockPanel-linux-x64.tar.gz` → 解压运行 |
|
||||
|
||||
### 说明
|
||||
- 数据存储在用户目录 (Windows `%LOCALAPPDATA%`、mac `~/Library`、Linux `~/.local/share`), 卸载重装不丢数据
|
||||
- 含纯 Polars 回测引擎; 不含 vectorbt 回测 (为控制体积)
|
||||
- 系统通知: 设置 → 实时监控 → 系统通知
|
||||
- 检查更新: 设置 → 系统设置 → 关于
|
||||
@@ -89,3 +89,10 @@ data/strategies/custom/
|
||||
|
||||
# ===== 临时 / 调试产物 =====
|
||||
backend._recheck*.py
|
||||
backend/_verify*.py
|
||||
backend/_desktop_run.log
|
||||
|
||||
# ===== 打包产物 (本地构建的安装包,不入库) =====
|
||||
backend/TickFlowStockPanel-win-x64.zip
|
||||
backend/TickFlowStockPanel-macos.zip
|
||||
backend/TickFlowStockPanel-linux-x64.tar.gz
|
||||
|
||||
@@ -172,7 +172,24 @@ docker compose up --build
|
||||
# 打开 http://localhost:3018
|
||||
```
|
||||
|
||||
### 方式 B:Dev 模式(二次开发)
|
||||
### 方式 B:桌面客户端(免环境,开箱即用)
|
||||
|
||||
下载对应平台的安装包,解压后双击运行即可,**无需 Python / Node 环境**。
|
||||
|
||||
| 平台 | 文件 |
|
||||
| :--- | :--- |
|
||||
| **Windows** | `TickFlowStockPanel-win-x64.zip` → 解压后运行 `TickFlowStockPanel.exe` |
|
||||
| **macOS** | `TickFlowStockPanel-macos.zip` → 解压后运行 app(首次打开右键→打开,绕过 Gatekeeper) |
|
||||
| **Linux** | `TickFlowStockPanel-linux-x64.tar.gz` → 解压后运行可执行文件 |
|
||||
|
||||
下载地址:**[GitHub Releases](https://github.com/shy3130/tickflow-stock-panel/releases/latest)**
|
||||
|
||||
> - 桌面版数据存储在用户目录(Windows `%APPDATA%`、macOS `~/Library/Application Support`、Linux `~/.local/share`),卸载重装数据不丢失。
|
||||
> - 桌面版**不含 vectorbt 回测引擎**(为控制体积);纯 Polars 回测引擎照常可用。
|
||||
> - 支持系统通知:在「设置 → 实时监控 → 系统通知」开启,监控告警会推送到操作系统通知中心。
|
||||
> - 版本更新:在「设置 → 系统设置 → 关于」点「检查更新」跳转 Release 页下载新版。
|
||||
|
||||
### 方式 C:Dev 模式(二次开发)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填 TICKFLOW_API_KEY,留空则启用 Free 试用
|
||||
|
||||
@@ -225,6 +225,7 @@ def get_preferences() -> dict:
|
||||
"sse_refresh_pages": preferences.get_sse_refresh_pages(),
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
@@ -385,6 +386,22 @@ class QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
|
||||
class SystemNotifyPrefsIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/system-notify")
|
||||
def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。
|
||||
|
||||
纯偏好, 无副作用 (不像策略监控要迁移规则), 直接落盘即可。
|
||||
quote_service 在每轮告警评估时读此开关决定是否发系统通知。
|
||||
"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_system_notify_enabled(req.enabled)
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
|
||||
+54
-10
@@ -1,19 +1,63 @@
|
||||
"""全局配置 — 从环境变量 / .env 读取。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# 项目根目录 = backend/ 的父目录
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
_PROJECT_ROOT = _BACKEND_DIR.parent
|
||||
# ── 运行环境检测 ──────────────────────────────────────────
|
||||
# PyInstaller 打包后: __file__ 指向临时解压目录 _MEIPASS, 不能作为路径基准。
|
||||
# 此时:
|
||||
# - 只读资源 (tiers.yaml / 前端 dist) 放在 _MEIPASS 内
|
||||
# - 可写用户数据 (data_dir) 放在可执行文件旁的用户目录
|
||||
# 非 frozen 模式 (开发/Docker): 保持原有 __file__ 推导, 行为完全不变。
|
||||
_IS_FROZEN = getattr(sys, "frozen", False)
|
||||
|
||||
|
||||
def _user_data_root() -> Path:
|
||||
"""桌面版用户数据根目录 (跨平台持久可写)。
|
||||
|
||||
Windows: %LOCALAPPDATA%/TickFlowStockPanel/TickFlowStockPanel
|
||||
macOS: ~/Library/Application Support/TickFlowStockPanel
|
||||
Linux: ~/.local/share/TickFlowStockPanel
|
||||
|
||||
注意: platformdirs 已含应用名, 切勿再拼一层。
|
||||
"""
|
||||
try:
|
||||
from platformdirs import user_data_dir
|
||||
|
||||
return Path(user_data_dir("TickFlowStockPanel"))
|
||||
except Exception: # noqa: BLE001
|
||||
# platformdirs 不可用时兜底: 可执行文件旁的 data/
|
||||
return Path(sys.executable).resolve().parent / "data"
|
||||
|
||||
|
||||
def _resource_root() -> Path:
|
||||
"""只读资源根目录。
|
||||
|
||||
frozen: PyInstaller 解压目录 (_MEIPASS)
|
||||
非 frozen: 项目根目录 (源码树)
|
||||
"""
|
||||
if _IS_FROZEN:
|
||||
# sys._MEIPASS 是 PyInstaller 注入的解压根
|
||||
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
"""项目根目录 (非 frozen 用)。"""
|
||||
return Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
_PROJECT_ROOT = _project_root()
|
||||
_RESOURCE_ROOT = _resource_root()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(_PROJECT_ROOT / ".env"),
|
||||
env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
@@ -34,14 +78,14 @@ class Settings(BaseSettings):
|
||||
log_level: str = "INFO"
|
||||
backtest_range_guard: bool = False
|
||||
|
||||
# Data — 默认使用项目根目录的 data/,可通过 DATA_DIR 环境变量覆盖
|
||||
data_dir: Path = _PROJECT_ROOT / "data"
|
||||
# Data — frozen: 用户数据目录; 非 frozen: 项目根目录的 data/ (可被 DATA_DIR 覆盖)
|
||||
data_dir: Path = _user_data_root() if _IS_FROZEN else (_PROJECT_ROOT / "data")
|
||||
|
||||
# tiers.yaml 路径(项目根目录)
|
||||
tiers_yaml: Path = _PROJECT_ROOT / "tiers.yaml"
|
||||
# tiers.yaml 路径 — frozen: 资源目录内; 非 frozen: 项目根目录
|
||||
tiers_yaml: Path = _RESOURCE_ROOT / "tiers.yaml" if _IS_FROZEN else _PROJECT_ROOT / "tiers.yaml"
|
||||
|
||||
# 静态文件(前端 dist) — 部署时只需 rsync 到 frontend/dist
|
||||
static_dir: Path = _PROJECT_ROOT / "frontend" / "dist"
|
||||
# 静态文件(前端 dist) — frozen: 资源目录的 static/; 非 frozen: frontend/dist
|
||||
static_dir: Path = _RESOURCE_ROOT / "static" if _IS_FROZEN else (_PROJECT_ROOT / "frontend" / "dist")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _resolve_paths(self) -> Settings:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""桌面客户端入口 — uvicorn 后台服务 + pywebview 桌面窗口。
|
||||
|
||||
运行方式:
|
||||
开发模式: python -m app.desktop (需 pip install pywebview)
|
||||
打包后: 双击可执行文件即可
|
||||
|
||||
职责:
|
||||
1. 单实例锁 — 已运行则聚焦已有窗口并退出
|
||||
2. 选可用端口 — 从 settings.port 起, 被占则递增
|
||||
3. 后台线程起 uvicorn (仅监听 127.0.0.1, 不暴露外网)
|
||||
4. 主线程起 pywebview 窗口渲染前端
|
||||
5. 窗口关闭 → 优雅停止 uvicorn → 进程退出
|
||||
|
||||
不含: 业务逻辑、配置持久化、监控告警 (全在 app.main 里)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_APP_NAME = "TickFlow 股票面板"
|
||||
_BASE_PORT = 3018
|
||||
_PORT_PROBE_RANGE = 50 # 从 3018 起最多试 50 个端口
|
||||
|
||||
|
||||
def _ensure_data_dir_writable() -> None:
|
||||
"""确保用户数据目录可写 (lifespan 会创建子目录, 这里只验证根目录)。
|
||||
|
||||
data_dir 在 frozen 模式下指向用户目录 (见 config.py), 非可写会导致
|
||||
DuckDB 视图 / parquet 落盘全失败。提前失败胜过启动后乱报错。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
data_root = settings.data_dir
|
||||
try:
|
||||
data_root.mkdir(parents=True, exist_ok=True)
|
||||
probe = data_root / ".write_probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("数据目录不可写, 桌面版无法运行: %s (%s)", data_root, e)
|
||||
raise
|
||||
|
||||
|
||||
def _acquire_single_instance() -> bool:
|
||||
"""单实例锁。已运行返回 False (本进程应退出), 否则 True。
|
||||
|
||||
用 data_dir/.desktop.lock 文件锁实现。跨进程, 文件存在即视为已运行
|
||||
(简单可靠; 不引入 msvcrt/fcntl 平台差异)。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
if lock_path.exists():
|
||||
# 软检测: 写入进程 PID, 若该 PID 已不存在则视为残留锁, 允许接管
|
||||
try:
|
||||
pid_str = lock_path.read_text(encoding="utf-8").strip()
|
||||
pid = int(pid_str) if pid_str.isdigit() else None
|
||||
except Exception: # noqa: BLE001
|
||||
pid = None
|
||||
|
||||
if pid is not None and _pid_alive(pid):
|
||||
logger.warning("检测到已有实例运行 (PID %d), 本进程退出", pid)
|
||||
return False
|
||||
# 残留锁: 清理后继续
|
||||
logger.info("清理残留单实例锁 (PID %s 已不存在)", pid)
|
||||
|
||||
lock_path.write_text(str(_current_pid()), encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _release_single_instance() -> None:
|
||||
from app.config import settings
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""检查指定 PID 的进程是否存活。"""
|
||||
import os
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows: 0 表示存在, 其它是异常
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, 0) # signal 0 = 探测存活, 不实际发信号
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _current_pid() -> int:
|
||||
import os
|
||||
|
||||
return os.getpid()
|
||||
|
||||
|
||||
def _find_free_port(start: int, count: int = _PORT_PROBE_RANGE) -> int:
|
||||
"""从 start 起找第一个可用端口。全部被占则返回 start (交给 uvicorn 报错)。"""
|
||||
for port in range(start, start + count):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
return start
|
||||
|
||||
|
||||
def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
|
||||
"""后台线程: 启动 uvicorn 服务。ready_event 在线程退出时置位 (通知主线程)。"""
|
||||
import uvicorn
|
||||
|
||||
# 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成)
|
||||
from app.main import app
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1", # 仅本机, 不暴露外网 (桌面版无需远程访问)
|
||||
port=port,
|
||||
log_level="info",
|
||||
access_log=False, # 桌面版不需要访问日志
|
||||
loop="auto",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# 线程结束时通知主线程 (无论正常退出还是异常)
|
||||
def _signal_done(*exc):
|
||||
ready_event.set()
|
||||
server.config.callback_notify = None # 不用 notify 机制
|
||||
|
||||
try:
|
||||
server.run()
|
||||
finally:
|
||||
ready_event.set()
|
||||
|
||||
|
||||
def _wait_for_server(port: int, timeout: float = 60.0) -> bool:
|
||||
"""轮询 health 接口直到后端就绪或超时。
|
||||
|
||||
比 monkey-patch uvicorn 内部方法更健壮, 不依赖版本内部实现。
|
||||
"""
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
url = f"http://127.0.0.1:{port}/health"
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except (urllib.error.URLError, ConnectionError, OSError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def _open_window(url: str) -> None:
|
||||
"""主线程: 用 pywebview 打开桌面窗口。"""
|
||||
import webview # type: ignore[import-not-found]
|
||||
|
||||
window = webview.create_window(
|
||||
_APP_NAME,
|
||||
url,
|
||||
width=1440,
|
||||
height=900,
|
||||
min_size=(1024, 700),
|
||||
# 桌面版固定单窗口, 禁用外部浏览器跳转
|
||||
confirm_close=False,
|
||||
)
|
||||
# pywebview 会阻塞主线程直到窗口关闭
|
||||
webview.start(debug=False)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""桌面客户端主入口。返回进程退出码。"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
try:
|
||||
_ensure_data_dir_writable()
|
||||
except Exception:
|
||||
# 数据目录不可写是致命错误, 无法继续
|
||||
return 1
|
||||
|
||||
# 单实例: 已运行则退出
|
||||
if not _acquire_single_instance():
|
||||
return 0
|
||||
|
||||
try:
|
||||
port = _find_free_port(_BASE_PORT)
|
||||
logger.info("桌面版后端将监听 127.0.0.1:%d", port)
|
||||
|
||||
# 后台线程起 uvicorn
|
||||
ready = threading.Event()
|
||||
server_thread = threading.Thread(
|
||||
target=_run_uvmicorn, args=(port, ready), daemon=True,
|
||||
name="uvicorn",
|
||||
)
|
||||
server_thread.start()
|
||||
|
||||
# 轮询 health 接口等后端就绪 (含 lifespan 初始化, 最多 60s)
|
||||
if not _wait_for_server(port, timeout=60.0):
|
||||
logger.error("后端启动超时, 桌面版退出")
|
||||
_release_single_instance()
|
||||
return 1
|
||||
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
logger.info("打开桌面窗口: %s", url)
|
||||
_open_window(url)
|
||||
|
||||
# 窗口关闭后, 进程退出 (daemon 线程会被回收)
|
||||
logger.info("窗口已关闭, 桌面版退出")
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
_release_single_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
"""系统通知适配器 — 三平台原生通知中心。
|
||||
|
||||
职责: 把后端产生的告警事件推送到操作系统通知中心。
|
||||
窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)。
|
||||
|
||||
平台实现:
|
||||
- Windows: winotify (进现代操作中心, 支持图标)
|
||||
- macOS: osascript (系统已内置, 无需额外依赖)
|
||||
- Linux: notify-send (系统已内置) / plyer 兜底
|
||||
|
||||
设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)。
|
||||
通知去重不在本层做, 复用 MonitorRuleEngine 的 cooldown 逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 单次通知最长字符 (避免某些平台截断报错)
|
||||
_MAX_LEN = 200
|
||||
|
||||
# 避免重复探测平台能力, 缓存一次
|
||||
_backend_cache: str | None = None
|
||||
|
||||
|
||||
def _detect_backend() -> str | None:
|
||||
"""探测当前平台可用的通知后端。"""
|
||||
global _backend_cache
|
||||
if _backend_cache is not None:
|
||||
return _backend_cache if _backend_cache != "none" else None
|
||||
|
||||
backend = None
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winotify # type: ignore[import-not-found] # noqa: F401
|
||||
|
||||
backend = "winotify"
|
||||
except ImportError:
|
||||
logger.debug("winotify 不可用, Windows 通知降级")
|
||||
backend = None
|
||||
elif sys.platform == "darwin":
|
||||
backend = "osascript"
|
||||
elif sys.platform.startswith("linux"):
|
||||
backend = "notify-send"
|
||||
|
||||
_backend_cache = backend or "none"
|
||||
return backend
|
||||
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
"""截断超长文本, 避免平台通知上限报错。"""
|
||||
text = (text or "").strip()
|
||||
return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "")
|
||||
|
||||
|
||||
def notify(title: str, message: str, icon: Path | None = None) -> bool:
|
||||
"""推送一条系统通知。
|
||||
|
||||
Args:
|
||||
title: 通知标题
|
||||
message: 通知正文
|
||||
icon: 可选图标路径 (部分平台支持)
|
||||
|
||||
Returns:
|
||||
True=成功送达, False=失败或无可用后端。
|
||||
失败静默, 不抛异常 (通知是辅助通道, 不能阻断告警主流程)。
|
||||
"""
|
||||
title = _truncate(title)
|
||||
message = _truncate(message)
|
||||
if not title:
|
||||
return False
|
||||
|
||||
backend = _detect_backend()
|
||||
if backend is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if backend == "winotify":
|
||||
return _notify_winotify(title, message)
|
||||
if backend == "osascript":
|
||||
return _notify_osascript(title, message)
|
||||
if backend == "notify-send":
|
||||
return _notify_notify_send(title, message, icon)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("系统通知失败 (%s): %s", backend, e)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _notify_winotify(title: str, message: str) -> bool:
|
||||
"""Windows 通知 (winotify) — 进现代操作中心。"""
|
||||
from winotify import Notifier # type: ignore[import-not-found]
|
||||
|
||||
Notifier().create_notification(
|
||||
title=title,
|
||||
msg=message,
|
||||
# winotify 要求 duration 为 "short" 或 "long"
|
||||
duration="short",
|
||||
# 无可点击动作 (桌面版不实现"点击回到窗口"的复杂交互)
|
||||
).show()
|
||||
return True
|
||||
|
||||
|
||||
def _notify_osascript(title: str, message: str) -> bool:
|
||||
"""macOS 通知 (osascript) — 调用系统 AppleScript。"""
|
||||
# 转义双引号, 避免 AppleScript 注入
|
||||
safe_title = title.replace('"', '\\"')
|
||||
safe_msg = message.replace('"', '\\"')
|
||||
script = (
|
||||
f'display notification "{safe_msg}" with title "{safe_title}"'
|
||||
)
|
||||
result = subprocess.run( # noqa: S603, S607
|
||||
["osascript", "-e", script],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _notify_notify_send(title: str, message: str, icon: Path | None) -> bool:
|
||||
"""Linux 通知 (notify-send) — freedesktop.org 标准。"""
|
||||
args = ["notify-send", title]
|
||||
if icon and Path(icon).exists():
|
||||
args.extend(["--icon", str(icon)])
|
||||
args.append(message)
|
||||
result = subprocess.run( # noqa: S603
|
||||
args,
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
@@ -205,6 +205,17 @@ def get_strategy_monitor_enabled() -> bool:
|
||||
return load().get("strategy_monitor_enabled", False)
|
||||
|
||||
|
||||
def get_system_notify_enabled() -> bool:
|
||||
"""系统通知开关 — 开启后监控告警同时推送到操作系统通知中心。"""
|
||||
return load().get("system_notify_enabled", False)
|
||||
|
||||
|
||||
def set_system_notify_enabled(enabled: bool) -> bool:
|
||||
"""保存系统通知开关。"""
|
||||
save({"system_notify_enabled": bool(enabled)})
|
||||
return bool(enabled)
|
||||
|
||||
|
||||
def get_screener_auto_run() -> bool:
|
||||
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
|
||||
return load().get("screener_auto_run", True)
|
||||
|
||||
@@ -527,9 +527,51 @@ class QuoteService:
|
||||
self._alert_event.set()
|
||||
logger.info("监控评估完成: %d 条通知", len(all_alerts))
|
||||
|
||||
# 系统通知 (可选通道, 由 preferences 开关控制)。
|
||||
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
|
||||
self._maybe_send_system_notifications(all_alerts)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("监控评估失败: %s", e)
|
||||
|
||||
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
|
||||
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
|
||||
|
||||
- 开关关闭: 直接返回
|
||||
- 开关开启: 逐条发系统通知; 失败静默, 不阻断主流程
|
||||
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
|
||||
- 批量策略事件 (symbol="") 聚合为一条通知, 避免刷屏
|
||||
"""
|
||||
try:
|
||||
from app.services import preferences
|
||||
from app.services import notify_adapter
|
||||
|
||||
if not preferences.get_system_notify_enabled():
|
||||
return
|
||||
|
||||
for ev in all_alerts:
|
||||
# 通知标题: 用 source 分类 (策略/信号/价格/异动)
|
||||
source = ev.get("source", "")
|
||||
source_label = {
|
||||
"strategy": "策略", "signal": "信号",
|
||||
"price": "价格", "market": "异动",
|
||||
}.get(source, source or "通知")
|
||||
|
||||
name = ev.get("name") or ""
|
||||
symbol = ev.get("symbol") or ""
|
||||
message = ev.get("message") or ""
|
||||
|
||||
# 正文: 优先用现成 message, 拼上 symbol/name 让用户一眼定位
|
||||
if symbol:
|
||||
body = f"{symbol} {name} {message}".strip()
|
||||
else:
|
||||
body = message or name
|
||||
|
||||
title = f"TickFlow · {source_label}"
|
||||
notify_adapter.notify(title, body)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
|
||||
|
||||
def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None:
|
||||
"""利用已计算好的 enriched 数据,运行策略池并写入缓存。"""
|
||||
import math
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.31"
|
||||
version = "0.1.33"
|
||||
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -29,6 +29,9 @@ dependencies = [
|
||||
# AI(可选,但默认装上)
|
||||
"openai>=1.40", # OpenAI 兼容适配器复用 openai SDK
|
||||
"httpx>=0.27",
|
||||
"platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写)
|
||||
"winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心)
|
||||
"plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux)
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -39,6 +42,12 @@ backtest = [
|
||||
"vectorbt>=0.26",
|
||||
]
|
||||
|
||||
# 桌面客户端依赖: pywebview 桌面窗口。打包用 (PyInstaller), 生产/Docker 不需要。
|
||||
# 启用:`uv sync --extra desktop`
|
||||
desktop = [
|
||||
"pywebview>=5.0",
|
||||
]
|
||||
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
|
||||
Generated
+256
-2
@@ -105,6 +105,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bottle"
|
||||
version = "0.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.5.20"
|
||||
@@ -114,6 +123,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
@@ -215,6 +250,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clr-loader"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -1442,6 +1489,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotly"
|
||||
version = "6.7.0"
|
||||
@@ -1464,6 +1520,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plyer"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polars"
|
||||
version = "1.40.1"
|
||||
@@ -1504,6 +1569,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proxy-tools"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" }
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
@@ -1600,6 +1671,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.13.4"
|
||||
@@ -1740,6 +1820,114 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-core"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-cocoa"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-quartz"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-security"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-uniformtypeidentifiers"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyobjc-framework-webkit"
|
||||
version = "12.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
@@ -1808,6 +1996,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pythonnet"
|
||||
version = "3.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "clr-loader", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2026.2"
|
||||
@@ -1817,6 +2018,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywebview"
|
||||
version = "6.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bottle" },
|
||||
{ name = "proxy-tools" },
|
||||
{ name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" },
|
||||
{ name = "pythonnet", marker = "sys_platform == 'win32'" },
|
||||
{ name = "qtpy", marker = "sys_platform == 'openbsd6'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1872,6 +2095,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qtpy"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.5.9"
|
||||
@@ -2235,7 +2470,7 @@ all = [
|
||||
|
||||
[[package]]
|
||||
name = "tickflow-stock-panel-backend"
|
||||
version = "0.1.30"
|
||||
version = "0.1.33"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
@@ -2245,6 +2480,8 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "openai" },
|
||||
{ name = "pandas" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "plyer" },
|
||||
{ name = "polars" },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pydantic" },
|
||||
@@ -2255,12 +2492,16 @@ dependencies = [
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "tickflow", extra = ["all"] },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "winotify", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
backtest = [
|
||||
{ name = "vectorbt" },
|
||||
]
|
||||
desktop = [
|
||||
{ name = "pywebview" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
@@ -2278,6 +2519,8 @@ requires-dist = [
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
|
||||
{ name = "openai", specifier = ">=1.40" },
|
||||
{ name = "pandas", specifier = ">=2.2" },
|
||||
{ name = "platformdirs", specifier = ">=4.0" },
|
||||
{ name = "plyer", specifier = ">=2.1" },
|
||||
{ name = "polars", specifier = ">=1.0" },
|
||||
{ name = "pyarrow", specifier = ">=16.0" },
|
||||
{ name = "pydantic", specifier = ">=2.7" },
|
||||
@@ -2286,14 +2529,16 @@ requires-dist = [
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
{ name = "pywebview", marker = "extra == 'desktop'", specifier = ">=5.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" },
|
||||
{ name = "sse-starlette", specifier = ">=2.0" },
|
||||
{ name = "tickflow", extras = ["all"], specifier = ">=0.1.23" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||
{ name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" },
|
||||
{ name = "winotify", marker = "sys_platform == 'win32'", specifier = ">=1.1" },
|
||||
]
|
||||
provides-extras = ["backtest", "dev"]
|
||||
provides-extras = ["backtest", "desktop", "dev"]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
@@ -2636,3 +2881,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winotify"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" },
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.31",
|
||||
"version": "0.1.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -583,6 +583,7 @@ export interface Preferences {
|
||||
sse_refresh_pages: Record<string, boolean>
|
||||
strategy_monitor_enabled: boolean
|
||||
strategy_monitor_ids: string[]
|
||||
system_notify_enabled: boolean
|
||||
sidebar_index_symbols: string[]
|
||||
nav_order: string[]
|
||||
nav_hidden: string[]
|
||||
@@ -686,6 +687,11 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(cfg),
|
||||
}),
|
||||
updateSystemNotify: (enabled: boolean) =>
|
||||
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
updatePipelineSchedule: (hour: number, minute: number) =>
|
||||
request<{ hour: number; minute: number }>('/api/settings/preferences/pipeline-schedule', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BarChart3,
|
||||
Flame,
|
||||
Zap,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
usePreferences,
|
||||
@@ -48,6 +49,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
const systemNotify = prefs?.system_notify_enabled ?? false
|
||||
const hasDepth = !!caps?.capabilities?.['depth5.batch']
|
||||
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
|
||||
const indicesPinned = prefs?.indices_nav_pinned ?? true
|
||||
@@ -91,6 +93,11 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
}, [qc])
|
||||
|
||||
const toggleSystemNotify = useCallback(async (enabled: boolean) => {
|
||||
await api.updateSystemNotify(enabled)
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
}, [qc])
|
||||
|
||||
const runFix = useMutation({
|
||||
mutationFn: () => api.runLimitLadderFix(),
|
||||
onSuccess: (data) => {
|
||||
@@ -239,6 +246,15 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
>
|
||||
前往监控中心配置 →
|
||||
</a>
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<ToggleRow
|
||||
icon={Bell}
|
||||
label="系统通知"
|
||||
desc="监控告警同时推送到操作系统通知中心(窗口最小化或后台也能收到)"
|
||||
checked={systemNotify}
|
||||
onChange={toggleSystemNotify}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 连板梯队降级修正 */}
|
||||
@@ -300,17 +316,22 @@ function ToggleRow({
|
||||
desc,
|
||||
checked,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
desc: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">{label}</div>
|
||||
<div className="text-[11px] text-muted truncate">{desc}</div>
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">{label}</div>
|
||||
<div className="text-[11px] text-muted truncate">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!checked)}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2 } from 'lucide-react'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
|
||||
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
@@ -16,6 +16,7 @@ import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound'
|
||||
export function SettingsSystemPanel() {
|
||||
const qc = useQueryClient()
|
||||
const { data: prefs } = usePreferences()
|
||||
const { data: versionData } = useVersion()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const screenerAutoRun = prefs?.screener_auto_run ?? true
|
||||
@@ -191,6 +192,40 @@ export function SettingsSystemPanel() {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5 mt-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Info className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-medium text-foreground">关于</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">版本</div>
|
||||
<div className="text-[11px] text-muted truncate">当前安装的应用版本</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-secondary shrink-0">
|
||||
{versionData?.version ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-foreground">检查更新</div>
|
||||
<div className="text-[11px] text-muted truncate">前往 GitHub Releases 下载最新版本</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/shy3130/tickflow-stock-panel/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs
|
||||
bg-elevated text-secondary hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
检查更新
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller 打包配置 — 桌面客户端 (onedir 模式)。
|
||||
|
||||
为什么 onedir 而非 onefile:
|
||||
- onefile 每次启动都解压到临时 _MEIxxxxx, 与 APScheduler/多线程冲突
|
||||
- onedir 启动更快, 调试更方便 (可看到目录结构), 原生库直接在目录里
|
||||
- 体积差异通过压缩安装包弥补 (CI 里 zip 打包)
|
||||
|
||||
入口: backend/app/desktop.py (桌面版入口, 含 uvicorn + pywebview)
|
||||
|
||||
构建 (在项目根目录):
|
||||
cd frontend && pnpm build # 先构建前端到 frontend/dist
|
||||
pyinstaller packaging/tickflow.spec # 产物在 dist/TickFlowStockPanel/
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyInstaller.utils.hooks import (
|
||||
collect_all,
|
||||
collect_submodules,
|
||||
collect_data_files,
|
||||
copy_metadata,
|
||||
)
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# ── 资源路径基准: 项目根 (spec 文件在 packaging/ 下) ──────────────────
|
||||
ROOT = Path(SPECPATH).parent
|
||||
FRONTEND_DIST = str(ROOT / "frontend" / "dist")
|
||||
TIERS_YAML = str(ROOT / "tiers.yaml")
|
||||
BUILTIN_STRATEGIES = str(ROOT / "backend" / "app" / "strategy" / "builtin")
|
||||
|
||||
# ── 收集带原生库的依赖 (.libs/ 目录必须完整, 否则启动崩) ─────────────
|
||||
# polars / pyarrow / duckdb / fastexcel 都自带共享库子目录
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = []
|
||||
|
||||
for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"):
|
||||
d, b, h = collect_all(pkg)
|
||||
datas += d
|
||||
binaries += b
|
||||
hiddenimports += h
|
||||
|
||||
# polars 新 ABI 运行时目录 (_polars_runtime_32) 需显式收集子模块
|
||||
hiddenimports += collect_submodules("polars")
|
||||
|
||||
# ── pywebview 平台后端 (动态导入, PyInstaller 默认抓不到) ────────────
|
||||
hiddenimports += collect_submodules("webview")
|
||||
hiddenimports += collect_submodules("webview.platforms")
|
||||
|
||||
# ── 系统通知后端 (winotify/plyer 按平台动态导入) ─────────────────────
|
||||
if sys.platform == "win32":
|
||||
hiddenimports += collect_submodules("winotify")
|
||||
hiddenimports += collect_submodules("plyer")
|
||||
hiddenimports += collect_submodules("plyer.platforms")
|
||||
|
||||
# ── uvicorn 动态导入的模块 (loop/protocol/logging 按字符串加载) ──────
|
||||
hiddenimports += [
|
||||
"uvicorn.logging",
|
||||
"uvicorn.loops",
|
||||
"uvicorn.loops.auto",
|
||||
"uvicorn.loops.asyncio",
|
||||
"uvicorn.protocols",
|
||||
"uvicorn.protocols.http",
|
||||
"uvicorn.protocols.http.auto",
|
||||
"uvicorn.protocols.http.h11_impl",
|
||||
"uvicorn.protocols.websockets",
|
||||
"uvicorn.protocols.websockets.auto",
|
||||
"uvicorn.lifespan",
|
||||
"uvicorn.lifespan.on",
|
||||
]
|
||||
|
||||
# ── fastapi / pydantic 元数据 (版本检测用) ───────────────────────────
|
||||
# 注意: 任何用 importlib.metadata.version() 读版本的包, 都必须 copy_metadata,
|
||||
# 否则 frozen 后报 PackageNotFoundError。tickflow 包内部就是这么读的。
|
||||
# 用容错写法: 不存在的包跳过, 避免不同环境 (有无装某依赖) 导致构建失败。
|
||||
def _safe_metadata(pkg):
|
||||
"""收集包元数据, 包不存在时静默跳过。"""
|
||||
try:
|
||||
return copy_metadata(pkg)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
for pkg in (
|
||||
"fastapi", "pydantic", "pydantic_settings", "starlette", "anyio",
|
||||
"tickflow", # tickflow/__version__.py 用 importlib.metadata 读版本
|
||||
"uvicorn", "polars", "duckdb", "pyarrow", "httpx", "numpy", "pandas",
|
||||
"openai", "platformdirs", "winotify", "plyer", "apscheduler",
|
||||
"python-dotenv", "fastexcel",
|
||||
):
|
||||
datas += _safe_metadata(pkg)
|
||||
|
||||
# ── 随包资源 (只读, 放进 _MEIPASS) ────────────────────────────────────
|
||||
# 前端 dist → static/ (config.py frozen 模式读 _MEIPASS/static)
|
||||
datas += [(FRONTEND_DIST, "static")]
|
||||
# tiers.yaml → 包根 (config.py frozen 模式读 _MEIPASS/tiers.yaml)
|
||||
datas += [(TIERS_YAML, ".")]
|
||||
# 内置策略 → app/strategy/builtin/ (importlib 动态加载, 不能进 PYZ)
|
||||
datas += [(BUILTIN_STRATEGIES, "app/strategy/builtin")]
|
||||
|
||||
# ── 排除不需要的重型依赖 (主包不含 vectorbt 回测链) ──────────────────
|
||||
excludes = [
|
||||
"vectorbt",
|
||||
"numba",
|
||||
"llvmlite",
|
||||
"matplotlib",
|
||||
"plotly",
|
||||
"ipywidgets",
|
||||
"nbformat",
|
||||
"nbconvert",
|
||||
"jupyter",
|
||||
"IPython",
|
||||
"pytest",
|
||||
"pytest_asyncio",
|
||||
"ruff",
|
||||
"mypy",
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
[str(ROOT / "backend" / "app" / "desktop.py")],
|
||||
pathex=[str(ROOT / "backend")],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="TickFlowStockPanel",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX 压缩原生库常导致崩溃, 关闭
|
||||
console=False, # 桌面应用: 不显示控制台窗口 (调试时临时改 True 抓日志)
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None, # TODO: 添加应用图标 (后续设计后填路径)
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name="TickFlowStockPanel",
|
||||
)
|
||||
Reference in New Issue
Block a user