From 43d508f527f83f41d0b05109853d9a9c0d2e8fdc Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Thu, 19 Mar 2026 23:07:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=96=B0=E5=A2=9E=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/__pycache__/app.cpython-312.pyc | Bin 18161 -> 44478 bytes desktop/app.py | 672 ++++++++++++++++++ desktop/requirements.txt | 1 + desktop/txt_templates.json | 95 +++ .../brand/components/BrandConvertTab.vue | 411 +++++++++++ .../pages/brand/components/BrandDedupeTab.vue | 390 ++++++++++ .../pages/brand/components/BrandSplitTab.vue | 402 +++++++++++ frontend-vue/src/pages/brand/index.vue | 119 ++-- frontend-vue/src/shared/bridges/pywebview.ts | 105 +++ 9 files changed, 2138 insertions(+), 57 deletions(-) create mode 100644 desktop/txt_templates.json create mode 100644 frontend-vue/src/pages/brand/components/BrandConvertTab.vue create mode 100644 frontend-vue/src/pages/brand/components/BrandDedupeTab.vue create mode 100644 frontend-vue/src/pages/brand/components/BrandSplitTab.vue diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc index e0a3df304e08b3e6f861eb8844f6a1742ebf591c..ba952b6961a76c74150af981ddcfbac5f71bcbe2 100644 GIT binary patch literal 44478 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 F{}*54ti%8S delta 4580 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 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 }>