提交新增内容

This commit is contained in:
super
2026-03-19 23:07:10 +08:00
parent 1e63ab291b
commit 43d508f527
9 changed files with 2138 additions and 57 deletions

View File

@@ -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="选择文件夹")

View File

@@ -1,2 +1,3 @@
pywebview>=5.1
requests>=2.32.0
openpyxl>=3.1.5

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,411 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要转换的 Excel 文件或文件夹将按所选模板生成 txt 文件</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectConvertFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectConvertFolder">选择文件夹</button>
</div>
<el-alert
v-if="!hasPywebviewSupport"
class="desktop-alert"
type="warning"
:closable="false"
title="当前环境未检测到 pywebview文件选择与本地格式转换能力需要在桌面端使用。"
/>
<div class="selected-files clean-placeholder">
<template v-if="convertSelectedPaths.length">
<span v-for="path in convertDisplayPaths" :key="path">{{ path }}</span>
<span v-if="convertSelectedPaths.length > convertDisplayPaths.length" class="more-line">
还有 {{ convertSelectedPaths.length - convertDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待转换文件</span>
</div>
</div>
<div class="section-title">模板选择</div>
<div class="template-toolbar">
<input v-model="convertTemplateName" class="template-name-input" type="text" placeholder="输入模板自定义名称(可选)" />
<button type="button" class="opt-btn small" @click="importConvertTemplate">上传模板</button>
</div>
<div class="option-group">
<label
v-for="template in convertTemplates"
:key="template.id"
class="radio-item"
:class="{ active: convertTemplateId === template.id }"
>
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
<div>
<span class="label">
{{ template.label }}
<span v-if="template.is_default" class="template-tag">默认</span>
<span v-else-if="template.built_in" class="template-tag muted">内置</span>
</span>
<div class="desc">输出文件{{ template.output_filename }}</div>
</div>
</label>
</div>
<div class="run-row compact-row">
<button type="button" class="opt-btn small" :disabled="!convertTemplateId" @click="setConvertDefaultTemplate">
设为默认模板
</button>
</div>
<div class="section-title">转换说明</div>
<div class="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">按当前模板输出</span>
<div class="desc">当前模板{{ currentConvertTemplate?.label || '未加载模板' }}</div>
</div>
</label>
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">SKU 使用雪花ID-序号</span>
<div class="desc">例如 1773816146796-1</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="convertRunning" @click="submitConvertRun">
{{ convertRunning ? '转换中...' : '开始转换' }}
</button>
<span class="loading-msg">
{{ convertRunning ? '正在生成英国.txt请稍候…' : '选择文件后即可执行格式转换' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">转换结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ convertSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ convertSummary.success_count }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ convertSummary.failed_count }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>生成的 txt 列表</span>
<button type="button" class="download-all" :disabled="!convertOutputDir" @click="openConvertOutputDir">
打开输出目录
</button>
</div>
<div v-if="convertResultItems.length === 0" class="empty-tasks">
暂无转换结果完成格式转换后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li
v-for="item in convertResultItems"
:key="`${item.source_path}-${item.output_path || item.error || 'convert'}`"
class="task-item"
>
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
type="button"
class="download"
@click="openConvertResult(item)"
>
打开文件
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand'
import { type ConvertTxtResultItem, type ConvertTxtTemplateItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
const hasPywebviewSupport = hasPywebview()
const convertSelectedPaths = ref<string[]>([])
const convertRunning = ref(false)
const convertOutputDir = ref('')
const convertResultItems = ref<ConvertTxtResultItem[]>([])
const convertSummary = ref({ total: 0, success_count: 0, failed_count: 0 })
const convertTemplates = ref<ConvertTxtTemplateItem[]>([])
const convertTemplateId = ref('uk_offer')
const convertTemplateName = ref('')
const convertDisplayPaths = computed(() => convertSelectedPaths.value.slice(0, 8))
const currentConvertTemplate = computed(
() => convertTemplates.value.find((item) => item.id === convertTemplateId.value) || null,
)
function shorten(value: string, maxLength: number) {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function resetConvertResults() {
convertResultItems.value = []
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
convertOutputDir.value = ''
}
async function loadConvertTemplates() {
const api = getPywebviewApi()
const templates = await api?.list_txt_templates?.()
convertTemplates.value = Array.isArray(templates) ? templates : []
const defaultTemplate = convertTemplates.value.find((item) => item.is_default)
if (defaultTemplate) {
convertTemplateId.value = defaultTemplate.id
return
}
if (convertTemplates.value.length && !convertTemplates.value.some((item) => item.id === convertTemplateId.value)) {
convertTemplateId.value = convertTemplates.value[0].id
}
}
async function importConvertTemplate() {
const api = getPywebviewApi()
if (!api?.import_txt_template) {
ElMessage.warning('当前环境不支持模板上传,请在桌面端打开')
return
}
const result = await api.import_txt_template(convertTemplateName.value.trim() || undefined)
if (!result.success) {
if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
convertTemplateName.value = ''
await loadConvertTemplates()
if (result.template?.id) {
convertTemplateId.value = result.template.id
}
ElMessage.success('模板导入成功')
}
async function setConvertDefaultTemplate() {
const api = getPywebviewApi()
if (!api?.set_default_txt_template || !convertTemplateId.value) {
ElMessage.warning('当前环境不支持设置默认模板')
return
}
const result = await api.set_default_txt_template(convertTemplateId.value)
if (!result.success) {
ElMessage.error(result.error || '设置默认模板失败')
return
}
await loadConvertTemplates()
ElMessage.success('默认模板已更新')
}
async function selectConvertFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
if (!paths?.length) return
convertSelectedPaths.value = paths
resetConvertResults()
ElMessage.success(`已选择 ${paths.length} 个待转换文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectConvertFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
return
}
try {
const folder = await api.select_clean_folder()
if (!folder) return
const result = await expandBrandFolder(folder)
if (result.success && result.paths?.length) {
convertSelectedPaths.value = result.paths
resetConvertResults()
ElMessage.success(`已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitConvertRun() {
const api = getPywebviewApi()
if (!api?.convert_excel_to_uk_txt || !api?.select_folder) {
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
return
}
if (!convertSelectedPaths.value.length) {
ElMessage.warning('请先选择待转换 Excel 文件或文件夹')
return
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
convertRunning.value = true
const result = await api.convert_excel_to_uk_txt({
paths: convertSelectedPaths.value,
output_dir: outputDir,
template_id: convertTemplateId.value,
})
if (!result.success) {
ElMessage.error(result.error || '格式转换失败')
return
}
convertOutputDir.value = result.summary?.output_dir || outputDir
convertSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
convertResultItems.value = result.items || []
ElMessage.success('格式转换完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
} finally {
convertRunning.value = false
}
}
async function openConvertOutputDir() {
const api = getPywebviewApi()
if (!api?.open_path || !convertOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
return
}
const result = await api.open_path(convertOutputDir.value)
if (!result.success) {
ElMessage.error(result.error || '打开目录失败')
}
}
async function openConvertResult(item: ConvertTxtResultItem) {
const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
return
}
const result = await api.open_path(item.output_path)
if (!result.success) {
ElMessage.error(result.error || '打开文件失败')
}
}
onMounted(() => {
loadConvertTemplates().catch(() => undefined)
})
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.opt-btn, .btn-run, .download { border: none; cursor: pointer; transition: all 0.2s ease; }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn.small { padding: 6px 10px; font-size: 12px; }
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; }
.desktop-alert { margin-top: 14px; text-align: left; }
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.option-group { margin-bottom: 20px; }
.template-toolbar { display: flex; gap: 10px; margin-bottom: 12px; }
.template-name-input { flex: 1; min-width: 0; padding: 8px 12px; border-radius: 8px; border: 1px solid #3a3a3a; background: #202020; color: #e0e0e0; font-size: 13px; }
.template-name-input::placeholder { color: #7f8790; }
.template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); }
.template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
.compact-row { margin-top: 4px; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; }
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; }
.btn-run:hover { background: #2980b9; }
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; }
.loading-msg { color: #3498db; font-size: 13px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; }
.left { flex: 1; min-width: 0; }
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; margin-top: 4px; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; }
.clean-placeholder { max-height: none; }
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; }
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
.download-all { padding: 7px 12px; border: 1px solid #2f5f85; border-radius: 6px; background: rgba(52, 152, 219, 0.14); color: #69b6ff; font-size: 12px; cursor: pointer; }
.download-all:disabled { cursor: not-allowed; opacity: 0.55; }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
</style>

View File

@@ -0,0 +1,390 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要清洗的 Excel 文件或文件夹后续将按所选规则输出处理结果</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectCleanFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectCleanFolder">选择文件夹</button>
</div>
<el-alert
v-if="!hasPywebviewSupport"
class="desktop-alert"
type="warning"
:closable="false"
title="当前环境未检测到 pywebview文件选择与 Excel 表头读取能力需要在桌面端使用。"
/>
<div class="selected-files clean-placeholder">
<template v-if="cleanSelectedPaths.length">
<span v-for="path in cleanDisplayPaths" :key="path">{{ path }}</span>
<span v-if="cleanSelectedPaths.length > cleanDisplayPaths.length" class="more-line">
还有 {{ cleanSelectedPaths.length - cleanDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待清洗文件</span>
</div>
</div>
<div class="section-title">保留列设置</div>
<div class="option-group column-option-group">
<div class="column-toolbar">
<span class="column-count">已选择 {{ cleanSelectedColumns.length }} / {{ cleanAvailableColumns.length }} </span>
<div class="column-actions">
<button type="button" class="opt-btn small" @click="selectAllCleanColumns">全选</button>
<button type="button" class="opt-btn small" @click="clearAllCleanColumns">清空</button>
</div>
</div>
<div class="column-grid" v-if="cleanAvailableColumns.length">
<label
v-for="column in cleanAvailableColumns"
:key="column"
class="column-item"
:class="{ active: cleanSelectedColumns.includes(column) }"
>
<input v-model="cleanSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span>
</label>
</div>
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
<div class="desc">读取首个 Excel 文件的第一行作为表头用于动态选择需要保留的列</div>
</div>
<div class="section-title">ID 保留规则</div>
<div class="option-group">
<label class="radio-item" :class="{ active: cleanKeepIntegerIds }">
<input v-model="cleanKeepIntegerIds" type="checkbox" />
<div>
<span class="label">保留整数 ID</span>
<div class="desc">例如 123 这种纯数字 ID</div>
</div>
</label>
<label class="radio-item" :class="{ active: cleanKeepUnderscoreIds }">
<input v-model="cleanKeepUnderscoreIds" type="checkbox" />
<div>
<span class="label">保留下划线 ID</span>
<div class="desc">例如 1_12_3 这种带下划线的子项 ID</div>
</div>
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="cleanRunning" @click="submitCleanRun">
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
</button>
<span class="loading-msg">
{{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">去重结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ cleanSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ cleanSummary.success_count }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ cleanSummary.failed_count }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!cleanOutputDir" @click="openCleanOutputDir">
打开输出目录
</button>
</div>
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
暂无去重结果完成数据去重后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in cleanResultItems" :key="`${item.source_path}-${item.output_path || item.error || 'result'}`" class="task-item">
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
type="button"
class="download"
@click="openCleanResult(item)"
>
打开文件
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand'
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
const hasPywebviewSupport = hasPywebview()
const cleanAvailableColumns = ref<string[]>([])
const cleanSelectedColumns = ref<string[]>([])
const cleanSelectedPaths = ref<string[]>([])
const cleanKeepIntegerIds = ref(true)
const cleanKeepUnderscoreIds = ref(true)
const cleanRunning = ref(false)
const cleanOutputDir = ref('')
const cleanResultItems = ref<CleanExcelResultItem[]>([])
const cleanSummary = ref({ total: 0, success_count: 0, failed_count: 0 })
const cleanDisplayPaths = computed(() => cleanSelectedPaths.value.slice(0, 8))
function shorten(value: string, maxLength: number) {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function selectAllCleanColumns() {
cleanSelectedColumns.value = [...cleanAvailableColumns.value]
}
function clearAllCleanColumns() {
cleanSelectedColumns.value = []
}
async function loadCleanHeaders(filePath: string) {
const api = getPywebviewApi()
if (!api?.get_excel_headers) {
ElMessage.warning('当前环境不支持 Excel 表头读取,请在桌面端打开')
return
}
cleanResultItems.value = []
cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 }
cleanOutputDir.value = ''
const result = await api.get_excel_headers(filePath)
if (!result.success || !result.headers?.length) {
ElMessage.error(result.error || '读取 Excel 表头失败')
cleanAvailableColumns.value = []
cleanSelectedColumns.value = []
return
}
const preferredHeaders = ['id', 'ASIN', '国家', '价格', '卖家名称']
const orderedHeaders = [
...preferredHeaders.filter((header) => result.headers?.includes(header)),
...(result.headers || []).filter((header) => !preferredHeaders.includes(header)),
]
cleanAvailableColumns.value = orderedHeaders
cleanSelectedColumns.value = preferredHeaders.filter((header) => orderedHeaders.includes(header))
}
async function selectCleanFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
if (!paths?.length) return
cleanSelectedPaths.value = paths
await loadCleanHeaders(paths[0])
ElMessage.success(`已选择 ${paths.length} 个待清洗文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectCleanFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
return
}
try {
const folder = await api.select_clean_folder()
if (!folder) return
const result = await expandBrandFolder(folder)
if (result.success && result.paths?.length) {
cleanSelectedPaths.value = result.paths
await loadCleanHeaders(result.paths[0])
ElMessage.success(`已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitCleanRun() {
const api = getPywebviewApi()
if (!api?.clean_excel_files || !api?.select_folder) {
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
return
}
if (!cleanSelectedPaths.value.length) {
ElMessage.warning('请先选择待清洗 Excel 文件或文件夹')
return
}
if (!cleanSelectedColumns.value.length) {
ElMessage.warning('请至少选择一列保留列')
return
}
if (!cleanKeepIntegerIds.value && !cleanKeepUnderscoreIds.value) {
ElMessage.warning('请至少选择一种 ID 保留规则')
return
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
cleanRunning.value = true
const result = await api.clean_excel_files({
paths: cleanSelectedPaths.value,
selected_columns: cleanSelectedColumns.value,
keep_integer_ids: cleanKeepIntegerIds.value,
keep_underscore_ids: cleanKeepUnderscoreIds.value,
output_dir: outputDir,
})
if (!result.success) {
ElMessage.error(result.error || '清洗失败')
return
}
cleanOutputDir.value = result.summary?.output_dir || outputDir
cleanSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
cleanResultItems.value = result.items || []
ElMessage.success('数据去重完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重失败')
} finally {
cleanRunning.value = false
}
}
async function openCleanOutputDir() {
const api = getPywebviewApi()
if (!api?.open_path || !cleanOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
return
}
const result = await api.open_path(cleanOutputDir.value)
if (!result.success) {
ElMessage.error(result.error || '打开目录失败')
}
}
async function openCleanResult(item: CleanExcelResultItem) {
const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
return
}
const result = await api.open_path(item.output_path)
if (!result.success) {
ElMessage.error(result.error || '打开文件失败')
}
}
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.opt-btn, .btn-run, .download { border: none; cursor: pointer; transition: all 0.2s ease; }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn.small { padding: 6px 10px; font-size: 12px; }
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; }
.desktop-alert { margin-top: 14px; text-align: left; }
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.option-group { margin-bottom: 20px; }
.column-option-group { padding: 14px; border-radius: 10px; background: #252525; border: 1px solid #2a2a2a; }
.column-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.column-count { font-size: 12px; color: #a9b4bf; }
.column-actions { display: flex; gap: 8px; }
.column-grid { display: grid; grid-template-columns: 1fr; gap: 8px; max-height: 280px; overflow-y: auto; }
.empty-column-state { padding: 16px 12px; border-radius: 8px; border: 1px dashed #3a3a3a; background: #202020; color: #7f8790; font-size: 12px; line-height: 1.6; }
.column-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #2f2f2f; background: #202020; cursor: pointer; transition: all 0.2s ease; font-size: 13px; color: #d7d7d7; }
.column-item:hover, .column-item.active { border-color: #3b6f98; background: #26313a; }
.column-item input { margin: 0; }
.column-item span { word-break: break-all; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; }
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; }
.btn-run:hover { background: #2980b9; }
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; }
.loading-msg { color: #3498db; font-size: 13px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; }
.left { flex: 1; min-width: 0; }
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; margin-top: 4px; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; }
.clean-placeholder { max-height: none; }
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; }
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
.download-all { padding: 7px 12px; border: 1px solid #2f5f85; border-radius: 6px; background: rgba(52, 152, 219, 0.14); color: #69b6ff; font-size: 12px; cursor: pointer; }
.download-all:disabled { cursor: not-allowed; opacity: 0.55; }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
</style>

View File

@@ -0,0 +1,402 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要拆分的 Excel 文件或文件夹按规则拆成多个 Excel 文件</div>
<div class="btns">
<button type="button" class="opt-btn" @click="selectSplitFiles">选择 Excel 文件</button>
<button type="button" class="opt-btn" @click="selectSplitFolder">选择文件夹</button>
</div>
<div class="selected-files clean-placeholder">
<template v-if="splitSelectedPaths.length">
<span v-for="path in splitDisplayPaths" :key="path">{{ path }}</span>
<span v-if="splitSelectedPaths.length > splitDisplayPaths.length" class="more-line">
还有 {{ splitSelectedPaths.length - splitDisplayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择待拆分文件</span>
</div>
</div>
<div class="section-title">保留列设置</div>
<div class="option-group column-option-group">
<div class="column-toolbar">
<span class="column-count">已选择 {{ splitSelectedColumns.length }} / {{ splitAvailableColumns.length }} </span>
<div class="column-actions">
<button type="button" class="opt-btn small" @click="splitSelectedColumns = [...splitAvailableColumns]">全选</button>
<button type="button" class="opt-btn small" @click="splitSelectedColumns = []">清空</button>
</div>
</div>
<div class="column-grid" v-if="splitAvailableColumns.length">
<label
v-for="column in splitAvailableColumns"
:key="column"
class="column-item"
:class="{ active: splitSelectedColumns.includes(column) }"
>
<input v-model="splitSelectedColumns" type="checkbox" :value="column" />
<span>{{ column }}</span>
</label>
</div>
<div v-else class="empty-column-state">请选择 Excel 文件后读取表头并填充可保留列</div>
</div>
<div class="section-title">总条数</div>
<div class="option-group">
<label class="radio-item active">
<input type="checkbox" checked disabled />
<div>
<span class="label">当前数据总条数</span>
<div class="desc">{{ splitTotalRows }} 不含表头</div>
</div>
</label>
</div>
<div class="section-title">拆分模式</div>
<div class="option-group">
<label class="radio-item" :class="{ active: splitMode === 'rows_per_file' }">
<input v-model="splitMode" type="radio" value="rows_per_file" name="split-mode" />
<div>
<span class="label">按每份条数</span>
<div class="desc">例如每份 2000 最后一份放剩余数据</div>
</div>
</label>
<label class="radio-item" :class="{ active: splitMode === 'parts' }">
<input v-model="splitMode" type="radio" value="parts" name="split-mode" />
<div>
<span class="label">按份数平均拆分</span>
<div class="desc">例如拆 5 按总条数尽量平均分配</div>
</div>
</label>
</div>
<div class="section-title">拆分参数</div>
<div class="option-group">
<label v-if="splitMode === 'rows_per_file'" class="split-input-card">
<span class="label">每份条数</span>
<input v-model.number="splitRowsPerFile" class="split-number-input" type="number" min="1" placeholder="例如 2000" />
</label>
<label v-else class="split-input-card">
<span class="label">拆分份数</span>
<input v-model.number="splitParts" class="split-number-input" type="number" min="1" placeholder="例如 5" />
</label>
</div>
<div class="run-row">
<button type="button" class="btn-run" :disabled="splitRunning" @click="submitSplitRun">
{{ splitRunning ? '拆分中...' : '开始拆分' }}
</button>
<span class="loading-msg">
{{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }}
</span>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">拆分结果</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">已处理文件</span>
<strong>{{ splitSummary.total }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ splitSummary.success_count }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ splitSummary.failed_count }}</strong>
</div>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>拆分后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!splitOutputDir" @click="openSplitOutputDir">
打开输出目录
</button>
</div>
<div v-if="splitResultItems.length === 0" class="empty-tasks">
暂无拆分结果完成数据拆分后会在这里展示输出文件
</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in splitResultItems" :key="`${item.output_path || item.source_path}-${item.row_count || 0}`" class="task-item">
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
type="button"
class="download"
@click="openSplitResult(item)"
>
打开文件
</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand'
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
const splitSelectedPaths = ref<string[]>([])
const splitAvailableColumns = ref<string[]>([])
const splitSelectedColumns = ref<string[]>([])
const splitTotalRows = ref(0)
const splitMode = ref<'rows_per_file' | 'parts'>('rows_per_file')
const splitRowsPerFile = ref<number | null>(2000)
const splitParts = ref<number | null>(5)
const splitRunning = ref(false)
const splitOutputDir = ref('')
const splitResultItems = ref<SplitExcelResultItem[]>([])
const splitSummary = ref({ total: 0, success_count: 0, failed_count: 0 })
const splitDisplayPaths = computed(() => splitSelectedPaths.value.slice(0, 8))
function shorten(value: string, maxLength: number) {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function resetSplitResults() {
splitResultItems.value = []
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
splitOutputDir.value = ''
}
async function loadSplitInfo(filePath: string) {
const api = getPywebviewApi()
if (!api?.get_excel_info) {
ElMessage.warning('当前环境不支持读取 Excel 信息,请在桌面端打开')
return
}
resetSplitResults()
const result = await api.get_excel_info(filePath)
if (!result.success) {
ElMessage.error(result.error || '读取 Excel 信息失败')
splitAvailableColumns.value = []
splitSelectedColumns.value = []
splitTotalRows.value = 0
return
}
splitAvailableColumns.value = result.headers || []
splitSelectedColumns.value = [...(result.headers || [])]
splitTotalRows.value = result.total_rows || 0
}
async function selectSplitFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
if (!paths?.length) return
splitSelectedPaths.value = paths
await loadSplitInfo(paths[0])
ElMessage.success(`已选择 ${paths.length} 个待拆分文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function selectSplitFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
return
}
try {
const folder = await api.select_clean_folder()
if (!folder) return
const result = await expandBrandFolder(folder)
if (result.success && result.paths?.length) {
splitSelectedPaths.value = result.paths
await loadSplitInfo(result.paths[0])
ElMessage.success(`已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
ElMessage.warning(result.error || '该文件夹下没有 xlsx 文件')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function submitSplitRun() {
const api = getPywebviewApi()
if (!api?.split_excel_files || !api?.select_folder) {
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
return
}
if (!splitSelectedPaths.value.length) {
ElMessage.warning('请先选择待拆分 Excel 文件或文件夹')
return
}
if (!splitSelectedColumns.value.length) {
ElMessage.warning('请至少选择一列保留列')
return
}
if (splitMode.value === 'rows_per_file' && (!splitRowsPerFile.value || splitRowsPerFile.value <= 0)) {
ElMessage.warning('请输入有效的每份条数')
return
}
if (splitMode.value === 'parts' && (!splitParts.value || splitParts.value <= 0)) {
ElMessage.warning('请输入有效的拆分份数')
return
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
splitRunning.value = true
const result = await api.split_excel_files({
paths: splitSelectedPaths.value,
selected_columns: splitSelectedColumns.value,
split_mode: splitMode.value,
rows_per_file: splitRowsPerFile.value || undefined,
parts: splitParts.value || undefined,
output_dir: outputDir,
})
if (!result.success) {
ElMessage.error(result.error || '数据拆分失败')
return
}
splitOutputDir.value = result.summary?.output_dir || outputDir
splitSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
splitResultItems.value = result.items || []
ElMessage.success('数据拆分完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
} finally {
splitRunning.value = false
}
}
async function openSplitOutputDir() {
const api = getPywebviewApi()
if (!api?.open_path || !splitOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
return
}
const result = await api.open_path(splitOutputDir.value)
if (!result.success) {
ElMessage.error(result.error || '打开目录失败')
}
}
async function openSplitResult(item: SplitExcelResultItem) {
const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
return
}
const result = await api.open_path(item.output_path)
if (!result.success) {
ElMessage.error(result.error || '打开文件失败')
}
}
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #1a1a1a; }
.section-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 24px; text-align: center; background: #252525; margin-bottom: 20px; transition: all 0.2s ease; }
.upload-zone:hover { border-color: #3498db; background: #2a2a2a; }
.hint { color: #888; font-size: 13px; margin-bottom: 12px; line-height: 1.5; }
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.opt-btn, .btn-run, .download { border: none; cursor: pointer; transition: all 0.2s ease; }
.opt-btn { padding: 8px 16px; font-size: 13px; color: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 6px; }
.opt-btn.small { padding: 6px 10px; font-size: 12px; }
.opt-btn:hover { color: #3498db; background: #333; border-color: #3498db; }
.selected-files { margin-top: 14px; font-size: 12px; color: #888; max-height: 112px; overflow-y: auto; text-align: left; }
.selected-files span { display: block; margin: 4px 0; word-break: break-all; }
.more-line { color: #b8c1cc; }
.option-group { margin-bottom: 20px; }
.column-option-group { padding: 14px; border-radius: 10px; background: #252525; border: 1px solid #2a2a2a; }
.column-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.column-count { font-size: 12px; color: #a9b4bf; }
.column-actions { display: flex; gap: 8px; }
.column-grid { display: grid; grid-template-columns: 1fr; gap: 8px; max-height: 280px; overflow-y: auto; }
.empty-column-state { padding: 16px 12px; border-radius: 8px; border: 1px dashed #3a3a3a; background: #202020; color: #7f8790; font-size: 12px; line-height: 1.6; }
.column-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; border: 1px solid #2f2f2f; background: #202020; cursor: pointer; transition: all 0.2s ease; font-size: 13px; color: #d7d7d7; }
.column-item:hover, .column-item.active { border-color: #3b6f98; background: #26313a; }
.column-item input { margin: 0; }
.column-item span { word-break: break-all; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
.desc { font-size: 12px; color: #888; margin-top: 2px; line-height: 1.5; font-weight: 400; }
.split-input-card { display: flex; flex-direction: column; gap: 10px; padding: 14px 16px; border-radius: 10px; border: 1px solid #2f2f2f; background: #252525; }
.split-number-input { width: 100%; padding: 12px 14px; border-radius: 8px; border: 1px solid #3a3a3a; background: #1b1b1b; color: #eaf4ff; font-size: 22px; font-weight: 700; line-height: 1.2; outline: none; transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; }
.split-number-input:focus { border-color: #3498db; box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.18); background: #20252b; }
.split-number-input::placeholder { color: #708090; font-size: 14px; font-weight: 400; }
.run-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; margin-top: 16px; }
.btn-run { padding: 10px 22px; background: #3498db; color: #fff; border-radius: 8px; font-size: 14px; }
.btn-run:hover { background: #2980b9; }
.btn-run:disabled { background: #555; color: #999; cursor: not-allowed; }
.loading-msg { color: #3498db; font-size: 13px; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; overflow-y: auto; padding: 16px; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 10px; background: #1e1e1e; }
.left { flex: 1; min-width: 0; }
.id { display: inline-block; font-weight: 600; color: #e0e0e0; font-size: 13px; }
.files { font-size: 12px; color: #888; margin-top: 4px; }
.time { font-size: 12px; color: #666; margin-top: 2px; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; }
.download:hover { background: rgba(52, 152, 219, 0.28); }
.empty-tasks { color: #666; font-size: 13px; padding: 24px 8px; }
.clean-placeholder { max-height: none; }
.clean-result-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 220px)); gap: 16px; margin-bottom: 18px; }
.summary-card { padding: 16px 18px; border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; font-size: 24px; color: #eaf4ff; }
.summary-label { font-size: 12px; color: #8d8d8d; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 10px; background: #1e1e1e; min-height: 260px; }
.result-list-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid #2a2a2a; font-size: 14px; color: #ddd; }
.download-all { padding: 7px 12px; border: 1px solid #2f5f85; border-radius: 6px; background: rgba(52, 152, 219, 0.14); color: #69b6ff; font-size: 12px; cursor: pointer; }
.download-all:disabled { cursor: not-allowed; opacity: 0.55; }
@media (max-width: 1100px) { .main-content { flex-direction: column; height: auto; } .left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2a2a2a; } .right-panel { min-height: 420px; } .task-item { flex-direction: column; align-items: flex-start; } .task-right { width: 100%; justify-content: flex-start; } .clean-result-summary { grid-template-columns: 1fr; } }
</style>

View File

@@ -9,12 +9,43 @@
<div class="nav-tabs">
<div class="nav-tab-group">
<button type="button" class="nav-tab active">品牌检测</button>
<button
type="button"
class="nav-tab"
:class="{ active: activeTab === 'brand' }"
@click="activeTab = 'brand'"
>
品牌检测
</button>
<button
type="button"
class="nav-tab"
:class="{ active: activeTab === 'dedupe' }"
@click="activeTab = 'dedupe'"
>
数据去重
</button>
<button
type="button"
class="nav-tab"
:class="{ active: activeTab === 'convert' }"
@click="activeTab = 'convert'"
>
格式转换
</button>
<button
type="button"
class="nav-tab"
:class="{ active: activeTab === 'split' }"
@click="activeTab = 'split'"
>
数据拆分
</button>
</div>
</div>
</header>
<div class="main-content">
<div v-if="activeTab === 'brand'" class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
@@ -106,26 +137,9 @@
<button type="button" class="template-download-row" @click="downloadTemplateXlsx">
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
stroke="rgba(255,255,255,0.92)"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 2v6h6"
stroke="rgba(255,255,255,0.92)"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 13h8M8 17h5"
stroke="rgba(255,255,255,0.86)"
stroke-width="1.5"
stroke-linecap="round"
/>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 13h8M8 17h5" stroke="rgba(255,255,255,0.86)" stroke-width="1.5" stroke-linecap="round" />
</svg>
</span>
<span class="template-download-text">
@@ -137,26 +151,9 @@
<button type="button" class="template-download-row" @click="downloadTemplateZip">
<span class="template-download-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"
stroke="rgba(255,255,255,0.92)"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14 2v6h6"
stroke="rgba(255,255,255,0.92)"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 8h16M7 12h10M7 16h7"
stroke="rgba(255,255,255,0.86)"
stroke-width="1.5"
stroke-linecap="round"
/>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 2v6h6" stroke="rgba(255,255,255,0.92)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 8h16M7 12h10M7 16h7" stroke="rgba(255,255,255,0.86)" stroke-width="1.5" stroke-linecap="round" />
</svg>
</span>
<span class="template-download-text">
@@ -188,10 +185,7 @@
class="inline-progress"
>
<div class="progress-bar-bg small">
<div
class="progress-bar-fill"
:style="{ width: `${getTaskProgressPercent(task)}%` }"
></div>
<div class="progress-bar-fill" :style="{ width: `${getTaskProgressPercent(task)}%` }"></div>
</div>
<span class="inline-progress-text">
{{ task.progress_current || 0 }} / {{ task.progress_total || 0 }}
@@ -227,6 +221,10 @@
</div>
</section>
</div>
<BrandDedupeTab v-else-if="activeTab === 'dedupe'" />
<BrandConvertTab v-else-if="activeTab === 'convert'" />
<BrandSplitTab v-else-if="activeTab === 'split'" />
</div>
</PageShell>
</template>
@@ -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<string[]>([])
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);

View File

@@ -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<string[]>
select_clean_xlsx_files?: () => Promise<string[]>
select_brand_folder?: () => Promise<string | null>
select_clean_folder?: () => Promise<string | null>
select_folder?: () => Promise<string | null>
get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }>
get_excel_info?: (filePath: string) => Promise<ExcelInfoResponse>
clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse>
split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse>
list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]>
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<ConvertTxtResponse>
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 }>