1018 lines
38 KiB
Python
1018 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
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
|
|
from uuid import uuid4
|
|
|
|
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
|
|
|
|
try:
|
|
from openpyxl import load_workbook
|
|
except ModuleNotFoundError as exc:
|
|
raise SystemExit(
|
|
"缺少依赖 openpyxl。\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")
|
|
TEMPLATE_STORE_PATH = ROOT_DIR / "desktop" / "txt_templates.json"
|
|
TXT_TEMPLATES = {
|
|
"uk_offer": {
|
|
"id": "uk_offer",
|
|
"label": "英国 Offer 模板",
|
|
"output_filename": "英国.txt",
|
|
"is_default": True,
|
|
"built_in": True,
|
|
"preamble_lines": ["TemplateType=Offer\tVersion=1.4"],
|
|
"header_columns": [
|
|
"sku",
|
|
"price",
|
|
"quantity",
|
|
"product-id",
|
|
"product-id-type",
|
|
"condition-type",
|
|
"condition-note",
|
|
"ASIN-hint",
|
|
"title",
|
|
"product-tax-code",
|
|
"operation-type",
|
|
"sale-price",
|
|
"sale-start-date",
|
|
"sale-end-date",
|
|
"leadtime-to-ship",
|
|
"launch-date",
|
|
"is-giftwrap-available",
|
|
"is-gift-message-available",
|
|
],
|
|
"blank_header_rows_after_schema": 1,
|
|
"required_source_columns": ["ASIN", "价格"],
|
|
"defaults": {
|
|
"quantity": "100",
|
|
"product-id-type": "ASIN",
|
|
"condition-type": "new",
|
|
"leadtime-to-ship": "4",
|
|
},
|
|
"field_mapping": {
|
|
"price": "价格",
|
|
"product-id": "ASIN",
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
|
def normalize_cell_text(value: object) -> str:
|
|
if value is None:
|
|
return ""
|
|
text = str(value).replace("\ufeff", "").replace("\u3000", " ")
|
|
text = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
|
text = re.sub(r"\s+", " ", text)
|
|
return text.strip()
|
|
|
|
|
|
def build_output_path(output_dir: str, source_path: str) -> str:
|
|
source = Path(source_path)
|
|
candidate = Path(output_dir) / f"{source.stem}_cleaned{source.suffix}"
|
|
index = 2
|
|
while candidate.exists():
|
|
candidate = Path(output_dir) / f"{source.stem}_cleaned_{index}{source.suffix}"
|
|
index += 1
|
|
return str(candidate)
|
|
|
|
|
|
def build_named_output_path(output_dir: str, filename: str) -> str:
|
|
candidate = Path(output_dir) / filename
|
|
if not candidate.exists():
|
|
return str(candidate)
|
|
stem = candidate.stem
|
|
suffix = candidate.suffix
|
|
index = 2
|
|
while True:
|
|
next_candidate = Path(output_dir) / f"{stem}_{index}{suffix}"
|
|
if not next_candidate.exists():
|
|
return str(next_candidate)
|
|
index += 1
|
|
|
|
|
|
def should_keep_id(value: object, keep_integer_ids: bool, keep_underscore_ids: bool) -> bool:
|
|
text = normalize_cell_text(value)
|
|
if not text:
|
|
return False
|
|
if keep_integer_ids and text.isdigit():
|
|
return True
|
|
if keep_underscore_ids and re.fullmatch(r"\d+_\d+", text):
|
|
return True
|
|
return False
|
|
|
|
|
|
def generate_snowflake_like_id() -> int:
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
def load_user_templates() -> tuple[dict[str, dict], str | None]:
|
|
if not TEMPLATE_STORE_PATH.exists():
|
|
return {}, None
|
|
try:
|
|
payload = json.loads(TEMPLATE_STORE_PATH.read_text(encoding="utf-8"))
|
|
templates = payload.get("templates") or {}
|
|
default_template_id = payload.get("default_template_id")
|
|
return templates, default_template_id
|
|
except Exception:
|
|
return {}, None
|
|
|
|
|
|
def save_user_templates(templates: dict[str, dict], default_template_id: str | None) -> None:
|
|
payload = {
|
|
"default_template_id": default_template_id,
|
|
"templates": templates,
|
|
}
|
|
TEMPLATE_STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def build_effective_templates() -> tuple[dict[str, dict], str]:
|
|
builtins = {key: dict(value) for key, value in TXT_TEMPLATES.items()}
|
|
user_templates, persisted_default = load_user_templates()
|
|
templates = {**builtins, **user_templates}
|
|
default_template_id = persisted_default or next((key for key, value in builtins.items() if value.get("is_default")), "uk_offer")
|
|
for key, value in templates.items():
|
|
value["is_default"] = key == default_template_id
|
|
return templates, default_template_id
|
|
|
|
|
|
def recognize_txt_template(template_text: str, custom_name: str | None = None) -> dict | None:
|
|
lines = [line.rstrip("\n") for line in template_text.splitlines()]
|
|
non_empty_lines = [line for line in lines if line.strip()]
|
|
if len(non_empty_lines) < 2:
|
|
return None
|
|
|
|
first_line = non_empty_lines[0]
|
|
header_line = non_empty_lines[1]
|
|
header_columns = header_line.split("\t")
|
|
uk_template = TXT_TEMPLATES["uk_offer"]
|
|
|
|
if not first_line.startswith("TemplateType=Offer"):
|
|
return None
|
|
if header_columns != uk_template["header_columns"]:
|
|
return None
|
|
|
|
template_id = f"user_{int(time.time() * 1000)}"
|
|
output_filename = custom_name or Path("英国.txt").name
|
|
if not output_filename.endswith(".txt"):
|
|
output_filename = f"{output_filename}.txt"
|
|
|
|
return {
|
|
"id": template_id,
|
|
"label": custom_name or "自定义 Offer 模板",
|
|
"output_filename": output_filename,
|
|
"is_default": False,
|
|
"built_in": False,
|
|
"preamble_lines": [first_line],
|
|
"header_columns": header_columns,
|
|
"blank_header_rows_after_schema": 1,
|
|
"required_source_columns": ["ASIN", "价格"],
|
|
"defaults": dict(uk_template["defaults"]),
|
|
"field_mapping": dict(uk_template["field_mapping"]),
|
|
}
|
|
|
|
|
|
@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, backend_base_url: str) -> None:
|
|
self.app_base_url = backend_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_clean_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_clean_folder(self) -> str | None:
|
|
folder = run_file_dialog(
|
|
lambda _root: filedialog.askdirectory(title="选择待清洗文件夹")
|
|
)
|
|
return folder or None
|
|
|
|
def get_excel_headers(self, file_path: str) -> dict:
|
|
try:
|
|
workbook = load_workbook(file_path, read_only=True, data_only=True)
|
|
sheet = workbook[workbook.sheetnames[0]]
|
|
first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
|
workbook.close()
|
|
if not first_row:
|
|
return {"success": False, "error": "Excel 表头为空"}
|
|
|
|
headers: list[str] = []
|
|
seen: set[str] = set()
|
|
for cell in first_row:
|
|
if cell is None:
|
|
continue
|
|
raw_value = str(cell).replace("\ufeff", "").strip()
|
|
if not raw_value:
|
|
continue
|
|
value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value
|
|
if value in seen:
|
|
continue
|
|
seen.add(value)
|
|
headers.append(value)
|
|
if value == "缩略图地址8":
|
|
break
|
|
|
|
if not headers:
|
|
return {"success": False, "error": "未读取到有效表头"}
|
|
return {"success": True, "headers": headers}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def get_excel_info(self, file_path: str) -> dict:
|
|
try:
|
|
workbook = load_workbook(file_path, read_only=True, data_only=True)
|
|
sheet = workbook[workbook.sheetnames[0]]
|
|
first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
|
if not first_row:
|
|
workbook.close()
|
|
return {"success": False, "error": "Excel 表头为空"}
|
|
|
|
headers: list[str] = []
|
|
seen: set[str] = set()
|
|
for cell in first_row:
|
|
if cell is None:
|
|
continue
|
|
raw_value = str(cell).replace("\ufeff", "").strip()
|
|
if not raw_value:
|
|
continue
|
|
value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value
|
|
if value in seen:
|
|
continue
|
|
seen.add(value)
|
|
headers.append(value)
|
|
if value == "缩略图地址8":
|
|
break
|
|
|
|
total_rows = max(sheet.max_row - 1, 0)
|
|
workbook.close()
|
|
return {"success": True, "headers": headers, "total_rows": total_rows}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def list_txt_templates(self) -> list[dict]:
|
|
try:
|
|
response = self.session.get(
|
|
resolve_url(self.app_base_url, "/api/convert/templates"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not payload.get("success"):
|
|
return []
|
|
items = payload.get("data") or []
|
|
return [
|
|
{
|
|
"id": str(item.get("templateCode") or item.get("id") or ""),
|
|
"label": item.get("templateName") or "",
|
|
"output_filename": item.get("outputFilename") or "",
|
|
"is_default": bool(item.get("isDefault")),
|
|
"built_in": bool(item.get("builtIn")),
|
|
}
|
|
for item in items
|
|
]
|
|
except Exception:
|
|
templates, _default_template_id = build_effective_templates()
|
|
return [
|
|
{
|
|
"id": template["id"],
|
|
"label": template["label"],
|
|
"output_filename": template["output_filename"],
|
|
"is_default": template.get("is_default", False),
|
|
"built_in": template.get("built_in", False),
|
|
}
|
|
for template in templates.values()
|
|
]
|
|
|
|
def import_txt_template(self, name: str | None = None) -> dict:
|
|
file_path = run_file_dialog(
|
|
lambda _root: filedialog.askopenfilename(
|
|
title="选择 txt 模板文件",
|
|
filetypes=[("TXT files", "*.txt")],
|
|
)
|
|
)
|
|
if not file_path:
|
|
return {"success": False, "error": "用户取消"}
|
|
|
|
try:
|
|
try:
|
|
template_text = Path(file_path).read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
template_text = Path(file_path).read_text(encoding="utf-8-sig")
|
|
|
|
response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/convert/templates/import"),
|
|
json={
|
|
"templateName": (name or "").strip() or Path(file_path).stem,
|
|
"templateContent": template_text,
|
|
},
|
|
timeout=120,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not payload.get("success") or not payload.get("data"):
|
|
return {"success": False, "error": payload.get("message") or "导入失败"}
|
|
item = payload.get("data") or {}
|
|
return {
|
|
"success": True,
|
|
"template": {
|
|
"id": str(item.get("templateCode") or item.get("id") or ""),
|
|
"label": item.get("templateName") or "",
|
|
"output_filename": item.get("outputFilename") or "",
|
|
"is_default": bool(item.get("isDefault")),
|
|
"built_in": bool(item.get("builtIn")),
|
|
},
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def set_default_txt_template(self, template_id: str) -> dict:
|
|
try:
|
|
response = self.session.post(
|
|
resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def delete_txt_template(self, template_id: str) -> dict:
|
|
try:
|
|
response = self.session.delete(
|
|
resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def split_excel_files(self, payload: dict) -> dict:
|
|
paths = payload.get("paths") or []
|
|
selected_columns = payload.get("selected_columns") or []
|
|
split_mode = payload.get("split_mode") or "rows_per_file"
|
|
rows_per_file = payload.get("rows_per_file")
|
|
parts = payload.get("parts")
|
|
|
|
if not isinstance(paths, list) or not paths:
|
|
return {"success": False, "error": "未选择待拆分文件"}
|
|
if not isinstance(selected_columns, list) or not selected_columns:
|
|
return {"success": False, "error": "请至少选择一列"}
|
|
if split_mode not in {"rows_per_file", "parts"}:
|
|
return {"success": False, "error": "拆分模式不合法"}
|
|
if split_mode == "rows_per_file" and (not isinstance(rows_per_file, int) or rows_per_file <= 0):
|
|
return {"success": False, "error": "每份条数不合法"}
|
|
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
|
|
return {"success": False, "error": "拆分份数不合法"}
|
|
|
|
try:
|
|
uploaded_files = []
|
|
for path in paths:
|
|
with open(path, "rb") as file_obj:
|
|
response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
|
files={"file": (Path(path).name, file_obj)},
|
|
timeout=180,
|
|
)
|
|
response.raise_for_status()
|
|
upload_payload = response.json()
|
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
|
upload_data = upload_payload.get("data") or {}
|
|
uploaded_files.append({
|
|
"fileKey": upload_data.get("fileKey"),
|
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
|
})
|
|
|
|
run_response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/split/run"),
|
|
json={
|
|
"files": uploaded_files,
|
|
"selectedColumns": selected_columns,
|
|
"splitMode": split_mode,
|
|
"rowsPerFile": rows_per_file,
|
|
"parts": parts,
|
|
},
|
|
timeout=600,
|
|
)
|
|
run_response.raise_for_status()
|
|
run_payload = run_response.json()
|
|
if not run_payload.get("success") or not run_payload.get("data"):
|
|
return {"success": False, "error": run_payload.get("message") or "数据拆分失败"}
|
|
|
|
data = run_payload.get("data") or {}
|
|
result_items = []
|
|
for item in data.get("items") or []:
|
|
if item.get("success") and item.get("downloadUrl"):
|
|
result_items.append({
|
|
"result_id": item.get("resultId"),
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": True,
|
|
"row_count": item.get("rowCount"),
|
|
})
|
|
else:
|
|
result_items.append({
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"success": False,
|
|
"error": item.get("error") or "数据拆分失败",
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"summary": {
|
|
"total": data.get("total") or len(paths),
|
|
"success_count": data.get("successCount") or 0,
|
|
"failed_count": data.get("failedCount") or 0,
|
|
},
|
|
"items": result_items,
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def delete_history_item(self, module_type: str, result_id: int) -> dict:
|
|
try:
|
|
response = self.session.delete(
|
|
resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def get_convert_history(self) -> dict:
|
|
try:
|
|
response = self.session.get(
|
|
resolve_url(self.app_base_url, "/api/convert/history"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not payload.get("success") or not payload.get("data"):
|
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
|
|
|
items = payload.get("data", {}).get("items") or []
|
|
return {
|
|
"success": True,
|
|
"items": [
|
|
{
|
|
"result_id": item.get("resultId"),
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": bool(item.get("success", True)),
|
|
}
|
|
for item in items
|
|
],
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def get_split_history(self) -> dict:
|
|
try:
|
|
response = self.session.get(
|
|
resolve_url(self.app_base_url, "/api/split/history"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not payload.get("success") or not payload.get("data"):
|
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
|
|
|
items = payload.get("data", {}).get("items") or []
|
|
return {
|
|
"success": True,
|
|
"items": [
|
|
{
|
|
"result_id": item.get("resultId"),
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": bool(item.get("success", True)),
|
|
"row_count": item.get("rowCount"),
|
|
}
|
|
for item in items
|
|
],
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def get_dedupe_history(self) -> dict:
|
|
try:
|
|
response = self.session.get(
|
|
resolve_url(self.app_base_url, "/api/dedupe/history"),
|
|
timeout=60,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not payload.get("success") or not payload.get("data"):
|
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
|
|
|
items = payload.get("data", {}).get("items") or []
|
|
return {
|
|
"success": True,
|
|
"items": [
|
|
{
|
|
"result_id": item.get("resultId"),
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": bool(item.get("success", True)),
|
|
}
|
|
for item in items
|
|
],
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def clean_excel_files(self, payload: dict) -> dict:
|
|
paths = payload.get("paths") or []
|
|
selected_columns = payload.get("selected_columns") or []
|
|
keep_integer_ids = bool(payload.get("keep_integer_ids", True))
|
|
keep_underscore_ids = bool(payload.get("keep_underscore_ids", True))
|
|
|
|
if not isinstance(paths, list) or not paths:
|
|
return {"success": False, "error": "未选择待处理文件"}
|
|
if not isinstance(selected_columns, list) or not selected_columns:
|
|
return {"success": False, "error": "请至少选择一列"}
|
|
if not keep_integer_ids and not keep_underscore_ids:
|
|
return {"success": False, "error": "请至少选择一种 ID 保留规则"}
|
|
|
|
try:
|
|
uploaded_files = []
|
|
for path in paths:
|
|
with open(path, "rb") as file_obj:
|
|
response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
|
files={"file": (Path(path).name, file_obj)},
|
|
timeout=180,
|
|
)
|
|
response.raise_for_status()
|
|
upload_payload = response.json()
|
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
|
upload_data = upload_payload.get("data") or {}
|
|
uploaded_files.append({
|
|
"fileKey": upload_data.get("fileKey"),
|
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
|
})
|
|
|
|
run_response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/dedupe/run"),
|
|
json={
|
|
"files": uploaded_files,
|
|
"selectedColumns": selected_columns,
|
|
"keepIntegerIds": keep_integer_ids,
|
|
"keepUnderscoreIds": keep_underscore_ids,
|
|
},
|
|
timeout=600,
|
|
)
|
|
run_response.raise_for_status()
|
|
run_payload = run_response.json()
|
|
if not run_payload.get("success") or not run_payload.get("data"):
|
|
return {"success": False, "error": run_payload.get("message") or "去重失败"}
|
|
|
|
data = run_payload.get("data") or {}
|
|
result_items = []
|
|
for item in data.get("items") or []:
|
|
if item.get("success") and item.get("downloadUrl"):
|
|
result_items.append({
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": True,
|
|
})
|
|
else:
|
|
result_items.append({
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"success": False,
|
|
"error": item.get("error") or "去重失败",
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"summary": {
|
|
"total": data.get("total") or len(paths),
|
|
"success_count": data.get("successCount") or 0,
|
|
"failed_count": data.get("failedCount") or 0,
|
|
},
|
|
"items": result_items,
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def convert_excel_to_uk_txt(self, payload: dict) -> dict:
|
|
paths = payload.get("paths") or []
|
|
template_id = payload.get("template_id") or "uk_offer"
|
|
|
|
if not isinstance(paths, list) or not paths:
|
|
return {"success": False, "error": "未选择待转换文件"}
|
|
|
|
try:
|
|
templates = self.list_txt_templates()
|
|
selected_template = next((item for item in templates if item.get("id") == template_id), None)
|
|
if not selected_template:
|
|
return {"success": False, "error": "模板不存在"}
|
|
|
|
uploaded_files = []
|
|
for path in paths:
|
|
with open(path, "rb") as file_obj:
|
|
response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
|
files={"file": (Path(path).name, file_obj)},
|
|
timeout=180,
|
|
)
|
|
response.raise_for_status()
|
|
upload_payload = response.json()
|
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
|
upload_data = upload_payload.get("data") or {}
|
|
uploaded_files.append({
|
|
"fileKey": upload_data.get("fileKey"),
|
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
|
})
|
|
|
|
run_response = self.session.post(
|
|
resolve_url(self.app_base_url, "/api/convert/run"),
|
|
json={
|
|
"files": uploaded_files,
|
|
"templateId": str(template_id),
|
|
},
|
|
timeout=600,
|
|
)
|
|
run_response.raise_for_status()
|
|
run_payload = run_response.json()
|
|
if not run_payload.get("success") or not run_payload.get("data"):
|
|
return {"success": False, "error": run_payload.get("message") or "格式转换失败"}
|
|
|
|
data = run_payload.get("data") or {}
|
|
result_items = []
|
|
for item in data.get("items") or []:
|
|
if item.get("success") and item.get("downloadUrl"):
|
|
result_items.append({
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"output_path": item.get("downloadUrl") or "",
|
|
"success": True,
|
|
})
|
|
else:
|
|
result_items.append({
|
|
"source_path": item.get("sourceFilename") or "",
|
|
"success": False,
|
|
"error": item.get("error") or "格式转换失败",
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"summary": {
|
|
"total": data.get("total") or len(paths),
|
|
"success_count": data.get("successCount") or 0,
|
|
"failed_count": data.get("failedCount") or 0,
|
|
},
|
|
"items": result_items,
|
|
}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
def open_path(self, path: str) -> dict:
|
|
try:
|
|
if not path:
|
|
return {"success": False, "error": "路径为空"}
|
|
target = Path(path)
|
|
if not target.exists():
|
|
return {"success": False, "error": "路径不存在"}
|
|
os.startfile(str(target))
|
|
return {"success": True}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
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(config.backend_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()
|