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()