重构项目,方便开发

This commit is contained in:
super
2026-03-19 13:15:47 +08:00
parent fb2e5ccea5
commit 1e63ab291b
38 changed files with 5940 additions and 0 deletions

74
desktop/README.md Normal file
View 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
View File

@@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

334
desktop/app.py Normal file
View 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
View File

@@ -0,0 +1,2 @@
pywebview>=5.1
requests>=2.32.0

View 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")

View 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")