335 lines
10 KiB
Python
335 lines
10 KiB
Python
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()
|