重构项目,方便开发
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user