This commit is contained in:
2025-09-27 14:05:45 +08:00
parent 89f600fa11
commit fe3e24b945
23 changed files with 474 additions and 261 deletions

View File

@@ -1,4 +1,4 @@
import {app, BrowserWindow, ipcMain, Menu, screen} from 'electron';
import {app, BrowserWindow, ipcMain, Menu, screen, dialog} from 'electron';
import { existsSync, createWriteStream, promises as fs, statSync } from 'fs';
import {join, dirname} from 'path';
import {spawn, ChildProcessWithoutNullStreams} from 'child_process';
@@ -178,7 +178,7 @@ WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " &
});
setTimeout(() => app.quit(), 1000);
} catch (error) {
} catch (error: unknown) {
console.error('[UPDATE] 更新失败:', error);
}
}
@@ -245,7 +245,7 @@ ipcMain.handle('download-update', async (event, downloadUrl: string) => {
isDownloading = false;
return { success: true, filePath: updateFilePath };
} catch (error) {
} catch (error: unknown) {
isDownloading = false;
downloadedFilePath = null;
@@ -304,7 +304,7 @@ WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " &
}, 500);
return { success: true };
} catch (error) {
} catch (error: unknown) {
return { success: false, error: error instanceof Error ? error.message : '重启失败' };
}
});
@@ -321,6 +321,37 @@ ipcMain.handle('get-update-status', () => {
return { downloadedFilePath, isDownloading, downloadProgress, isPackaged: app.isPackaged, isDev };
});
// 添加文件保存对话框处理器
ipcMain.handle('show-save-dialog', async (event, options) => {
if (!mainWindow) {
return { canceled: true };
}
try {
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
} catch (error) {
console.error('文件保存对话框错误:', error);
return { canceled: true, error: error instanceof Error ? error.message : '对话框打开失败' };
}
});
// 添加文件夹选择对话框处理器
ipcMain.handle('show-open-dialog', async (event, options) => {
if (!mainWindow) {
return { canceled: true };
}
try {
const result = await dialog.showOpenDialog(mainWindow, options);
return result;
} catch (error) {
console.error('文件夹选择对话框错误:', error);
return { canceled: true, error: error instanceof Error ? error.message : '对话框打开失败' };
}
});
async function downloadFile(url: string, filePath: string, onProgress: (progress: any) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;

View File

@@ -9,6 +9,11 @@ const electronAPI = {
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
// 添加文件保存对话框 API
showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options),
// 添加文件夹选择对话框 API
showOpenDialog: (options: any) => ipcRenderer.invoke('show-open-dialog', options),
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
},

View File

@@ -426,9 +426,13 @@ onUnmounted(() => {
SSEManager.disconnect()
})
</script>
<template>
<div>
<div id="app-root" class="root">
<div class="loading-container" id="loading">

View File

@@ -23,9 +23,6 @@ export const amazonApi = {
deleteProduct(productId: string) {
return http.post('/api/amazon/products/delete', { id: productId });
},
exportToExcel(products: unknown[], options: Record<string, unknown> = {}) {
return http.post('/api/amazon/export', { products, ...options });
},
getProductStats() {
return http.get('/api/amazon/stats');
},

View File

@@ -30,9 +30,4 @@ export const rakutenApi = {
getLatestProducts() {
return http.get('/api/rakuten/products/latest').then(res => unwrap<{ products: any[] }>(res));
},
exportAndSave(exportData: unknown) {
return http
.post('/api/rakuten/export-and-save', exportData)
.then(res => unwrap<{ filePath: string; fileName?: string; recordCount?: number; hasImages?: boolean }>(res));
},
};

View File

@@ -59,7 +59,7 @@ export const zebraApi = {
'/api/banma/shops', params as unknown as Record<string, unknown>
);
},
getOrders(params: { accountId?: number; startDate?: string; endDate?: string; page?: number; pageSize?: number; shopIds?: string }) {
getOrders(params: { accountId?: number; startDate?: string; endDate?: string; page?: number; pageSize?: number; shopIds?: string; batchId: string }) {
return http.get<ZebraOrdersResp>('/api/banma/orders', params as unknown as Record<string, unknown>);
},
@@ -70,9 +70,6 @@ export const zebraApi = {
getLatestOrders() {
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
},
exportAndSaveOrders(exportData: unknown) {
return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData);
},
getOrderStats() {
return http.get('/api/banma/orders/stats');
},

View File

@@ -15,7 +15,6 @@ const genmaiLoading = ref(false) // Genmai Spirit加载状态
// 分页配置
const currentPage = ref(1)
const pageSize = ref(15)
const totalPages = computed(() => Math.max(1, Math.ceil((localProductData.value.length || 0) / pageSize.value)))
const amazonUpload = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
@@ -26,14 +25,6 @@ const paginatedData = computed(() => {
return localProductData.value.slice(start, end)
})
// 左侧步骤栏进度
const activeStep = computed(() => {
// 0 导入/输入 -> 1 采集 -> 2 查看校验 -> 3 导出
if (loading.value && progressPercentage.value < 100) return 1
if (!localProductData.value.length) return 0
if (localProductData.value.length && progressPercentage.value < 100) return 1
return 2
})
// 左侧:网站地区 & 待采集队列
const region = ref('JP')
@@ -53,8 +44,7 @@ function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'i
async function processExcelFile(file: File) {
try {
loading.value = true
tableLoading.value = true
localProductData.value = []
// 不再立即清空表格数据,保留之前的数据
progressPercentage.value = 0
progressVisible.value = false
@@ -70,7 +60,6 @@ async function processExcelFile(file: File) {
showMessage(error.message || '处理文件失败', 'error')
} finally {
loading.value = false
tableLoading.value = false
}
}
@@ -99,7 +88,7 @@ async function batchGetProductInfo(asinList: string[]) {
try {
currentAsin.value = '正在处理...'
progressPercentage.value = 0
localProductData.value = []
localProductData.value = [] // 开始采集时才清空表格数据
const batchId = `BATCH_${Date.now()}`
const batchSize = 2 // 每批处理2个ASIN
@@ -147,12 +136,7 @@ async function batchGetProductInfo(asinList: string[]) {
progressPercentage.value = 100
currentAsin.value = '处理完成'
// 结果提示
if (failedCount > 0) {
showMessage(`采集完成!共 ${asinList.length} 个ASIN成功 ${asinList.length - failedCount} 个,失败 ${failedCount}`, 'warning')
} else {
showMessage(`采集完成!成功获取 ${asinList.length} 个产品信息`, 'success')
}
} catch (error: any) {
showMessage(error.message || '批量获取产品信息失败', 'error')
@@ -180,33 +164,53 @@ async function startQueuedFetch() {
}
// 导出Excel数据
const exportLoading = ref(false)
const exportProgress = ref(0)
const showExportProgress = ref(false)
async function exportToExcel() {
if (!localProductData.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
try {
loading.value = true
showMessage('正在生成Excel文件请稍候...', 'info')
exportLoading.value = true
showExportProgress.value = true
exportProgress.value = 0
// 数据格式化 - 只保留核心字段
const exportData = localProductData.value.map(product => ({
asin: product.asin || '',
seller_shipper: getSellerShipperText(product),
price: product.price || '无货'
}))
const progressInterval = setInterval(() => {
if (exportProgress.value < 90) exportProgress.value += Math.random() * 20
}, 100)
await amazonApi.exportToExcel(exportData, {
filename: `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
})
// 生成Excel HTML格式
let html = `<table>
<tr><th>ASIN</th><th>卖家/配送方</th><th>当前售价</th></tr>`
showMessage('Excel文件导出成功', 'success')
} catch (error: any) {
showMessage(error.message || '导出Excel失败', 'error')
} finally {
loading.value = false
}
localProductData.value.forEach(product => {
html += `<tr>
<td>${product.asin || ''}</td>
<td>${getSellerShipperText(product)}</td>
<td>${product.price || '无货'}</td>
</tr>`
})
html += '</table>'
const blob = new Blob([html], { type: 'application/vnd.ms-excel' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xls`
link.click()
URL.revokeObjectURL(link.href)
clearInterval(progressInterval)
exportProgress.value = 100
showMessage('Excel文件导出成功', 'success')
setTimeout(() => {
showExportProgress.value = false
exportLoading.value = false
exportProgress.value = 0
}, 2000)
}
// 获取卖家/配送方信息 - 数据处理辅助函数
@@ -275,6 +279,7 @@ function downloadAmazonTemplate() {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// 组件挂载时获取最新数据
onMounted(async () => {
try {
@@ -345,7 +350,14 @@ onMounted(async () => {
<div class="step-card">
<div class="step-header"><div class="title">导出数据</div></div>
<div class="action-buttons column">
<el-button size="small" class="w100 btn-blue" :disabled="!localProductData.length || loading" @click="exportToExcel">导出Excel</el-button>
<el-button size="small" class="w100 btn-blue" :disabled="!localProductData.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出Excel' }}</el-button>
<!-- 导出进度条 -->
<div v-if="showExportProgress" class="export-progress">
<div class="export-progress-bar">
<div class="export-progress-fill" :style="{ width: exportProgress + '%' }"></div>
</div>
<div class="export-progress-text">{{ Math.round(exportProgress) }}%</div>
</div>
<el-button size="small" class="w100 btn-blue" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
</div>
</div>
@@ -498,6 +510,10 @@ onMounted(async () => {
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
.export-progress-fill { height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease; }
.export-progress-text { font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right; }
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
.table-wrapper { flex: 1; overflow: auto; }
@@ -528,6 +544,7 @@ onMounted(async () => {
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.empty-text { font-size: 14px; color: #909399; }
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; }
</style>
<script lang="ts">

View File

@@ -2,7 +2,7 @@
<div>
<div class="version-info" @click="autoCheck">v{{ version || '-' }}</div>
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center class="update-dialog" title="软件更新">
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center class="update-dialog" :title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'">
<div v-if="stage === 'check'" class="update-content">
<div class="update-layout">
<div class="left-pane">
@@ -39,30 +39,27 @@
</div>
<div v-else-if="stage === 'downloading'" class="update-content">
<div class="update-header text-center">
<img src="/icon/icon.png" class="app-icon" alt="App Icon" />
<h3>正在更新 {{ appName }}</h3>
<p>正在下载更新文件...</p>
</div>
<div class="download-progress">
<div class="progress-info">
<span>{{ prog.current }} / {{ prog.total }}</span>
<div class="download-main">
<div class="download-icon">
<img src="/icon/icon.png" class="app-icon" alt="App Icon" />
</div>
<el-progress
:percentage="prog.percentage"
:show-text="false"
:stroke-width="6"
color="#409EFF" />
<div class="progress-details">
<span>{{ prog.percentage }}%</span>
<span v-if="prog.speed">{{ prog.speed }}</span>
<div class="download-content">
<div class="download-info">
<p>正在下载更新</p>
</div>
<div class="download-progress">
<el-progress
:percentage="prog.percentage"
:show-text="false"
:stroke-width="6"
color="#409EFF" />
<div class="progress-details">
<span style="font-weight: 500">{{ prog.current }} / {{ prog.total }}</span>
<el-button size="small" @click="cancelDownload">取消</el-button>
</div>
</div>
</div>
</div>
<div class="update-buttons">
<el-button @click="cancelDownload">取消下载</el-button>
</div>
</div>
<div v-else-if="stage === 'completed'" class="update-content">
@@ -107,7 +104,7 @@ const show = computed({
type Stage = 'check' | 'downloading' | 'completed'
const stage = ref<Stage>('check')
const appName = ref('ERP客户端')
const appName = ref('我了个电商')
const version = ref('2.0.0')
const prog = ref({ percentage: 0, current: '0 MB', total: '0 MB', speed: '' as string | undefined })
const info = ref({
@@ -149,14 +146,12 @@ async function start() {
ElMessage({ message: '下载链接不可用', type: 'error' })
return
}
if (!window.electronAPI) {
ElMessage({ message: '更新功能不可用', type: 'error' })
return
}
stage.value = 'downloading'
prog.value = { percentage: 0, current: '0 MB', total: '0 MB', speed: '' }
window.electronAPI.onDownloadProgress((progress) => {
prog.value = {
percentage: progress.percentage || 0,
@@ -210,12 +205,6 @@ async function installUpdate() {
type: 'warning'
}
)
if (!window.electronAPI) {
ElMessage({ message: '更新功能不可用', type: 'error' })
return
}
const response = await window.electronAPI.installUpdate()
if (response.success) {
@@ -374,8 +363,53 @@ onUnmounted(() => {
border-radius: 8px;
}
.download-header {
text-align: center;
margin-bottom: 20px;
}
.download-header h3 {
font-size: 14px;
font-weight: 500;
margin: 0;
color: #1f2937;
}
.download-main {
display: grid;
grid-template-columns: 80px 1fr;
align-items: start;
}
.download-icon {
display: flex;
justify-content: center;
}
.download-icon .app-icon {
width: 64px;
height: 64px;
border-radius: 12px;
}
.download-content {
min-width: 0;
}
.download-info {
margin-bottom: 12px;
}
.download-info p {
font-size: 14px;
font-weight: 600;
color: #6b7280;
margin: 0;
}
.download-progress {
margin: 24px 0;
margin: 0;
}
.progress-info {
@@ -388,11 +422,15 @@ onUnmounted(() => {
}
.progress-details {
margin-top: 8px;
font-size: 12px;
color: #909399;
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-details span {
font-size: 12px;
color: #909399;
}
:deep(.el-progress-bar__outer) {

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import { ElMessage } from 'element-plus'
import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy'
// UI 与加载状态
const loading = ref(false)
@@ -139,7 +141,7 @@ async function searchProductInternal(product: any) {
function beforeUpload(file: File) {
const ok = /\.xlsx?$/.test(file.name)
if (!ok) alert('仅支持 .xlsx/.xls 文件')
if (!ok) ElMessage({ message: '仅支持 .xlsx/.xls 文件', type: 'warning' })
return ok
}
@@ -288,36 +290,104 @@ function nextTickSafe() {
return Promise.resolve()
}
function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
ElMessage({ message, type })
}
async function exportToExcel() {
if (!allProducts.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
exportLoading.value = true
try {
if (allProducts.value.length === 0) return alert('没有数据可导出')
exportLoading.value = true
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const fileName = `乐天商品数据_${timestamp}.xlsx`
const payload = {
products: allProducts.value,
title: '乐天商品数据导出',
fileName,
timestamp: new Date().toLocaleString('zh-CN'),
// 传给后端的可选提示参数
useMultiThread: true,
chunkSize: 300,
skipImages: allProducts.value.length > 200,
const ExcelJS = (await import('exceljs')).default
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('乐天商品数据')
worksheet.columns = [
{ header: '店铺名', key: 'shopName', width: 15 },
{ header: '商品图片', key: 'image', width: 15 },
{ header: '商品链接', key: 'productUrl', width: 30 },
{ header: '商品标题', key: 'title', width: 30 },
{ header: '价格', key: 'price', width: 10 },
{ header: '排名', key: 'ranking', width: 8 },
{ header: '1688识图链接', key: 'mapLink', width: 30 },
{ header: '1688运费', key: 'freight', width: 10 },
{ header: '1688中位价', key: 'median', width: 10 },
{ header: '1688最低价', key: 'minPrice', width: 10 },
{ header: '1688中间价', key: 'midPrice', width: 10 },
{ header: '1688最高价', key: 'maxPrice', width: 10 }
]
const imageUrls = allProducts.value.map(product => product.imgUrl || '')
const images = await batchConvertImages(imageUrls, 80)
for (let i = 0; i < allProducts.value.length; i++) {
const product = allProducts.value[i]
const base64Image = images[i]
const row = worksheet.addRow({
shopName: product.originalShopName || '',
image: base64Image ? '图片' : '无图片',
productUrl: product.productUrl || '',
title: product.productTitle || '',
price: product.price || '',
ranking: product.ranking || '',
mapLink: product.mapRecognitionLink || '',
freight: product.freight || '',
median: product.median || '',
minPrice: product.skuPrices?.[0] || '',
midPrice: product.skuPrices?.[Math.floor((product.skuPrices?.length || 0) / 2)] || '',
maxPrice: product.skuPrices?.[product.skuPrices?.length - 1] || ''
})
if (base64Image) {
const base64Data = base64Image.split(',')[1]
if (base64Data) {
const imageId = workbook.addImage({
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
}
const resp = await rakutenApi.exportAndSave(payload)
alert(`Excel文件已保存到: ${resp.filePath}`)
} catch (e: any) {
alert(e?.message || '导出失败')
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
URL.revokeObjectURL(link.href)
showMessage('Excel文件导出成功', 'success')
} catch (error) {
showMessage('导出失败', 'error')
} finally {
exportLoading.value = false
}
}
onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<div class="body-layout">
<!-- 左侧步骤栏 -->
@@ -390,9 +460,10 @@ onMounted(loadLatest)
<div class="step-header">
<div class="title">导出数据</div>
</div>
<div class="mini-hint">点击下方按钮导出 Excel</div>
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading" @click="exportToExcel">导出数据</el-button>
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div>
</div>
@@ -590,6 +661,10 @@ onMounted(loadLatest)
color: #606266;
padding-left: 2px;
}
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
.export-progress-fill { height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease; }
.export-progress-text { font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right; }
.table-container {
display: flex;

View File

@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy'
type Shop = { id: string; shopName: string }
@@ -21,6 +22,7 @@ const showProgress = ref(false)
const allOrderData = ref<ZebraOrder[]>([])
const currentPage = ref(1)
const pageSize = ref(15)
const currentBatchId = ref('')
// 批量获取状态
const fetchCurrentPage = ref(1)
@@ -90,6 +92,7 @@ async function fetchData() {
allOrderData.value = []
fetchCurrentPage.value = 1
fetchTotalItems.value = 0
currentBatchId.value = `ZEBRA_${Date.now()}`
const [startDate = '', endDate = ''] = dateRange.value || []
await fetchPageData(startDate, endDate)
@@ -105,7 +108,8 @@ async function fetchPageData(startDate: string, endDate: string) {
endDate,
page: fetchCurrentPage.value,
pageSize: 50,
shopIds: selectedShops.value.join(',')
shopIds: selectedShops.value.join(','),
batchId: currentBatchId.value
})
const orders = data.orders || []
@@ -143,14 +147,103 @@ function stopFetch() {
// 进度条保留显示,不自动隐藏
}
function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
ElMessage({ message, type })
}
async function exportToExcel() {
if (!allOrderData.value.length) return
if (!allOrderData.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
exportLoading.value = true
try {
const result = await zebraApi.exportAndSaveOrders({ orders: allOrderData.value })
ElMessage({ message: `Excel文件已保存到: ${result.filePath}` as any, type: 'success' })
} catch (e: any) {
ElMessage({ message: e?.message || '导出Excel失败', type: 'error' })
const ExcelJS = (await import('exceljs')).default
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('斑马订单数据')
worksheet.columns = [
{ header: '下单时间', key: 'orderedAt', width: 15 },
{ header: '商品图片', key: 'image', width: 15 },
{ header: '商品名称', key: 'productTitle', width: 25 },
{ header: '乐天订单号', key: 'shopOrderNumber', width: 20 },
{ header: '下单距今', key: 'timeSinceOrder', width: 12 },
{ header: '订单金额/日元', key: 'priceJpy', width: 15 },
{ header: '数量', key: 'productQuantity', width: 8 },
{ header: '税费/日元', key: 'shippingFeeJpy', width: 12 },
{ header: '回款抽点rmb', key: 'serviceFee', width: 15 },
{ header: '商品番号', key: 'productNumber', width: 15 },
{ header: '1688订单号', key: 'poNumber', width: 15 },
{ header: '采购金额/rmb', key: 'shippingFeeCny', width: 15 },
{ header: '国际运费/rmb', key: 'internationalShippingFee', width: 15 },
{ header: '国内物流', key: 'poLogisticsCompany', width: 12 },
{ header: '国内单号', key: 'poTrackingNumber', width: 15 },
{ header: '日本单号', key: 'internationalTrackingNumber', width: 15 },
{ header: '地址状态', key: 'trackInfo', width: 12 }
]
const imageUrls = allOrderData.value.map(order => order.productImage || '')
const imageBase64s = await batchConvertImages(imageUrls, 80)
for (let i = 0; i < allOrderData.value.length; i++) {
const order = allOrderData.value[i]
const base64Image = imageBase64s[i]
const row = worksheet.addRow({
orderedAt: order.orderedAt || '',
image: base64Image ? '图片' : '无图片',
productTitle: order.productTitle || '',
shopOrderNumber: order.shopOrderNumber || '',
timeSinceOrder: order.timeSinceOrder || '',
priceJpy: formatJpy(order.priceJpy),
productQuantity: order.productQuantity || 0,
shippingFeeJpy: formatJpy(order.shippingFeeJpy),
serviceFee: order.serviceFee || '',
productNumber: order.productNumber || '',
poNumber: order.poNumber || '',
shippingFeeCny: formatCny(order.shippingFeeCny),
internationalShippingFee: order.internationalShippingFee || '',
poLogisticsCompany: order.poLogisticsCompany || '',
poTrackingNumber: order.poTrackingNumber || '',
internationalTrackingNumber: order.internationalTrackingNumber || '',
trackInfo: order.trackInfo || ''
})
if (base64Image) {
const base64Data = base64Image.split(',')[1]
if (base64Data) {
const imageId = workbook.addImage({
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
}
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
URL.revokeObjectURL(link.href)
showMessage('Excel文件导出成功', 'success')
} catch (error) {
showMessage('导出失败', 'error')
} finally {
exportLoading.value = false
}
@@ -160,7 +253,8 @@ onMounted(async () => {
await loadAccounts()
try {
const latest = await zebraApi.getLatestOrders()
allOrderData.value = latest?.orders || []
const data = (latest as any)?.data || latest
allOrderData.value = data?.orders || []
} catch {}
})
@@ -298,9 +392,10 @@ async function removeCurrentAccount() {
<div class="step-index">4</div>
<div class="step-body">
<div class="step-title">导出数据</div>
<div class="tip">点击下方按钮导出数据 Excel</div>
<div class="tip">点击下方按钮导出所有订单数据 Excel 文件</div>
<div class="btn-col">
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" @click="exportToExcel" class="w100">导出数据</el-button>
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div>
</div>
</section>
@@ -518,6 +613,10 @@ export default {
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
.export-progress-fill { height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease; }
.export-progress-text { font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right; }
</style>

View File

@@ -1,8 +1,12 @@
import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import './style.css';
import 'element-plus/dist/index.css'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -7,6 +7,8 @@ export default interface ElectronApi {
getDownloadProgress: () => Promise<{ percentage: number; current: string; total: string; speed: string; isDownloading: boolean }>
installUpdate: () => Promise<{ success: boolean; error?: string }>
cancelDownload: () => Promise<{ success: boolean }>
showSaveDialog: (options: { title?: string; defaultPath?: string; filters?: { name: string; extensions: string[] }[] }) => Promise<{ canceled: boolean; filePath?: string; error?: string }>
showOpenDialog: (options: { title?: string; properties?: string[]; filters?: { name: string; extensions: string[] }[] }) => Promise<{ canceled: boolean; filePaths?: string[]; error?: string }>
onDownloadProgress: (callback: (progress: any) => void) => void
removeDownloadProgressListener: () => void
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>

View File

@@ -3,15 +3,20 @@ import com.tashow.erp.entity.AmazonProductEntity;
import com.tashow.erp.repository.AmazonProductRepository;
import com.tashow.erp.service.IAmazonScrapingService;
import com.tashow.erp.utils.ExcelParseUtil;
import com.tashow.erp.utils.ExcelExportUtil;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.LoggerUtil;
import com.tashow.erp.fx.controller.JavaBridge;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
import java.util.Optional;
@RestController
@RequestMapping("/api/amazon")
public class AmazonController {
@@ -20,6 +25,8 @@ public class AmazonController {
private IAmazonScrapingService amazonScrapingService;
@Autowired
private AmazonProductRepository amazonProductRepository;
@Autowired
private JavaBridge javaBridge;
/**
* 批量获取亚马逊产品信息
*/
@@ -53,9 +60,6 @@ public class AmazonController {
*/
@PostMapping("/import/asin")
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return JsonData.buildError("上传文件为空");
}
try {
List<String> asinList = ExcelParseUtil.parseFirstColumn(file);
if (asinList.isEmpty()) {
@@ -72,4 +76,5 @@ public class AmazonController {
return JsonData.buildError("解析失败: " + e.getMessage());
}
}
}

View File

@@ -21,8 +21,7 @@ public class BanmaOrderController {
IBanmaOrderService banmaOrderService;
@Autowired
BanmaOrderRepository banmaOrderRepository;
@Autowired
JavaBridge javaBridge;
@GetMapping("/orders")
public ResponseEntity<Map<String, Object>> getOrders(
@RequestParam(required = false, name = "accountId") Long accountId,
@@ -30,7 +29,7 @@ public class BanmaOrderController {
@RequestParam(required = false, name = "endDate") String endDate,
@RequestParam(defaultValue = "1", name = "page") int page,
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
@RequestParam(required = false, name = "batchId") String batchId,
@RequestParam( "batchId") String batchId,
@RequestParam(required = false, name = "shopIds") String shopIds) {
List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null;
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, batchId, shopIdList);
@@ -73,30 +72,4 @@ public class BanmaOrderController {
return JsonData.buildSuccess(Map.of("orders", orders, "total", orders.size()));
}
/**
* JavaFX专用导出并保存Excel文件到桌面
*/
@PostMapping("/export-and-save")
public JsonData exportAndSave(@RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked")
List<Map<String, Object>> orders = (List<Map<String, Object>>) body.get("orders");
String[] headers = {"下单时间", "商品图片", "商品名称", "乐天订单号", "下单距今时间", "乐天订单金额/日元",
"购买数量", "税费/日元", "服务商回款抽点rmb", "商品番号", "1688采购订单号",
"采购金额/rmb", "国际运费/rmb", "国内物流公司", "国内物流单号", "日本物流单号", "地址状态"};
byte[] excelData = ExcelExportUtil.createExcelWithImages("斑马订单数据", headers, orders, 1, "productImage");
if (excelData.length == 0) return JsonData.buildError("生成Excel文件失败");
String fileName = String.format("斑马订单数据_%s.xlsx", java.time.LocalDate.now().toString());
String savedPath = javaBridge.saveExcelFileToDesktop(excelData, fileName);
return savedPath != null
? JsonData.buildSuccess(Map.of("filePath", savedPath, "fileName", fileName))
: JsonData.buildError("保存文件失败,请检查权限");
} catch (Exception e) {
logger.error("导出并保存斑马订单Excel失败: {}", e.getMessage(), e);
return JsonData.buildError("导出并保存Excel失败: " + e.getMessage());
}
}
}

View File

@@ -59,7 +59,7 @@ public class ProxyController {
}
/**
* 通过URL参数代理获取图片为JavaFX WebView优化
* 通过URL参数代理获取图片
* @param imageUrl 图片URL
* @return 图片字节数组
*/
@@ -104,7 +104,7 @@ public class ProxyController {
// 设置缓存头以提升性能
responseHeaders.setCacheControl("max-age=3600");
responseHeaders.set("Access-Control-Allow-Origin", "*");
// 删除手动CORS设置使用WebConfig中的全局CORS配置
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
} catch (Exception e) {

View File

@@ -126,75 +126,6 @@ public class RakutenController {
}
}
@PostMapping("/export-and-save")
public JsonData exportAndSave(@RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked") List<Map<String, Object>> products = (List<Map<String, Object>>) body.get("products");
if (CollectionUtils.isEmpty(products)) return JsonData.buildError("没有可导出的数据");
boolean skipImages = Optional.ofNullable((Boolean) body.get("skipImages")).orElse(false);
String fileName = Optional.ofNullable((String) body.get("fileName")).filter(name -> !name.trim().isEmpty()).orElse("乐天商品数据_" + java.time.LocalDate.now() + ".xlsx");
// 构建与前端表格一致的列顺序与字段
String[] headers = {
"店铺名",
"商品链接",
"商品图片",
"排名",
"商品标题",
"价格",
"1688识图链接",
"1688运费",
"1688中位价",
"1688最低价",
"1688中间价",
"1688最高价"
};
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> p : products) {
LinkedHashMap<String, Object> row = new LinkedHashMap<>();
List<Double> priceList = parseSkuPriceList(p.get("skuPriceJson"), p.get("skuPrice"));
Double minPrice = priceList.isEmpty() ? null : priceList.get(0);
Double midPrice = priceList.isEmpty() ? null : priceList.get(priceList.size() / 2);
Double maxPrice = priceList.isEmpty() ? null : priceList.get(priceList.size() - 1);
row.put("店铺名", p.get("originalShopName"));
row.put("商品链接", p.get("productUrl"));
row.put("商品图片", p.get("imgUrl"));
row.put("排名", p.get("ranking"));
row.put("商品标题", p.get("productTitle"));
row.put("价格", p.get("price"));
row.put("1688识图链接", p.get("mapRecognitionLink"));
row.put("1688运费", p.get("freight"));
row.put("1688中位价", p.get("median"));
row.put("1688最低价", minPrice);
row.put("1688中间价", midPrice);
row.put("1688最高价", maxPrice);
rows.add(row);
}
byte[] excelData = com.tashow.erp.utils.ExcelExportUtil.createExcelWithImages(
"乐天商品数据",
headers,
rows,
skipImages ? -1 : 1,
skipImages ? null : "商品图片"
);
if (excelData == null || excelData.length == 0) return JsonData.buildError("生成Excel失败");
String savedPath = javaBridge.saveExcelFileToDesktop(excelData, fileName);
if (savedPath == null) return JsonData.buildError("保存文件失败");
log.info("导出Excel: {}, 记录数: {}", fileName, products.size());
return JsonData.buildSuccess(Map.of("filePath", savedPath, "fileName", fileName, "recordCount", products.size(), "hasImages", !skipImages));
} catch (Exception e) {
log.error("导出Excel失败", e);
return JsonData.buildError("导出Excel失败: " + e.getMessage());
}
}
// 解析 skuPriceJson 或 skuPrice 字段中的价格键,返回从小到大排序的价格列表
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {

View File

@@ -18,7 +18,7 @@ public class AmazonProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
@Column
private String asin;
@Column(name = "price")
@@ -34,7 +34,6 @@ public class AmazonProductEntity {
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -60,7 +60,7 @@ public interface AmazonProductRepository extends JpaRepository<AmazonProductEnti
/**
* 获取最新会话的产品数据(只返回最后一次采集的结果)
*/
@Query(value = "SELECT * FROM amazon_products WHERE session_id = (SELECT session_id FROM amazon_products ORDER BY created_at DESC LIMIT 1) ORDER BY created_at ASC, id ASC", nativeQuery = true)
@Query(value = "SELECT * FROM amazon_products WHERE session_id = (SELECT session_id FROM amazon_products GROUP BY session_id ORDER BY session_id DESC LIMIT 1) ORDER BY updated_at ", nativeQuery = true)
List<AmazonProductEntity> findLatestProducts();
/**
@@ -70,4 +70,12 @@ public interface AmazonProductRepository extends JpaRepository<AmazonProductEnti
@Transactional
@Query("DELETE FROM AmazonProductEntity a WHERE a.asin = :asin AND a.createdAt >= :cutoffTime")
void deleteByAsinAndCreatedAtAfter(@Param("asin") String asin, @Param("cutoffTime") LocalDateTime cutoffTime);
/**
* 删除指定时间之前的所有数据(用于数据库清理)
*/
@Modifying
@Transactional
@Query("DELETE FROM AmazonProductEntity a WHERE a.createdAt < :beforeTime")
void deleteAllDataBefore(@Param("beforeTime") LocalDateTime beforeTime);
}

View File

@@ -62,7 +62,7 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
/**
* 获取最新会话的产品数据(只返回最后一次采集的结果)
*/
@Query(value = "SELECT * FROM rakuten_products WHERE session_id = (SELECT session_id FROM rakuten_products ORDER BY created_at LIMIT 1) ORDER BY created_at ASC, id ASC", nativeQuery = true)
@Query(value = "SELECT * FROM rakuten_products WHERE session_id = (SELECT session_id FROM rakuten_products ORDER BY created_at DESC LIMIT 1) ORDER BY created_at ASC, id ASC", nativeQuery = true)
List<RakutenProductEntity> findLatestProducts();
/**
@@ -98,4 +98,12 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
* 根据产品URL列表查找产品
*/
List<RakutenProductEntity> findByProductUrlIn(List<String> productUrls);
/**
* 删除指定时间之前的所有数据(用于数据库清理)
*/
@Modifying
@Transactional
@Query("DELETE FROM RakutenProductEntity r WHERE r.createdAt < :beforeTime")
void deleteAllDataBefore(@Param("beforeTime") LocalDateTime beforeTime);
}

View File

@@ -58,11 +58,13 @@ public class AmazonScrapingServiceImpl implements IAmazonScrapingService, PagePr
// 提取价格
String priceSymbol = html.xpath("//span[@class='a-price-symbol']/text()").toString();
String priceWhole = html.xpath("//span[@class='a-price-whole']/text()").toString();
String price = priceSymbol + priceWhole;
String price = null;
if (!isEmpty(priceSymbol) && !isEmpty(priceWhole)) {
price = priceSymbol + priceWhole;
}
if (isEmpty(price)) {
price = html.xpath("//span[@class='a-price-range']/text()").toString();
}
// 提取卖家
String seller = html.xpath("//a[@id='sellerProfileTriggerId']/text()").toString();
if (isEmpty(seller)) {
@@ -101,39 +103,44 @@ public class AmazonScrapingServiceImpl implements IAmazonScrapingService, PagePr
@Override
public List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId) {
String sessionId = (batchId != null) ? batchId : "SINGLE_" + UUID.randomUUID();
List<AmazonProductEntity> products = new ArrayList<>();
LocalDateTime batchTime = LocalDateTime.now(); // 统一的批次时间
for (String asin : asinList) {
// 第一步清理1小时前的所有旧数据
amazonProductRepository.deleteAllDataBefore(LocalDateTime.now().minusHours(1));
// 第二步处理每个ASIN
Map<String, AmazonProductEntity> allProducts = new HashMap<>();
for (String asin : asinList.stream().distinct().toList()) {
if (asin == null || asin.trim().isEmpty()) continue;
String cleanAsin = asin.replaceAll("[^a-zA-Z0-9]", "");
AmazonProductEntity product = amazonProductRepository.findByAsin(cleanAsin).filter(entity -> entity.getCreatedAt().isAfter(LocalDateTime.now().minusHours(1)) && !isEmpty(entity.getPrice()) && !isEmpty(entity.getSeller())).orElseGet(() -> {
// 采集新数据
// 查找缓存,有缓存就用缓存,没缓存就爬取
Optional<AmazonProductEntity> cached = amazonProductRepository.findByAsin(cleanAsin);
if (cached.isPresent()) {
AmazonProductEntity entity = cached.get();
entity.setSessionId(sessionId);
entity.setUpdatedAt(LocalDateTime.now());
amazonProductRepository.save(entity);
allProducts.put(cleanAsin, entity);
} else {
String url = "https://www.amazon.co.jp/dp/" + cleanAsin;
RakutenProxyUtil proxyUtil = new RakutenProxyUtil();
synchronized (spiderLock) {
activeSpider = Spider.create(this).addUrl(url).setDownloader(proxyUtil.createProxyDownloader(proxyUtil.detectSystemProxy(url))).thread(1);
activeSpider.run();
activeSpider = null;
}
AmazonProductEntity entity = resultCache.getOrDefault(cleanAsin, new AmazonProductEntity());
entity.setAsin(cleanAsin);
entity.setSessionId(sessionId);
try {
amazonProductRepository.save(entity);
dataReportUtil.reportDataCollection("AMAZON", 1, "0");
} catch (Exception e) {
logger.warn("保存商品数据失败: {}", cleanAsin);
}
return entity;
});
products.add(product);
entity.setUpdatedAt(LocalDateTime.now());
amazonProductRepository.save(entity);
dataReportUtil.reportDataCollection("AMAZON", 1, "0");
allProducts.put(cleanAsin, entity);
}
}
return products;
return new ArrayList<>(allProducts.values());
}
private boolean isEmpty(String str) {

View File

@@ -18,9 +18,6 @@ import jakarta.annotation.PostConstruct;
@Service
public class AuthServiceImpl implements IAuthService {
@Value("${api.server.base-url}")
private String serverApiUrl;
@Value("${project.version:2.1.0}")
private String appVersion;

View File

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -50,6 +51,21 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
@Override
@Transactional
public void saveProductsWithSessionId(List<RakutenProduct> products, String sessionId) {
if (products == null || products.isEmpty()) {
return;
}
// 获取所有涉及的店铺名
Set<String> shopNames = products.stream()
.map(RakutenProduct::getOriginalShopName)
.filter(name -> name != null && !name.trim().isEmpty())
.collect(Collectors.toSet());
// 清理所有1小时前的旧数据不分店铺全部清掉
LocalDateTime cutoffTime = LocalDateTime.now().minusHours(1);
repository.deleteAllDataBefore(cutoffTime);
log.info("清理1小时前的所有旧数据");
List<RakutenProductEntity> entities = products.stream()
.map(product -> {
RakutenProductEntity entity = new RakutenProductEntity();
@@ -60,7 +76,7 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
.collect(Collectors.toList());
repository.saveAll(entities);
log.info("保存产品数据sessionId: {},数量: {}", sessionId, products.size());
log.info("保存产品数据sessionId: {},数量: {},涉及店铺: {}", sessionId, products.size(), shopNames);
}
/**
@@ -68,7 +84,11 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
*/
@Override
public boolean hasRecentData(String shopName) {
return repository.existsByOriginalShopNameAndCreatedAtAfter(shopName, LocalDateTime.now().minusHours(1));
boolean hasRecent = repository.existsByOriginalShopNameAndCreatedAtAfter(shopName, LocalDateTime.now().minusHours(1));
if (hasRecent) {
log.info("店铺 {} 存在1小时内缓存数据将使用缓存", shopName);
}
return hasRecent;
}
/**
@@ -112,6 +132,11 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
return;
}
// 清理所有1小时前的旧数据不分店铺全部清掉
LocalDateTime cutoffTime = LocalDateTime.now().minusHours(1);
repository.deleteAllDataBefore(cutoffTime);
log.info("清理1小时前的所有旧缓存数据");
// 根据产品的唯一标识如productUrl来查找并更新对应的数据库记录
List<String> productUrls = products.stream()
.map(RakutenProduct::getProductUrl)

View File

@@ -527,11 +527,7 @@
this.currentAsin = '处理完成';
this.tableLoading = false;
if (failedCount > 0) {
this.$message.warning(`采集完成!共 ${asinList.length} 个ASIN成功 ${asinList.length - failedCount} 个,失败 ${failedCount}`);
} else {
this.$message.success(`采集完成!成功获取 ${asinList.length} 个产品信息`);
}
} catch (error) {
this.$message.error(error.message || '批量获取产品信息失败');