重构项目,方便开发
This commit is contained in:
74
desktop/README.md
Normal file
74
desktop/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 桌面端启动
|
||||
|
||||
## Python 要求
|
||||
|
||||
桌面端当前依赖 `pywebview`,在 Windows 下建议使用:
|
||||
|
||||
- `Python 3.12`
|
||||
- `Python 3.11`
|
||||
|
||||
不要直接用 `Python 3.14`。你当前遇到的 `pythonnet` 构建失败就是这个兼容性问题。
|
||||
|
||||
## 开发模式
|
||||
|
||||
前置条件:
|
||||
|
||||
- `frontend-vue` 的 Vite 开发服务已启动在 `http://127.0.0.1:5173`
|
||||
- 后端 API 已启动在 `http://127.0.0.1:8000`
|
||||
- 本机已安装 `Python 3.12` 或 `Python 3.11`
|
||||
|
||||
启动命令:
|
||||
|
||||
```powershell
|
||||
cd F:\project\crawler-plugin
|
||||
.\desktop\run_desktop_dev.ps1
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
|
||||
- 选择兼容的 Python 解释器
|
||||
- 安装 `desktop/requirements.txt`
|
||||
- 启动 `pywebview` 桌面窗口
|
||||
|
||||
如果脚本检测不到兼容 Python,会直接提示并退出,不再继续错误安装。
|
||||
|
||||
## 发布模式
|
||||
|
||||
先构建前端:
|
||||
|
||||
```powershell
|
||||
cd F:\project\crawler-plugin\frontend-vue
|
||||
npm run build
|
||||
```
|
||||
|
||||
再启动桌面端:
|
||||
|
||||
```powershell
|
||||
cd F:\project\crawler-plugin
|
||||
.\desktop\run_desktop_dist.ps1
|
||||
```
|
||||
|
||||
## 手动安装依赖
|
||||
|
||||
如果要手动安装,请显式使用兼容版本,例如:
|
||||
|
||||
```powershell
|
||||
py -3.12 -m pip install -r .\desktop\requirements.txt
|
||||
```
|
||||
|
||||
## 当前桌面能力
|
||||
|
||||
- 选择品牌 Excel 文件
|
||||
- 选择文件夹
|
||||
- 选择保存目录
|
||||
- 另存模板文件
|
||||
- 按 URL 下载结果文件到本地
|
||||
|
||||
## 说明
|
||||
|
||||
发布模式下,桌面宿主会:
|
||||
|
||||
- 本地托管 `frontend-vue/dist`
|
||||
- 将 `/api`、`/login`、`/logout`、`/static` 代理到 `--backend-url`
|
||||
|
||||
所以后端仍然需要运行。
|
||||
1
desktop/__init__.py
Normal file
1
desktop/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
desktop/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
desktop/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
desktop/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/app.cpython-312.pyc
Normal file
BIN
desktop/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
BIN
desktop/__pycache__/app.cpython-314.pyc
Normal file
BIN
desktop/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
334
desktop/app.py
Normal file
334
desktop/app.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import mimetypes
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from tkinter import Tk, filedialog
|
||||
from typing import Iterable
|
||||
from urllib.parse import urljoin
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit(
|
||||
"缺少依赖 requests。\n"
|
||||
"请先执行:python -m pip install -r .\\desktop\\requirements.txt"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
import webview
|
||||
except ModuleNotFoundError as exc:
|
||||
raise SystemExit(
|
||||
"缺少依赖 pywebview。\n"
|
||||
"请先执行:python -m pip install -r .\\desktop\\requirements.txt"
|
||||
) from exc
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
FRONTEND_DIR = ROOT_DIR / "frontend-vue"
|
||||
DIST_DIR = FRONTEND_DIR / "dist"
|
||||
INDEX_FILE = DIST_DIR / "index.html"
|
||||
PROXY_PREFIXES = ("/api", "/login", "/logout", "/static")
|
||||
|
||||
|
||||
def find_free_port() -> int:
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def ensure_dist_exists() -> None:
|
||||
if not INDEX_FILE.exists():
|
||||
raise FileNotFoundError(
|
||||
f"未找到前端构建产物:{INDEX_FILE}\n"
|
||||
"请先进入 frontend-vue 执行 npm run build。"
|
||||
)
|
||||
|
||||
|
||||
def resolve_url(base_url: str, maybe_relative_url: str) -> str:
|
||||
if maybe_relative_url.startswith(("http://", "https://")):
|
||||
return maybe_relative_url
|
||||
return urljoin(base_url.rstrip("/") + "/", maybe_relative_url.lstrip("/"))
|
||||
|
||||
|
||||
def run_file_dialog(fn):
|
||||
root = Tk()
|
||||
root.withdraw()
|
||||
root.attributes("-topmost", True)
|
||||
try:
|
||||
return fn(root)
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DesktopConfig:
|
||||
mode: str
|
||||
frontend_url: str
|
||||
backend_url: str
|
||||
host: str
|
||||
port: int
|
||||
width: int
|
||||
height: int
|
||||
title: str
|
||||
|
||||
|
||||
class DesktopApi:
|
||||
def __init__(self, app_base_url: str) -> None:
|
||||
self.app_base_url = app_base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def select_brand_xlsx_files(self) -> list[str]:
|
||||
files = run_file_dialog(
|
||||
lambda _root: filedialog.askopenfilenames(
|
||||
title="选择 Excel 文件",
|
||||
filetypes=[("Excel files", "*.xlsx")],
|
||||
)
|
||||
)
|
||||
return list(files)
|
||||
|
||||
def select_brand_folder(self) -> str | None:
|
||||
folder = run_file_dialog(
|
||||
lambda _root: filedialog.askdirectory(title="选择文件夹")
|
||||
)
|
||||
return folder or None
|
||||
|
||||
def select_folder(self) -> str | None:
|
||||
folder = run_file_dialog(
|
||||
lambda _root: filedialog.askdirectory(title="选择文件夹")
|
||||
)
|
||||
return folder or None
|
||||
|
||||
def save_template_xlsx(self) -> dict:
|
||||
return self._download_to_selected_path(
|
||||
"/static/品牌文档格式_模板.xlsx",
|
||||
"品牌文档格式_模板.xlsx",
|
||||
)
|
||||
|
||||
def save_template_zip(self) -> dict:
|
||||
return self._download_to_selected_path(
|
||||
"/static/模板2-以文件夹方式上传.zip",
|
||||
"模板2-以文件夹方式上传.zip",
|
||||
)
|
||||
|
||||
def save_file_from_url(self, url: str, filename: str) -> dict:
|
||||
return self._download_to_selected_path(url, filename)
|
||||
|
||||
def _download_to_selected_path(self, source_url: str, default_filename: str) -> dict:
|
||||
path = run_file_dialog(
|
||||
lambda _root: filedialog.asksaveasfilename(
|
||||
title="选择保存位置",
|
||||
initialfile=default_filename,
|
||||
defaultextension=Path(default_filename).suffix or None,
|
||||
)
|
||||
)
|
||||
if not path:
|
||||
return {"success": False, "error": "用户取消"}
|
||||
|
||||
try:
|
||||
resolved_url = resolve_url(self.app_base_url, source_url)
|
||||
with self.session.get(resolved_url, timeout=120, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
with open(path, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 128):
|
||||
if chunk:
|
||||
file.write(chunk)
|
||||
return {"success": True, "path": path}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
|
||||
class DistRequestHandler(BaseHTTPRequestHandler):
|
||||
backend_base_url = ""
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._dispatch()
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
self._dispatch()
|
||||
|
||||
def do_PUT(self) -> None: # noqa: N802
|
||||
self._dispatch()
|
||||
|
||||
def do_DELETE(self) -> None: # noqa: N802
|
||||
self._dispatch()
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self._dispatch()
|
||||
|
||||
def _dispatch(self) -> None:
|
||||
if self.path.startswith(PROXY_PREFIXES):
|
||||
self._proxy_to_backend()
|
||||
return
|
||||
self._serve_dist_file()
|
||||
|
||||
def _proxy_to_backend(self) -> None:
|
||||
target_url = resolve_url(self.backend_base_url, self.path)
|
||||
body = self._read_request_body()
|
||||
headers = {k: v for k, v in self.headers.items() if k.lower() != "host"}
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method=self.command,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
data=body,
|
||||
stream=True,
|
||||
timeout=120,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
payload = f"后端代理请求失败:{exc}".encode("utf-8")
|
||||
self.send_response(502)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
self.send_response(response.status_code)
|
||||
excluded_headers = {"content-encoding", "transfer-encoding", "connection"}
|
||||
for key, value in response.headers.items():
|
||||
if key.lower() in excluded_headers:
|
||||
continue
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
for chunk in response.iter_content(chunk_size=1024 * 128):
|
||||
if chunk:
|
||||
self.wfile.write(chunk)
|
||||
|
||||
def _serve_dist_file(self) -> None:
|
||||
request_path = self.path.split("?", 1)[0].lstrip("/")
|
||||
if not request_path:
|
||||
file_path = INDEX_FILE
|
||||
else:
|
||||
file_path = (DIST_DIR / request_path).resolve()
|
||||
if not str(file_path).startswith(str(DIST_DIR.resolve())):
|
||||
self.send_error(403)
|
||||
return
|
||||
if not file_path.exists() or file_path.is_dir():
|
||||
file_path = INDEX_FILE
|
||||
|
||||
try:
|
||||
content = file_path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime_type or "application/octet-stream")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def _read_request_body(self) -> bytes | None:
|
||||
content_length = self.headers.get("Content-Length")
|
||||
if not content_length:
|
||||
return None
|
||||
return self.rfile.read(int(content_length))
|
||||
|
||||
def log_message(self, format: str, *args) -> None: # noqa: A003
|
||||
return
|
||||
|
||||
|
||||
class DistServer:
|
||||
def __init__(self, host: str, port: int, backend_url: str) -> None:
|
||||
handler = type(
|
||||
"ConfiguredDistRequestHandler",
|
||||
(DistRequestHandler,),
|
||||
{"backend_base_url": backend_url.rstrip("/")},
|
||||
)
|
||||
self.server = ThreadingHTTPServer((host, port), handler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Crawler Plugin 桌面端启动器")
|
||||
parser.add_argument("--mode", choices=("dev", "dist"), default="dev")
|
||||
parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
|
||||
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--width", type=int, default=1440)
|
||||
parser.add_argument("--height", type=int, default=960)
|
||||
parser.add_argument("--title", default="南日AI")
|
||||
return parser
|
||||
|
||||
|
||||
def parse_args() -> DesktopConfig:
|
||||
args = build_parser().parse_args()
|
||||
port = args.port or find_free_port()
|
||||
return DesktopConfig(
|
||||
mode=args.mode,
|
||||
frontend_url=args.frontend_url.rstrip("/"),
|
||||
backend_url=args.backend_url.rstrip("/"),
|
||||
host=args.host,
|
||||
port=port,
|
||||
width=args.width,
|
||||
height=args.height,
|
||||
title=args.title,
|
||||
)
|
||||
|
||||
|
||||
def ensure_dev_server_ready(frontend_url: str) -> None:
|
||||
try:
|
||||
response = requests.get(frontend_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise SystemExit(
|
||||
"Dev server is not reachable.\n"
|
||||
f"Expected frontend URL: {frontend_url}\n"
|
||||
"Start Vite first with:\n"
|
||||
" cd frontend-vue\n"
|
||||
" npm run dev"
|
||||
) from exc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = parse_args()
|
||||
dist_server = None
|
||||
|
||||
if config.mode == "dist":
|
||||
ensure_dist_exists()
|
||||
dist_server = DistServer(config.host, config.port, config.backend_url)
|
||||
dist_server.start()
|
||||
app_url = dist_server.base_url
|
||||
else:
|
||||
ensure_dev_server_ready(config.frontend_url)
|
||||
app_url = config.frontend_url
|
||||
|
||||
api = DesktopApi(app_url)
|
||||
window = webview.create_window(
|
||||
title=config.title,
|
||||
url=app_url,
|
||||
js_api=api,
|
||||
width=config.width,
|
||||
height=config.height,
|
||||
min_size=(1200, 760),
|
||||
)
|
||||
|
||||
try:
|
||||
webview.start()
|
||||
finally:
|
||||
if dist_server is not None:
|
||||
dist_server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
desktop/requirements.txt
Normal file
2
desktop/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pywebview>=5.1
|
||||
requests>=2.32.0
|
||||
67
desktop/run_desktop_dev.ps1
Normal file
67
desktop/run_desktop_dev.ps1
Normal file
@@ -0,0 +1,67 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
function Get-CompatiblePythonCommand {
|
||||
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($pyLauncher) {
|
||||
foreach ($version in @("3.12", "3.11")) {
|
||||
try {
|
||||
& py "-$version" --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return @("py", "-$version")
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($pythonCommand) {
|
||||
try {
|
||||
$pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
|
||||
if ($pythonVersion -in @("3.12", "3.11")) {
|
||||
return @("python")
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-Python {
|
||||
param(
|
||||
[string[]]$PythonCommand,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$exe = $PythonCommand[0]
|
||||
$prefix = @()
|
||||
if ($PythonCommand.Length -gt 1) {
|
||||
$prefix = $PythonCommand[1..($PythonCommand.Length - 1)]
|
||||
}
|
||||
|
||||
& $exe @prefix @Arguments
|
||||
}
|
||||
|
||||
$pythonCmd = Get-CompatiblePythonCommand
|
||||
if (-not $pythonCmd) {
|
||||
Write-Host ""
|
||||
Write-Host "No compatible Python version was found."
|
||||
Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows."
|
||||
Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet."
|
||||
Write-Host ""
|
||||
Write-Host "Install Python 3.12 first, then run:"
|
||||
Write-Host " .\desktop\run_desktop_dev.ps1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using Python: $($pythonCmd -join ' ')"
|
||||
Write-Host "Ensuring desktop dependencies..."
|
||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
||||
|
||||
Write-Host "Starting desktop app..."
|
||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000")
|
||||
67
desktop/run_desktop_dist.ps1
Normal file
67
desktop/run_desktop_dist.ps1
Normal file
@@ -0,0 +1,67 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
function Get-CompatiblePythonCommand {
|
||||
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($pyLauncher) {
|
||||
foreach ($version in @("3.12", "3.11")) {
|
||||
try {
|
||||
& py "-$version" --version *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return @("py", "-$version")
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($pythonCommand) {
|
||||
try {
|
||||
$pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim()
|
||||
if ($pythonVersion -in @("3.12", "3.11")) {
|
||||
return @("python")
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Invoke-Python {
|
||||
param(
|
||||
[string[]]$PythonCommand,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$exe = $PythonCommand[0]
|
||||
$prefix = @()
|
||||
if ($PythonCommand.Length -gt 1) {
|
||||
$prefix = $PythonCommand[1..($PythonCommand.Length - 1)]
|
||||
}
|
||||
|
||||
& $exe @prefix @Arguments
|
||||
}
|
||||
|
||||
$pythonCmd = Get-CompatiblePythonCommand
|
||||
if (-not $pythonCmd) {
|
||||
Write-Host ""
|
||||
Write-Host "No compatible Python version was found."
|
||||
Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows."
|
||||
Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet."
|
||||
Write-Host ""
|
||||
Write-Host "Install Python 3.12 first, then run:"
|
||||
Write-Host " .\desktop\run_desktop_dist.ps1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using Python: $($pythonCmd -join ' ')"
|
||||
Write-Host "Ensuring desktop dependencies..."
|
||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
||||
|
||||
Write-Host "Starting desktop app..."
|
||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dist", "--backend-url", "http://127.0.0.1:8000")
|
||||
Reference in New Issue
Block a user