diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc index e0a3df3..ba952b6 100644 Binary files a/desktop/__pycache__/app.cpython-312.pyc and b/desktop/__pycache__/app.cpython-312.pyc differ diff --git a/desktop/app.py b/desktop/app.py index 59116b4..e2b1c52 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -2,16 +2,21 @@ 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 @@ -29,12 +34,63 @@ except ModuleNotFoundError as exc: "请先执行: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: @@ -67,6 +123,120 @@ def run_file_dialog(fn): 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 @@ -93,12 +263,514 @@ class DesktopApi: ) 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]: + 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: + template_text = Path(file_path).read_text(encoding="utf-8") + except UnicodeDecodeError: + template_text = Path(file_path).read_text(encoding="utf-8-sig") + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} + + template = recognize_txt_template(template_text, (name or "").strip() or None) + if not template: + return {"success": False, "error": "无法识别模板"} + + user_templates, default_template_id = load_user_templates() + user_templates[template["id"]] = template + save_user_templates(user_templates, default_template_id) + + return { + "success": True, + "template": { + "id": template["id"], + "label": template["label"], + "output_filename": template["output_filename"], + "is_default": False, + "built_in": False, + }, + } + + def set_default_txt_template(self, template_id: str) -> dict: + templates, _default_template_id = build_effective_templates() + if template_id not in templates: + return {"success": False, "error": "模板不存在"} + + user_templates, _ = load_user_templates() + save_user_templates(user_templates, template_id) + return {"success": True} + + 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") + output_dir = payload.get("output_dir") or "" + + 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": "拆分份数不合法"} + if not output_dir: + return {"success": False, "error": "未选择输出目录"} + + output_dir_path = Path(output_dir) + if not output_dir_path.exists() or not output_dir_path.is_dir(): + return {"success": False, "error": "输出目录不存在"} + + items: list[dict] = [] + success_count = 0 + failed_count = 0 + + for path in paths: + try: + workbook = load_workbook(path) + sheet = workbook[workbook.sheetnames[0]] + header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) + header_map: dict[str, int] = {} + for index, cell in enumerate(header_cells): + value = normalize_cell_text(cell.value) + if not value: + continue + value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value + if value in header_map: + continue + header_map[value] = index + if value == "缩略图地址8": + break + + missing = [column for column in selected_columns if column not in header_map] + if missing: + workbook.close() + items.append({ + "source_path": path, + "success": False, + "error": f"缺少列:{'、'.join(missing)}", + }) + failed_count += 1 + continue + + rows_data: list[list[str]] = [] + for row in sheet.iter_rows(min_row=2, values_only=True): + projected = [ + normalize_cell_text(row[header_map[column]]) if header_map[column] < len(row) else "" + for column in selected_columns + ] + rows_data.append(projected) + + total_rows = len(rows_data) + if total_rows == 0: + workbook.close() + items.append({ + "source_path": path, + "success": False, + "error": "没有可拆分的数据行", + }) + failed_count += 1 + continue + + if split_mode == "rows_per_file": + chunk_size = rows_per_file + chunks = [rows_data[i : i + chunk_size] for i in range(0, total_rows, chunk_size)] + else: + actual_parts = min(parts, total_rows) + base = total_rows // actual_parts + remainder = total_rows % actual_parts + chunks = [] + start = 0 + for idx in range(actual_parts): + size = base + (1 if idx < remainder else 0) + end = start + size + chunks.append(rows_data[start:end]) + start = end + + from openpyxl import Workbook + + source = Path(path) + for index, chunk in enumerate(chunks, start=1): + output_workbook = Workbook() + output_sheet = output_workbook.active + output_sheet.title = sheet.title + output_sheet.append(selected_columns) + for row_values in chunk: + output_sheet.append(row_values) + + output_path = build_named_output_path(output_dir, f"{source.stem}_split_{index}.xlsx") + output_workbook.save(output_path) + output_workbook.close() + + items.append({ + "source_path": path, + "output_path": output_path, + "success": True, + "row_count": len(chunk), + }) + + workbook.close() + success_count += 1 + except Exception as exc: # noqa: BLE001 + items.append({ + "source_path": path, + "success": False, + "error": str(exc), + }) + failed_count += 1 + + return { + "success": True, + "summary": { + "total": len(paths), + "success_count": success_count, + "failed_count": failed_count, + "output_dir": str(output_dir_path), + }, + "items": items, + } + + 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)) + output_dir = payload.get("output_dir") or "" + + 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 保留规则"} + if not output_dir: + return {"success": False, "error": "未选择输出目录"} + + output_dir_path = Path(output_dir) + if not output_dir_path.exists() or not output_dir_path.is_dir(): + return {"success": False, "error": "输出目录不存在"} + + items: list[dict] = [] + success_count = 0 + failed_count = 0 + + for path in paths: + try: + workbook = load_workbook(path) + sheet = workbook[workbook.sheetnames[0]] + header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) + headers: list[str] = [] + header_map: dict[str, int] = {} + for index, cell in enumerate(header_cells): + value = normalize_cell_text(cell.value) + if not value: + continue + value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value + if value in header_map: + continue + header_map[value] = index + headers.append(value) + if value == "缩略图地址8": + break + + missing = [column for column in selected_columns if column not in header_map] + if missing: + workbook.close() + items.append({ + "source_path": path, + "success": False, + "error": f"缺少列:{'、'.join(missing)}", + }) + failed_count += 1 + continue + + from openpyxl import Workbook + + output_workbook = Workbook() + output_sheet = output_workbook.active + output_sheet.title = sheet.title + output_sheet.append(selected_columns) + + id_column_index = header_map.get("id") + + for row in sheet.iter_rows(min_row=2, values_only=True): + original_values = list(row) + if id_column_index is not None: + id_value = original_values[id_column_index] if id_column_index < len(original_values) else None + if not should_keep_id(id_value, keep_integer_ids, keep_underscore_ids): + continue + + cleaned_values = [ + normalize_cell_text(original_values[header_map[column]]) + if header_map[column] < len(original_values) + else "" + for column in selected_columns + ] + output_sheet.append(cleaned_values) + + output_path = build_output_path(output_dir, path) + output_workbook.save(output_path) + output_workbook.close() + workbook.close() + + items.append({ + "source_path": path, + "output_path": output_path, + "success": True, + }) + success_count += 1 + except Exception as exc: # noqa: BLE001 + items.append({ + "source_path": path, + "success": False, + "error": str(exc), + }) + failed_count += 1 + + return { + "success": True, + "summary": { + "total": len(paths), + "success_count": success_count, + "failed_count": failed_count, + "output_dir": str(output_dir_path), + }, + "items": items, + } + + def convert_excel_to_uk_txt(self, payload: dict) -> dict: + paths = payload.get("paths") or [] + output_dir = payload.get("output_dir") or "" + template_id = payload.get("template_id") or "uk_offer" + + if not isinstance(paths, list) or not paths: + return {"success": False, "error": "未选择待转换文件"} + if not output_dir: + return {"success": False, "error": "未选择输出目录"} + + templates, _default_template_id = build_effective_templates() + template = templates.get(template_id) + if not template: + return {"success": False, "error": "模板不存在"} + + output_dir_path = Path(output_dir) + if not output_dir_path.exists() or not output_dir_path.is_dir(): + return {"success": False, "error": "输出目录不存在"} + + items: list[dict] = [] + success_count = 0 + failed_count = 0 + + for path in paths: + try: + workbook = load_workbook(path, read_only=True, data_only=True) + sheet = workbook[workbook.sheetnames[0]] + header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) + if not header_row: + workbook.close() + items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'}) + failed_count += 1 + continue + + header_map: dict[str, int] = {} + for index, cell in enumerate(header_row): + value = normalize_cell_text(cell) + if not value or value in header_map: + continue + header_map[value] = index + + required = template["required_source_columns"] + missing = [column for column in required if column not in header_map] + if missing: + workbook.close() + items.append({ + "source_path": path, + "success": False, + "error": f"缺少列:{'、'.join(missing)}", + }) + failed_count += 1 + continue + + rows: list[str] = [] + rows.extend(template["preamble_lines"]) + rows.append('\t'.join(template["header_columns"])) + for _ in range(template.get("blank_header_rows_after_schema", 0)): + rows.append('\t' * (len(template["header_columns"]) - 1)) + + batch_id = generate_snowflake_like_id() + row_index = 1 + for row in sheet.iter_rows(min_row=2, values_only=True): + line_values: list[str] = [] + asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '') + if not asin: + continue + + for column in template["header_columns"]: + if column == 'sku': + line_values.append(f'{batch_id}-{row_index}') + continue + if column in template["field_mapping"]: + source_column = template["field_mapping"][column] + value = normalize_cell_text(row[header_map[source_column]] if header_map[source_column] < len(row) else '') + line_values.append(value) + continue + if column in template["defaults"]: + line_values.append(str(template["defaults"][column])) + continue + line_values.append('') + + rows.append('\t'.join(line_values)) + row_index += 1 + + output_path = build_named_output_path(output_dir, template["output_filename"]) + Path(output_path).write_text('\n'.join(rows), encoding='utf-8') + workbook.close() + + items.append({ + "source_path": path, + "output_path": output_path, + "success": True, + }) + success_count += 1 + except Exception as exc: # noqa: BLE001 + items.append({ + "source_path": path, + "success": False, + "error": str(exc), + }) + failed_count += 1 + + return { + "success": True, + "summary": { + "total": len(paths), + "success_count": success_count, + "failed_count": failed_count, + "output_dir": str(output_dir_path), + }, + "items": items, + } + + 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="选择文件夹") diff --git a/desktop/requirements.txt b/desktop/requirements.txt index 117978c..ce8b9a8 100644 --- a/desktop/requirements.txt +++ b/desktop/requirements.txt @@ -1,2 +1,3 @@ pywebview>=5.1 requests>=2.32.0 +openpyxl>=3.1.5 diff --git a/desktop/txt_templates.json b/desktop/txt_templates.json new file mode 100644 index 0000000..8873222 --- /dev/null +++ b/desktop/txt_templates.json @@ -0,0 +1,95 @@ +{ + "default_template_id": null, + "templates": { + "user_1773929220357": { + "id": "user_1773929220357", + "label": "自定义 Offer 模板", + "output_filename": "英国.txt", + "is_default": false, + "built_in": false, + "preamble_lines": [ + "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" + ], + "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" + } + }, + "user_1773929734779": { + "id": "user_1773929734779", + "label": "测试", + "output_filename": "测试.txt", + "is_default": false, + "built_in": false, + "preamble_lines": [ + "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" + ], + "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" + } + } + } +} \ No newline at end of file diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue new file mode 100644 index 0000000..cfc2de6 --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue @@ -0,0 +1,411 @@ + + + + + \ No newline at end of file diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue new file mode 100644 index 0000000..5c35ef3 --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue @@ -0,0 +1,390 @@ + + + + + \ No newline at end of file diff --git a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue new file mode 100644 index 0000000..93c3382 --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue @@ -0,0 +1,402 @@ + + + + + \ No newline at end of file diff --git a/frontend-vue/src/pages/brand/index.vue b/frontend-vue/src/pages/brand/index.vue index ef72801..aa23520 100644 --- a/frontend-vue/src/pages/brand/index.vue +++ b/frontend-vue/src/pages/brand/index.vue @@ -9,12 +9,43 @@ -
+
+ + + +
@@ -249,8 +247,12 @@ import { type BrandTaskItem, } from '@/shared/api/brand' import { getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview' +import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue' +import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue' +import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue' const hasPywebviewSupport = hasPywebview() +const activeTab = ref<'brand' | 'dedupe' | 'convert' | 'split'>('brand') const selectedPaths = ref([]) const runMode = ref<'immediate' | 'task'>('immediate') const matchStrategy = ref<'Terms' | 'Simple'>('Terms') @@ -267,10 +269,7 @@ let taskRefreshTimer: number | null = null const displayPaths = computed(() => selectedPaths.value.slice(0, 8)) const progressPercent = computed(() => { - if (!progressTotal.value) { - return progressStatus.value === 'running' ? 12 : 100 - } - + if (!progressTotal.value) return progressStatus.value === 'running' ? 12 : 100 return Math.min(100, Math.round((progressCurrent.value / progressTotal.value) * 100)) }) @@ -298,7 +297,6 @@ function getStatusLabel(status?: string) { failed: '失败', cancelled: '已取消', } - return statusMap[(status || '').toLowerCase()] || (status || '-') } @@ -311,12 +309,10 @@ function getTaskProgressPercent(task: BrandTaskItem) { function stopTaskMonitor() { activeTaskId.value = null running.value = false - if (taskEvents) { taskEvents.close() taskEvents = null } - if (pollTimer !== null) { window.clearInterval(pollTimer) pollTimer = null @@ -345,7 +341,6 @@ async function refreshMonitoredTask(taskId: string | number) { progressStatus.value = status as typeof progressStatus.value stopTaskMonitor() await loadTasks() - if (status === 'success') ElMessage.success('生成完成,可下载结果') if (status === 'failed') ElMessage.error('生成失败') if (status === 'cancelled') ElMessage.info('已取消') @@ -640,12 +635,22 @@ onBeforeUnmount(() => { .nav-tab { padding: 8px 14px; font-size: 13px; - color: #3498db; - background: rgba(52, 152, 219, 0.2); + color: #9fb9d3; + background: transparent; border: none; border-radius: 6px; } +.nav-tab:hover { + color: #d8ecff; + background: rgba(52, 152, 219, 0.12); +} + +.nav-tab.active { + color: #3498db; + background: rgba(52, 152, 219, 0.2); +} + .main-content { display: flex; height: calc(100vh - 56px); @@ -1093,4 +1098,4 @@ onBeforeUnmount(() => { width: 100%; } } - + \ No newline at end of file diff --git a/frontend-vue/src/shared/bridges/pywebview.ts b/frontend-vue/src/shared/bridges/pywebview.ts index 52f2628..9266028 100644 --- a/frontend-vue/src/shared/bridges/pywebview.ts +++ b/frontend-vue/src/shared/bridges/pywebview.ts @@ -1,7 +1,112 @@ +export interface SplitExcelPayload { + paths: string[] + selected_columns: string[] + split_mode: 'rows_per_file' | 'parts' + rows_per_file?: number + parts?: number + output_dir: string +} + +export interface SplitExcelResultItem { + source_path: string + output_path?: string + success: boolean + error?: string + row_count?: number +} + +export interface SplitExcelSummary { + total: number + success_count: number + failed_count: number + output_dir?: string +} + +export interface SplitExcelResponse { + success: boolean + summary?: SplitExcelSummary + items?: SplitExcelResultItem[] + error?: string +} + +export interface ExcelInfoResponse { + success: boolean + headers?: string[] + total_rows?: number + error?: string +} + +export interface CleanExcelResultItem { + source_path: string + output_path?: string + success: boolean + error?: string +} + +export interface ConvertTxtPayload { + paths: string[] + output_dir: string + template_id: string +} + +export interface ConvertTxtTemplateItem { + id: string + label: string + output_filename: string + is_default?: boolean + built_in?: boolean +} + +export interface ConvertTxtResultItem { + source_path: string + output_path?: string + success: boolean + error?: string +} + +export interface ConvertTxtSummary { + total: number + success_count: number + failed_count: number + output_dir?: string +} + +export interface ConvertTxtResponse { + success: boolean + summary?: ConvertTxtSummary + items?: ConvertTxtResultItem[] + error?: string +} + +export interface CleanExcelSummary { + total: number + success_count: number + failed_count: number + output_dir?: string +} + +export interface CleanExcelResponse { + success: boolean + summary?: CleanExcelSummary + items?: CleanExcelResultItem[] + error?: string +} + export interface PywebviewApi { select_brand_xlsx_files?: () => Promise + select_clean_xlsx_files?: () => Promise select_brand_folder?: () => Promise + select_clean_folder?: () => Promise select_folder?: () => Promise + get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }> + get_excel_info?: (filePath: string) => Promise + clean_excel_files?: (payload: CleanExcelPayload) => Promise + split_excel_files?: (payload: SplitExcelPayload) => Promise + list_txt_templates?: () => Promise + import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }> + set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }> + convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise + open_path?: (path: string) => Promise<{ success: boolean; error?: string }> save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }> save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }> save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }>