环境搭建配置

This commit is contained in:
super
2026-03-21 10:00:16 +08:00
parent 82ccd9a8ea
commit 4b83bf76b4
55 changed files with 8801 additions and 616 deletions

4
.gitignore vendored
View File

@@ -3,6 +3,7 @@ frontend-vue/node_modules/
# frontend-vue build output
frontend-vue/dist/
frontend-vue/new_web_source
# generated type declarations
frontend-vue/auto-imports.d.ts
@@ -28,3 +29,6 @@ backend-java/src/main/resources/application-local.yml
# backend-java IDE files
backend-java/.idea/
backend-java/*.iml
# desktop app
desktop/

BIN
1.xlsx Normal file

Binary file not shown.

BIN
5.xlsx Normal file

Binary file not shown.

12
app/.env Normal file
View File

@@ -0,0 +1,12 @@
base_url=http://159.75.121.33:15124
workflow_id=7608812635877900322
mysql_host=159.75.121.33
mysql_user=AIimage
proxy_url=https://api.jikip.com/ip-get?num=1&minute=1&format=json&area=all&protocol=1&mode=2&key=r4m8dov52giup2o
proxy_mode=2
client_name=NanriAI
java_api_base=http://127.0.0.1:18080

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -37,7 +37,7 @@ app = create_app()
def run_app(host='127.0.0.1', port=5123):
init_db()
# init_db()
app.run(host=host, port=port, threaded=True, use_reloader=False)

View File

@@ -14,6 +14,7 @@ from config import mysql_host, mysql_user, mysql_password, mysql_database
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
def get_db():
@@ -42,6 +43,22 @@ def _render_html(template_name: str, **context):
return render_template_string(content, **context)
def _render_html_new(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = os.path.join(BASE_DIR, "web_source", template_name)
if not os.path.isfile(path):
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode("utf-8")
except Exception:
content = raw.decode("utf-8", errors="replace")
return render_template_string(content, **context)
def _is_session_user_valid():
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
uid = session.get('user_id')

File diff suppressed because one or more lines are too long

1
app/assets/convert.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/dedupe.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
app/assets/split.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,8 @@
主页面蓝图首页、home、图片工作台、品牌页、静态文件、Logo
"""
import os
from flask import Blueprint, send_file
import requests
from flask import Blueprint, Response, request, send_file
from app_common import (
get_db,
@@ -16,7 +17,9 @@ from app_common import (
)
from flask import redirect, url_for, session
from config import base_url,version
from config import base_url,version,java_api_base
JAVA_API_BASE = java_api_base
main_bp = Blueprint('main', __name__)
@@ -90,6 +93,36 @@ def serve_new_web_source(filename):
@main_bp.route('/newApi/<path:subpath>', methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'])
def proxy_new_api(subpath):
target_url = f"{JAVA_API_BASE}/{subpath}"
try:
headers = {
key: value
for key, value in request.headers.items()
if key.lower() not in {'host', 'content-length'}
}
upstream = requests.request(
method=request.method,
url=target_url,
params=request.args,
data=request.get_data(),
headers=headers,
cookies=request.cookies,
allow_redirects=False,
timeout=300,
)
excluded_headers = {'content-encoding', 'content-length', 'transfer-encoding', 'connection'}
response_headers = [
(name, value)
for name, value in upstream.headers.items()
if name.lower() not in excluded_headers
]
return Response(upstream.content, upstream.status_code, response_headers)
except requests.RequestException as exc:
return {'success': False, 'message': str(exc), 'data': None}, 502
@main_bp.route('/logo.jpg', methods=['GET'])
def get_logo_image():
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')

Binary file not shown.

Binary file not shown.

View File

@@ -7,6 +7,7 @@ import os
from dotenv import load_dotenv
load_dotenv()
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
java_api_base = os.getenv("java_api_base", "http://127.0.0.1:18080")
# MySQL 配置
mysql_host = os.getenv("mysql_host")
@@ -16,6 +17,7 @@ mysql_database = os.getenv("mysql_database","aiimage")
proxy_url = os.getenv("proxy_url")
proxy_mode = int(os.getenv("proxy_mode",1))
client_name=os.getenv("client_name") + ".exe"
cache_path = "./user_data"

View File

@@ -1,7 +1,7 @@
"""
基于 pywebview 实现的跨平台 UI需先登录方可使用
"""
from config import cache_path,debug,version
from config import cache_path,debug,version,java_api_base
import datetime
import json
import sys
@@ -15,6 +15,7 @@ if not debug:
import webview
import threading
import time
import requests
with open("version.txt","w",encoding="utf-8") as file:
@@ -25,6 +26,7 @@ with open("version.txt","w",encoding="utf-8") as file:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
APP_URL = "http://127.0.0.1:5123"
PORT = 5123
JAVA_API_BASE = java_api_base
def generate_images(params):
@@ -143,6 +145,28 @@ class WindowAPI:
)
return result[0] if result else ''
def upload_file_to_java(self, file_path):
"""按本地路径读取文件并上传到 Java 后端临时目录。"""
if not file_path or not str(file_path).strip():
return {'success': False, 'error': '文件路径为空'}
normalized_path = os.path.abspath(str(file_path).strip())
if not os.path.isfile(normalized_path):
return {'success': False, 'error': '文件不存在'}
try:
with open(normalized_path, 'rb') as file_obj:
response = requests.post(
f"{JAVA_API_BASE}/api/files/upload",
files={'file': (os.path.basename(normalized_path), file_obj)},
timeout=120,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
return {'success': False, 'error': 'Java 返回格式错误'}
return payload
except Exception as e:
return {'success': False, 'error': str(e)}
def save_file_from_url(self, url, default_filename='download.zip'):
"""弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。"""
if not url or not url.strip():
@@ -332,7 +356,7 @@ def main():
easy_drag=True
)
api = WindowAPI(window)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip)
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.upload_file_to_java, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip)
webview.start(
debug=True,
storage_path=cache_path,

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>格式转换 - 数富AI</title>
<script type="module" crossorigin src="/assets/convert.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-Rm5Hhqpf.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Chz98E9c.css">
<link rel="stylesheet" crossorigin href="/assets/convert-D4Yv2oRy.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据去重 - 数富AI</title>
<script type="module" crossorigin src="/assets/dedupe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-Rm5Hhqpf.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Chz98E9c.css">
<link rel="stylesheet" crossorigin href="/assets/dedupe-BHE_BCAJ.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数据拆分 - 数富AI</title>
<script type="module" crossorigin src="/assets/split.js"></script>
<link rel="modulepreload" crossorigin href="/assets/pywebview-Rm5Hhqpf.js">
<link rel="stylesheet" crossorigin href="/assets/pywebview-Chz98E9c.css">
<link rel="stylesheet" crossorigin href="/assets/split-_O6NHqDj.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

Binary file not shown.

1
app/version.txt Normal file
View File

@@ -0,0 +1 @@
{"version": "1.0.3"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,11 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class SecurityConfig {
@@ -12,9 +17,25 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("Content-Disposition"));
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.file.controller;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.file.model.vo.ExcelInfoVo;
import com.nanri.aiimage.modules.file.model.vo.UploadFileVo;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import io.swagger.v3.oas.annotations.Operation;
@@ -11,8 +12,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@@ -46,4 +49,14 @@ public class FileUploadController {
MultipartFile file) throws Exception {
return ApiResponse.success(localFileStorageService.saveTempFile(file));
}
@GetMapping("/excel-info")
@Operation(summary = "读取 Excel 信息", description = "根据 fileKey 读取 Excel 第一行表头及总行数,供前端选择保留列和展示总条数。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "读取成功", content = @Content(schema = @Schema(implementation = ExcelInfoVo.class))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "源文件不存在")
})
public ApiResponse<ExcelInfoVo> excelInfo(@RequestParam String fileKey) throws Exception {
return ApiResponse.success(localFileStorageService.getExcelInfo(fileKey));
}
}

View File

@@ -0,0 +1,17 @@
package com.nanri.aiimage.modules.file.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
@Schema(description = "Excel 文件信息")
public class ExcelInfoVo {
@Schema(description = "表头列表")
private List<String> headers;
@Schema(description = "数据总行数")
private Integer totalRows;
}

View File

@@ -2,14 +2,26 @@ package com.nanri.aiimage.modules.file.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.file.model.vo.ExcelInfoVo;
import com.nanri.aiimage.modules.file.model.vo.UploadFileVo;
import lombok.RequiredArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@@ -36,4 +48,73 @@ public class LocalFileStorageService {
vo.setSize(file.getSize());
return vo;
}
public ExcelInfoVo getExcelInfo(String fileKey) throws IOException {
File inputFile = findLocalSourceFile(fileKey);
if (inputFile == null || !inputFile.exists()) {
throw new BusinessException("源文件不存在");
}
DataFormatter formatter = new DataFormatter();
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
Sheet sheet = workbook.getSheetAt(0);
Row headerRow = sheet.getRow(0);
if (headerRow == null) {
throw new BusinessException("Excel 表头为空");
}
Map<String, Integer> headerMap = new HashMap<>();
List<String> headers = new ArrayList<>();
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
Cell cell = headerRow.getCell(i);
String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell));
if (value.isBlank()) {
continue;
}
value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty()
? value
: value.split("idASIN国家状态价格变体数量", 2)[0].trim();
if (headerMap.containsKey(value)) {
continue;
}
headerMap.put(value, i);
headers.add(value);
if ("缩略图地址8".equals(value)) {
break;
}
}
ExcelInfoVo vo = new ExcelInfoVo();
vo.setHeaders(headers);
vo.setTotalRows(Math.max(sheet.getLastRowNum(), 0));
return vo;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw new BusinessException("读取 Excel 信息失败");
}
}
private File findLocalSourceFile(String fileKey) {
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
if (!baseDir.exists()) {
return null;
}
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
}
private String normalizeCellText(String value) {
if (value == null) {
return "";
}
return value.replace("\ufeff", "")
.replace("\u3000", " ")
.replace("\r\n", " ")
.replace("\r", " ")
.replace("\n", " ")
.replace("\t", " ")
.trim()
.replaceAll("\\s+", " ");
}
}

View File

@@ -1,6 +1,9 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="page-shell module-page">
<BrandTopBar active="convert" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要转换的 Excel 文件或文件夹将按所选模板生成 txt 文件</div>
@@ -9,14 +12,6 @@
<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>
@@ -29,10 +24,6 @@
</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"
@@ -44,28 +35,15 @@
<div class="template-item-content">
<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>
{{ template.templateName }}
<span v-if="template.isDefault" class="template-tag">默认</span>
<span v-else-if="template.builtIn" class="template-tag muted">内置</span>
</span>
<div class="desc">输出文件{{ template.output_filename }}</div>
<div class="desc">输出文件{{ template.outputFilename }}</div>
</div>
<button
v-if="!template.built_in"
type="button"
class="template-delete-btn"
@click.stop.prevent="deleteConvertTemplate(template.id)"
>
删除
</button>
</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">
@@ -73,7 +51,7 @@
<input type="checkbox" checked disabled />
<div>
<span class="label">按当前模板输出</span>
<div class="desc">当前模板{{ currentConvertTemplate?.label || '未加载模板' }}</div>
<div class="desc">当前模板{{ currentConvertTemplate?.templateName || '未加载模板' }}</div>
</div>
</label>
@@ -107,11 +85,11 @@
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ convertSummary.success_count }}</strong>
<strong>{{ convertSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ convertSummary.failed_count }}</strong>
<strong>{{ convertSummary.failedCount }}</strong>
</div>
</div>
@@ -127,12 +105,12 @@
<ul v-else class="task-list clean-result-list">
<li
v-for="item in convertResultItems"
:key="`${item.output_path || item.source_path || item.error || 'convert'}`"
:key="`${item.resultId || item.outputFilename || item.sourceFilename}`"
class="task-item"
>
<div class="left">
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
@@ -141,7 +119,7 @@
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
v-if="item.success && item.downloadUrl"
type="button"
class="download"
@click="downloadConvertResult(item)"
@@ -149,10 +127,10 @@
下载文件
</button>
<button
v-if="item.result_id"
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteConvertHistory(item.result_id)"
@click="deleteConvertHistoryRecord(item.resultId)"
>
删除
</button>
@@ -162,43 +140,50 @@
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolder } from '@/shared/api/brand'
import { type ConvertTxtResultItem, type ConvertTxtTemplateItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
import { deleteConvertHistory, getConvertHistory, getConvertTemplates, runConvert, type ConvertResultItem, type ConvertRunVo, type ConvertTemplateVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const hasPywebviewSupport = hasPywebview()
const convertSelectedPaths = ref<string[]>([])
const convertUploadedFiles = ref<UploadedJavaFile[]>([])
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 convertResultItems = ref<ConvertResultItem[]>([])
const convertSummary = ref<ConvertRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const convertTemplates = ref<ConvertTemplateVo[]>([])
const convertTemplateId = 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() {
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
convertOutputDir.value = ''
async function uploadPathsToJava(paths: string[]) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
const uploaded: UploadedJavaFile[] = []
for (const path of paths) {
const result = await api.upload_file_to_java(path)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${path}`)
}
uploaded.push(result.data)
}
return uploaded
}
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)
const templates = await getConvertTemplates()
convertTemplates.value = templates || []
const defaultTemplate = convertTemplates.value.find((item) => item.isDefault)
if (defaultTemplate) {
convertTemplateId.value = defaultTemplate.id
return
@@ -208,79 +193,23 @@ async function loadConvertTemplates() {
}
}
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 deleteConvertTemplate(templateId: string) {
const api = getPywebviewApi()
if (!api?.delete_txt_template) {
ElMessage.warning('当前环境不支持删除模板')
return
}
const result = await api.delete_txt_template(templateId)
if (!result.success) {
ElMessage.error(result.error || '删除模板失败')
return
}
if (convertTemplateId.value === templateId) {
convertTemplateId.value = ''
}
await loadConvertTemplates()
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 handleSelectedPaths(paths: string[], successMessage: string) {
convertSelectedPaths.value = paths
convertUploadedFiles.value = await uploadPathsToJava(paths)
ElMessage.success(successMessage)
}
async function selectConvertFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
convertSelectedPaths.value = paths
resetConvertResults()
ElMessage.success(`已选择 ${paths.length} 个待转换文件`)
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待转换文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
@@ -288,20 +217,18 @@ async function selectConvertFiles() {
async function selectConvertFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_clean_folder()
const folder = await api.select_brand_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 文件`)
await handleSelectedPaths(result.paths, `已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
@@ -312,34 +239,23 @@ async function selectConvertFolder() {
}
async function submitConvertRun() {
const api = getPywebviewApi()
if (!api?.convert_excel_to_uk_txt) {
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
if (!convertUploadedFiles.value.length) {
ElMessage.warning('请先选择待转换 Excel 文件或文件夹')
return
}
if (!convertSelectedPaths.value.length) {
ElMessage.warning('请先选择待转换 Excel 文件或文件夹')
if (!convertTemplateId.value) {
ElMessage.warning('请先选择模板')
return
}
try {
convertRunning.value = true
const result = await api.convert_excel_to_uk_txt({
paths: convertSelectedPaths.value,
template_id: convertTemplateId.value,
const result = await runConvert({
files: convertUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename })),
templateId: convertTemplateId.value,
})
if (!result.success) {
ElMessage.error(result.error || '格式转换失败')
return
}
convertOutputDir.value = ''
convertSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
convertSummary.value = result
convertResultItems.value = result.items || []
await loadConvertHistory()
ElMessage.success('格式转换完成')
} catch (error) {
@@ -349,36 +265,41 @@ async function submitConvertRun() {
}
}
async function deleteConvertHistory(resultId: number) {
const api = getPywebviewApi()
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
async function loadConvertHistory() {
try {
const response = await getConvertHistory()
convertResultItems.value = response.items || []
convertSummary.value = {
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch {
// ignore history load errors
}
const result = await api.delete_history_item('convert', resultId)
if (!result.success) {
ElMessage.error(result.error || '删除失败')
return
}
await loadConvertHistory()
ElMessage.success('已删除')
}
async function downloadConvertResult(item: ConvertTxtResultItem) {
async function deleteConvertHistoryRecord(resultId: number) {
try {
await deleteConvertHistory(resultId)
await loadConvertHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function downloadConvertResult(item: ConvertResultItem) {
const api = getPywebviewApi()
if (!item.output_path) {
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const today = new Date()
const yyyy = String(today.getFullYear())
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}.txt`
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
const result = await api.save_file_from_url(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
@@ -387,25 +308,7 @@ async function downloadConvertResult(item: ConvertTxtResultItem) {
return
}
window.open(item.output_path, '_blank')
}
async function loadConvertHistory() {
const api = getPywebviewApi()
if (!api?.get_convert_history) return
try {
const response = await api.get_convert_history()
if (!response.success || !response.items) return
convertResultItems.value = response.items
convertSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
}
} catch {
// ignore history load errors
}
window.open(item.downloadUrl, '_blank')
}
onMounted(() => {
@@ -415,7 +318,8 @@ onMounted(() => {
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); 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; }
@@ -423,29 +327,22 @@ onMounted(() => {
.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, .btn-run, .download, .btn-delete { 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 { 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; }
.template-item-content { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
.template-delete-btn { padding: 6px 10px; border: 1px solid #5c2f2f; border-radius: 6px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; font-size: 12px; cursor: pointer; }
.template-delete-btn:hover { background: rgba(231, 76, 60, 0.2); }
.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; }
.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); }
.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; }
@@ -464,6 +361,8 @@ onMounted(() => {
.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); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.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; }
@@ -472,7 +371,5 @@ onMounted(() => {
.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>
</style>

View File

@@ -1,6 +1,9 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="page-shell module-page">
<BrandTopBar active="dedupe" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要清洗的 Excel 文件或文件夹后续将按所选规则输出处理结果</div>
@@ -9,14 +12,6 @@
<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>
@@ -95,11 +90,11 @@
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ cleanSummary.success_count }}</strong>
<strong>{{ cleanSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ cleanSummary.failed_count }}</strong>
<strong>{{ cleanSummary.failedCount }}</strong>
</div>
</div>
@@ -113,10 +108,10 @@
</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">
<li v-for="item in cleanResultItems" :key="`${item.resultId || item.outputFilename || item.sourceFilename}`" class="task-item">
<div class="left">
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
@@ -125,7 +120,7 @@
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
v-if="item.success && item.downloadUrl"
type="button"
class="download"
@click="downloadCleanResult(item)"
@@ -133,10 +128,10 @@
下载文件
</button>
<button
v-if="item.result_id"
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteCleanHistory(item.result_id)"
@click="deleteCleanHistoryRecord(item.resultId)"
>
删除
</button>
@@ -146,31 +141,29 @@
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolder } from '@/shared/api/brand'
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
import { deleteDedupeHistory, getDedupeHistory, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const hasPywebviewSupport = hasPywebview()
const cleanAvailableColumns = ref<string[]>([])
const cleanSelectedColumns = ref<string[]>([])
const cleanSelectedPaths = ref<string[]>([])
const cleanUploadedFiles = ref<UploadedJavaFile[]>([])
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 cleanResultItems = ref<DedupeResultItem[]>([])
const cleanSummary = ref<DedupeRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
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]
}
@@ -179,19 +172,26 @@ function clearAllCleanColumns() {
cleanSelectedColumns.value = []
}
async function loadCleanHeaders(filePath: string) {
async function uploadPathsToJava(paths: string[]) {
const api = getPywebviewApi()
if (!api?.get_excel_headers) {
ElMessage.warning('当前环境不支持 Excel 表头读取,请在桌面端打开')
return
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
const uploaded: UploadedJavaFile[] = []
for (const path of paths) {
const result = await api.upload_file_to_java(path)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${path}`)
}
uploaded.push(result.data)
}
return uploaded
}
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 表头失败')
async function loadCleanHeaders(fileKey: string) {
const result = await getExcelInfo(fileKey)
if (!result.headers?.length) {
ElMessage.error('读取 Excel 表头失败')
cleanAvailableColumns.value = []
cleanSelectedColumns.value = []
return
@@ -207,19 +207,26 @@ async function loadCleanHeaders(filePath: string) {
cleanSelectedColumns.value = preferredHeaders.filter((header) => orderedHeaders.includes(header))
}
async function handleSelectedPaths(paths: string[], successMessage: string) {
cleanSelectedPaths.value = paths
cleanUploadedFiles.value = await uploadPathsToJava(paths)
if (cleanUploadedFiles.value.length > 0) {
await loadCleanHeaders(cleanUploadedFiles.value[0].fileKey)
}
ElMessage.success(successMessage)
}
async function selectCleanFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
cleanSelectedPaths.value = paths
await loadCleanHeaders(paths[0])
ElMessage.success(`已选择 ${paths.length} 个待清洗文件`)
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待清洗文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
@@ -227,20 +234,18 @@ async function selectCleanFiles() {
async function selectCleanFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_clean_folder()
const folder = await api.select_brand_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 文件`)
await handleSelectedPaths(result.paths, `已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
@@ -251,12 +256,7 @@ async function selectCleanFolder() {
}
async function submitCleanRun() {
const api = getPywebviewApi()
if (!api?.clean_excel_files) {
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
return
}
if (!cleanSelectedPaths.value.length) {
if (!cleanUploadedFiles.value.length) {
ElMessage.warning('请先选择待清洗 Excel 文件或文件夹')
return
}
@@ -271,24 +271,14 @@ async function submitCleanRun() {
try {
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,
const result = await runDedupe({
files: cleanUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename })),
selectedColumns: cleanSelectedColumns.value,
keepIntegerIds: cleanKeepIntegerIds.value,
keepUnderscoreIds: cleanKeepUnderscoreIds.value,
})
if (!result.success) {
ElMessage.error(result.error || '清洗失败')
return
}
cleanOutputDir.value = ''
cleanSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
cleanSummary.value = result
cleanResultItems.value = result.items || []
await loadCleanHistory()
ElMessage.success('数据去重完成')
} catch (error) {
@@ -298,60 +288,41 @@ async function submitCleanRun() {
}
}
onMounted(() => {
loadCleanHistory().catch(() => undefined)
})
async function loadCleanHistory() {
const api = getPywebviewApi()
if (!api?.get_dedupe_history) {
return
}
try {
const response = await api.get_dedupe_history()
if (!response.success || !response.items) return
cleanResultItems.value = response.items
const response = await getDedupeHistory()
cleanResultItems.value = response.items || []
cleanSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch {
// ignore history load errors
}
}
async function deleteCleanHistory(resultId: number) {
const api = getPywebviewApi()
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
async function deleteCleanHistoryRecord(resultId: number) {
try {
await deleteDedupeHistory(resultId)
await loadCleanHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
const result = await api.delete_history_item('dedupe', resultId)
if (!result.success) {
ElMessage.error(result.error || '删除失败')
return
}
await loadCleanHistory()
ElMessage.success('已删除')
}
async function downloadCleanResult(item: CleanExcelResultItem) {
async function downloadCleanResult(item: DedupeResultItem) {
const api = getPywebviewApi()
if (!item.output_path) {
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const today = new Date()
const yyyy = String(today.getFullYear())
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}_cleaned.xlsx`
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
const result = await api.save_file_from_url(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
@@ -360,12 +331,17 @@ async function downloadCleanResult(item: CleanExcelResultItem) {
return
}
window.open(item.output_path, '_blank')
window.open(item.downloadUrl, '_blank')
}
onMounted(() => {
loadCleanHistory().catch(() => undefined)
})
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); 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; }
@@ -373,11 +349,10 @@ async function downloadCleanResult(item: CleanExcelResultItem) {
.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, .btn-run, .download, .btn-delete { 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; }
@@ -415,6 +390,8 @@ async function downloadCleanResult(item: CleanExcelResultItem) {
.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); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.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; }
@@ -423,7 +400,5 @@ async function downloadCleanResult(item: CleanExcelResultItem) {
.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>
</style>

View File

@@ -1,6 +1,9 @@
<template>
<div class="main-content">
<aside class="left-panel">
<div class="page-shell module-page">
<BrandTopBar active="split" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">选择文件</div>
<div class="upload-zone">
<div class="hint">选择需要拆分的 Excel 文件或文件夹按规则拆成多个 Excel 文件</div>
@@ -105,11 +108,11 @@
</div>
<div class="summary-card">
<span class="summary-label">成功结果</span>
<strong>{{ splitSummary.success_count }}</strong>
<strong>{{ splitSummary.successCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败文件</span>
<strong>{{ splitSummary.failed_count }}</strong>
<strong>{{ splitSummary.failedCount }}</strong>
</div>
</div>
@@ -123,11 +126,11 @@
</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">
<li v-for="item in splitResultItems" :key="`${item.resultId || item.outputFilename || item.sourceFilename}-${item.rowCount || 0}`" class="task-item">
<div class="left">
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div>
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div v-if="item.outputFilename" class="files">输出文件{{ item.outputFilename }}</div>
<div v-if="item.rowCount !== undefined" class="time">包含 {{ item.rowCount }} 条数据</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
@@ -136,7 +139,7 @@
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.success && item.output_path"
v-if="item.success && item.downloadUrl"
type="button"
class="download"
@click="downloadSplitResult(item)"
@@ -144,10 +147,10 @@
下载文件
</button>
<button
v-if="item.result_id"
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteSplitHistory(item.result_id)"
@click="deleteSplitHistoryRecord(item.resultId)"
>
删除
</button>
@@ -157,16 +160,20 @@
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolder } from '@/shared/api/brand'
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
import { deleteSplitHistory, getExcelInfo, getSplitHistory, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
const splitSelectedPaths = ref<string[]>([])
const splitUploadedFiles = ref<UploadedJavaFile[]>([])
const splitAvailableColumns = ref<string[]>([])
const splitSelectedColumns = ref<string[]>([])
const splitTotalRows = ref(0)
@@ -174,55 +181,53 @@ 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 splitResultItems = ref<SplitResultItem[]>([])
const splitSummary = ref<SplitRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
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() {
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
splitOutputDir.value = ''
}
async function loadSplitInfo(filePath: string) {
async function uploadPathsToJava(paths: string[]) {
const api = getPywebviewApi()
if (!api?.get_excel_info) {
ElMessage.warning('当前环境不支持读取 Excel 信息,请在桌面端打开')
return
if (!api?.upload_file_to_java) {
throw new Error('当前桌面端未提供文件上传桥接能力')
}
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
const uploaded: UploadedJavaFile[] = []
for (const path of paths) {
const result = await api.upload_file_to_java(path)
if (!result?.success || !result.data) {
throw new Error(result?.error || result?.message || `上传失败:${path}`)
}
uploaded.push(result.data)
}
return uploaded
}
async function loadSplitInfo(fileKey: string) {
const result = await getExcelInfo(fileKey)
splitAvailableColumns.value = result.headers || []
splitSelectedColumns.value = [...(result.headers || [])]
splitTotalRows.value = result.total_rows || 0
splitTotalRows.value = result.totalRows || 0
}
async function handleSelectedPaths(paths: string[], successMessage: string) {
splitSelectedPaths.value = paths
splitUploadedFiles.value = await uploadPathsToJava(paths)
if (splitUploadedFiles.value.length > 0) {
await loadSplitInfo(splitUploadedFiles.value[0].fileKey)
}
ElMessage.success(successMessage)
}
async function selectSplitFiles() {
const api = getPywebviewApi()
if (!api?.select_clean_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在桌面端打开')
if (!api?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await api.select_clean_xlsx_files()
const paths = await api.select_brand_xlsx_files()
if (!paths?.length) return
splitSelectedPaths.value = paths
await loadSplitInfo(paths[0])
ElMessage.success(`已选择 ${paths.length} 个待拆分文件`)
await handleSelectedPaths(paths, `已选择 ${paths.length} 个待拆分文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
@@ -230,20 +235,18 @@ async function selectSplitFiles() {
async function selectSplitFolder() {
const api = getPywebviewApi()
if (!api?.select_clean_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在桌面端打开')
if (!api?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await api.select_clean_folder()
const folder = await api.select_brand_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 文件`)
await handleSelectedPaths(result.paths, `已选择文件夹内 ${result.paths.length} 个 xlsx 文件`)
return
}
@@ -254,12 +257,7 @@ async function selectSplitFolder() {
}
async function submitSplitRun() {
const api = getPywebviewApi()
if (!api?.split_excel_files) {
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
return
}
if (!splitSelectedPaths.value.length) {
if (!splitUploadedFiles.value.length) {
ElMessage.warning('请先选择待拆分 Excel 文件或文件夹')
return
}
@@ -278,25 +276,15 @@ async function submitSplitRun() {
try {
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,
const result = await runSplit({
files: splitUploadedFiles.value.map((item) => ({ fileKey: item.fileKey, originalFilename: item.originalFilename })),
selectedColumns: splitSelectedColumns.value,
splitMode: splitMode.value,
rowsPerFile: splitMode.value === 'rows_per_file' ? splitRowsPerFile.value || undefined : undefined,
parts: splitMode.value === 'parts' ? splitParts.value || undefined : undefined,
})
if (!result.success) {
ElMessage.error(result.error || '数据拆分失败')
return
}
splitOutputDir.value = ''
splitSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
splitSummary.value = result
splitResultItems.value = result.items || []
await loadSplitHistory()
ElMessage.success('数据拆分完成')
} catch (error) {
@@ -307,57 +295,40 @@ async function submitSplitRun() {
}
async function loadSplitHistory() {
const api = getPywebviewApi()
if (!api?.get_split_history) return
try {
const response = await api.get_split_history()
if (!response.success || !response.items) return
splitResultItems.value = response.items
const response = await getSplitHistory()
splitResultItems.value = response.items || []
splitSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
total: response.items?.length || 0,
successCount: response.items?.filter((item) => item.success).length || 0,
failedCount: response.items?.filter((item) => !item.success).length || 0,
items: response.items || [],
}
} catch {
// ignore history load errors
}
}
onMounted(() => {
loadSplitHistory().catch(() => undefined)
})
async function deleteSplitHistory(resultId: number) {
const api = getPywebviewApi()
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
async function deleteSplitHistoryRecord(resultId: number) {
try {
await deleteSplitHistory(resultId)
await loadSplitHistory()
ElMessage.success('已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
const result = await api.delete_history_item('split', resultId)
if (!result.success) {
ElMessage.error(result.error || '删除失败')
return
}
await loadSplitHistory()
ElMessage.success('已删除')
}
async function downloadSplitResult(item: SplitExcelResultItem) {
async function downloadSplitResult(item: SplitResultItem) {
const api = getPywebviewApi()
if (!item.output_path) {
if (!item.downloadUrl) {
ElMessage.warning('当前结果没有下载地址')
return
}
const today = new Date()
const yyyy = String(today.getFullYear())
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}.xlsx`
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
const result = await api.save_file_from_url(item.downloadUrl, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
@@ -366,12 +337,17 @@ async function downloadSplitResult(item: SplitExcelResultItem) {
return
}
window.open(item.output_path, '_blank')
window.open(item.downloadUrl, '_blank')
}
onMounted(() => {
loadSplitHistory().catch(() => undefined)
})
</script>
<style scoped>
.main-content { display: flex; height: calc(100vh - 56px); }
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); 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; }
@@ -379,7 +355,7 @@ async function downloadSplitResult(item: SplitExcelResultItem) {
.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, .btn-run, .download, .btn-delete { 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; }
@@ -425,6 +401,8 @@ async function downloadSplitResult(item: SplitExcelResultItem) {
.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); }
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; }
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
.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; }
@@ -433,7 +411,5 @@ async function downloadSplitResult(item: SplitExcelResultItem) {
.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>
</style>

View File

@@ -0,0 +1,137 @@
<template>
<header class="top-bar">
<div class="logo-area">
<span class="app-name">数富AI-亚马逊</span>
<a href="/home" class="btn-home">返回首页</a>
</div>
<nav class="nav-tabs">
<span class="nav-tab-group">
<a v-for="item in navItems" :key="item.key" :href="item.href" class="nav-tab" :class="{ active: active === item.key }">
{{ item.label }}
</a>
</span>
</nav>
<div class="top-right"></div>
</header>
</template>
<script setup lang="ts">
const props = defineProps<{
active: 'brand' | 'dedupe' | 'convert' | 'split'
}>()
const active = props.active
const navItems = [
{ key: 'brand', label: '品牌检测', href: '/brand' },
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
] as const
</script>
<style scoped>
.top-bar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 20px;
background: #111;
border-bottom: 1px solid #2a2a2a;
}
.logo-area {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.app-name {
color: #fff;
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
.btn-home {
color: #d2d2d2;
font-size: 13px;
text-decoration: none;
white-space: nowrap;
}
.btn-home:hover {
color: #fff;
}
.nav-tabs {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
}
.nav-tab-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.nav-tab {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
height: 38px;
padding: 0 16px;
border-radius: 10px;
border: 1px solid transparent;
color: #8d8d8d;
background: transparent;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s ease;
}
.nav-tab:hover {
color: #b9d6ff;
background: rgba(77, 126, 189, 0.14);
}
.nav-tab.active {
background: rgba(77, 126, 189, 0.24);
border-color: rgba(91, 148, 219, 0.35);
color: #6caeff;
}
.top-right {
width: 120px;
flex-shrink: 0;
}
@media (max-width: 1100px) {
.top-bar {
height: auto;
align-items: flex-start;
flex-direction: column;
padding: 16px 20px;
}
.nav-tabs {
width: 100%;
justify-content: flex-start;
}
.top-right {
display: none;
}
}
</style>

View File

@@ -39,48 +39,50 @@ export interface BrandTaskMutationResponse {
error?: string
}
const API_PREFIX = '/new'
export function getBrandTaskEventsUrl(taskId: string | number) {
return `/api/brand/tasks/${taskId}/events`
return `${API_PREFIX}/api/brand/tasks/${taskId}/events`
}
export function getBrandTaskDownloadUrl(taskId: string | number) {
return `/api/brand/download/${taskId}`
return `${API_PREFIX}/api/brand/download/${taskId}`
}
export function getBrandTemplateXlsxUrl() {
return '/static/品牌文档格式_模板.xlsx'
return `${API_PREFIX}/static/品牌文档格式_模板.xlsx`
}
export function getBrandTemplateZipUrl() {
return '/static/模板2-以文件夹方式上传.zip'
return `${API_PREFIX}/static/模板2-以文件夹方式上传.zip`
}
export function expandBrandFolder(folder: string) {
return requestPostJson<BrandExpandFolderResponse>('/api/brand/expand-folder', { folder })
return requestPostJson<BrandExpandFolderResponse>(`${API_PREFIX}/api/brand/expand-folder`, { folder })
}
export function runBrandNow(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>('/api/brand/run', { paths, strategy })
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/run`, { paths, strategy })
}
export function createBrandTask(paths: string[], strategy: string) {
return requestPostJson<BrandTaskMutationResponse>('/api/brand/tasks', { paths, strategy })
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks`, { paths, strategy })
}
export function getBrandTasks() {
return requestGetJson<BrandTaskListResponse>('/api/brand/tasks')
return requestGetJson<BrandTaskListResponse>(`${API_PREFIX}/api/brand/tasks`)
}
export function getBrandTask(taskId: string | number) {
return requestGetJson<BrandTaskDetailResponse>(`/api/brand/tasks/${taskId}`)
return requestGetJson<BrandTaskDetailResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}`)
}
export function cancelBrandTask(taskId: string | number) {
return requestPostJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}/cancel`)
return requestPostJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}/cancel`)
}
export function deleteBrandTask(taskId: string | number) {
return requestDeleteJson<BrandTaskMutationResponse>(`/api/brand/tasks/${taskId}`)
return requestDeleteJson<BrandTaskMutationResponse>(`${API_PREFIX}/api/brand/tasks/${taskId}`)
}
export function createBrandTaskEvents(taskId: string | number) {

View File

@@ -6,26 +6,36 @@ import axios, {
type InternalAxiosRequestConfig,
} from 'axios'
export interface ApiSuccess<T> {
export interface LegacyApiSuccess<T> {
success: true
msg?: string
data?: T
}
export interface ApiFailure {
export interface LegacyApiFailure {
success: false
error?: string
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure
export type ApiResponse<T> = LegacyApiSuccess<T> | LegacyApiFailure
export interface JavaApiResponse<T> {
success: boolean
message: string
data: T | null
}
export type RequestOptions<D = unknown> = AxiosRequestConfig<D>
function extractErrorMessage(error: unknown) {
if (error instanceof AxiosError) {
const data = error.response?.data
if (data && typeof data === 'object' && 'error' in data) {
return String(data.error)
if (data && typeof data === 'object') {
if ('error' in data && data.error) {
return String(data.error)
}
if ('message' in data && data.message) {
return String(data.message)
}
}
if (typeof data === 'string' && data.trim()) {
@@ -105,3 +115,11 @@ export const requestGetJson = get
export const requestPostJson = post
export const requestPutJson = put
export const requestDeleteJson = del
export async function unwrapJavaResponse<T>(promise: Promise<JavaApiResponse<T>>) {
const response = await promise
if (!response.success) {
throw new Error(response.message || '请求失败')
}
return response.data as T
}

View File

@@ -0,0 +1,162 @@
import { del, get, http, post, type JavaApiResponse, unwrapJavaResponse } from '@/shared/api/http'
const JAVA_API_PREFIX = '/newApi/api'
export interface UploadedFileRef {
fileKey: string
originalFilename?: string
}
export interface UploadFileVo {
fileKey: string
originalFilename: string
localPath: string
size: number
}
export interface DedupeResultItem {
resultId?: number
sourceFilename: string
outputFilename?: string
success: boolean
error?: string
downloadUrl?: string
}
export interface DedupeRunVo {
total: number
successCount: number
failedCount: number
items: DedupeResultItem[]
}
export interface DedupeHistoryVo {
items: DedupeResultItem[]
}
export interface DedupeRunRequest {
files: UploadedFileRef[]
selectedColumns: string[]
keepIntegerIds: boolean
keepUnderscoreIds: boolean
}
export interface SplitResultItem {
resultId?: number
sourceFilename: string
outputFilename?: string
success: boolean
error?: string
rowCount?: number
downloadUrl?: string
}
export interface SplitRunVo {
total: number
successCount: number
failedCount: number
items: SplitResultItem[]
}
export interface SplitHistoryVo {
items: SplitResultItem[]
}
export interface SplitRunRequest {
files: UploadedFileRef[]
selectedColumns: string[]
splitMode: 'rows_per_file' | 'parts'
rowsPerFile?: number
parts?: number
}
export interface ConvertResultItem {
resultId?: number
sourceFilename: string
outputFilename?: string
success: boolean
error?: string
downloadUrl?: string
}
export interface ConvertRunVo {
total: number
successCount: number
failedCount: number
items: ConvertResultItem[]
}
export interface ConvertHistoryVo {
items: ConvertResultItem[]
}
export interface ConvertRunRequest {
files: UploadedFileRef[]
templateId: string
}
export interface ConvertTemplateVo {
id: string
templateCode: string
templateName: string
outputFilename: string
isDefault?: boolean
builtIn?: boolean
}
export interface ExcelInfoVo {
headers: string[]
totalRows: number
}
export function getExcelInfo(fileKey: string) {
return unwrapJavaResponse(get<JavaApiResponse<ExcelInfoVo>>(`${JAVA_API_PREFIX}/files/excel-info`, {
params: { fileKey },
}))
}
export function runDedupe(request: DedupeRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<DedupeRunVo>, DedupeRunRequest>(`${JAVA_API_PREFIX}/dedupe/run`, request))
}
export function getDedupeHistory() {
return unwrapJavaResponse(get<JavaApiResponse<DedupeHistoryVo>>(`${JAVA_API_PREFIX}/dedupe/history`))
}
export function deleteDedupeHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/dedupe/history/${resultId}`))
}
export function runSplit(request: SplitRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<SplitRunVo>, SplitRunRequest>(`${JAVA_API_PREFIX}/split/run`, request))
}
export function getSplitHistory() {
return unwrapJavaResponse(get<JavaApiResponse<SplitHistoryVo>>(`${JAVA_API_PREFIX}/split/history`))
}
export function deleteSplitHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/split/history/${resultId}`))
}
export function getConvertTemplates() {
return unwrapJavaResponse(get<JavaApiResponse<ConvertTemplateVo[]>>(`${JAVA_API_PREFIX}/convert/templates`))
}
export function runConvert(request: ConvertRunRequest) {
return unwrapJavaResponse(post<JavaApiResponse<ConvertRunVo>, ConvertRunRequest>(`${JAVA_API_PREFIX}/convert/run`, request))
}
export function getConvertHistory() {
return unwrapJavaResponse(get<JavaApiResponse<ConvertHistoryVo>>(`${JAVA_API_PREFIX}/convert/history`))
}
export function deleteConvertHistory(resultId: number) {
return unwrapJavaResponse(del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/convert/history/${resultId}`))
}
export function getJavaDownloadUrl(path: string) {
return path.startsWith('http://') || path.startsWith('https://') ? path : `${JAVA_API_PREFIX}${path}`
}
export { JAVA_API_PREFIX, http }

View File

@@ -1,149 +1,24 @@
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 {
result_id?: number
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 CleanExcelPayload {
paths: string[]
selected_columns: string[]
keep_integer_ids?: boolean
keep_underscore_ids?: boolean
output_dir?: string
}
export interface CleanExcelResultItem {
result_id?: number
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 {
result_id?: number
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 ConvertTxtHistoryResponse {
success: boolean
items?: ConvertTxtResultItem[]
error?: string
}
export interface SplitExcelHistoryResponse {
success: boolean
items?: SplitExcelResultItem[]
error?: string
}
export interface CleanExcelHistoryResponse {
success: boolean
items?: CleanExcelResultItem[]
error?: string
export interface UploadedJavaFile {
fileKey: string
originalFilename: string
localPath: string
size: number
}
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>
close?: () => Promise<void>
minimize?: () => Promise<void>
maximize?: () => Promise<void>
toggle_maximize?: () => Promise<void>
save_image?: (urlOrData: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>
save_image_to_folder?: (urlOrData: string, dirPath: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>
select_folder?: () => Promise<string | null>
get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }>
get_dedupe_history?: () => Promise<CleanExcelHistoryResponse>
get_convert_history?: () => Promise<ConvertTxtHistoryResponse>
get_split_history?: () => Promise<SplitExcelHistoryResponse>
delete_history_item?: (moduleType: string, resultId: number) => Promise<{ success: boolean; 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 }>
delete_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 }>
select_brand_xlsx_files?: () => Promise<string[]>
select_brand_folder?: () => Promise<string | null>
upload_file_to_java?: (filePath: string) => Promise<{ success: boolean; message?: string; data?: UploadedJavaFile; error?: string }>
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; 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 }>
}
declare global {
@@ -154,8 +29,26 @@ declare global {
}
}
let cachedPywebviewApi: PywebviewApi | undefined
let pywebviewReadyBound = false
function syncPywebviewApi() {
cachedPywebviewApi = window.pywebview?.api
return cachedPywebviewApi
}
function bindPywebviewReady() {
if (pywebviewReadyBound || typeof window === 'undefined' || !window.addEventListener) return
pywebviewReadyBound = true
window.addEventListener('pywebviewready', () => {
syncPywebviewApi()
})
}
bindPywebviewReady()
export function getPywebviewApi() {
return window.pywebview?.api
return syncPywebviewApi() || cachedPywebviewApi
}
export function hasPywebview() {

View File

@@ -7,7 +7,7 @@ import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
base: './',
base: '/',
plugins: [
vue(),
AutoImport({
@@ -29,26 +29,15 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/login': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/logout': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/static': {
target: 'http://127.0.0.1:8000',
'/newApi/api': {
target: 'http://127.0.0.1:18080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/newApi/, ''),
},
},
},
build: {
outDir: 'dist',
outDir: 'new_web_source',
emptyOutDir: true,
cssCodeSplit: true,
rollupOptions: {

Binary file not shown.

Binary file not shown.

Binary file not shown.