1
This commit is contained in:
@@ -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 { existsSync, createWriteStream, promises as fs, statSync } from 'fs';
|
||||||
import {join, dirname} from 'path';
|
import {join, dirname} from 'path';
|
||||||
import {spawn, ChildProcessWithoutNullStreams} from 'child_process';
|
import {spawn, ChildProcessWithoutNullStreams} from 'child_process';
|
||||||
@@ -178,7 +178,7 @@ WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " &
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => app.quit(), 1000);
|
setTimeout(() => app.quit(), 1000);
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error('[UPDATE] 更新失败:', error);
|
console.error('[UPDATE] 更新失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +245,7 @@ ipcMain.handle('download-update', async (event, downloadUrl: string) => {
|
|||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
|
|
||||||
return { success: true, filePath: updateFilePath };
|
return { success: true, filePath: updateFilePath };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
isDownloading = false;
|
isDownloading = false;
|
||||||
downloadedFilePath = null;
|
downloadedFilePath = null;
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ WshShell.Run Chr(34) & "${helperPath.replace(/\\/g, '\\\\')}" & Chr(34) & " " &
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
return { success: false, error: error instanceof Error ? error.message : '重启失败' };
|
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 };
|
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> {
|
async function downloadFile(url: string, filePath: string, onProgress: (progress: any) => void): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http;
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ const electronAPI = {
|
|||||||
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
||||||
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
|
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) => {
|
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||||
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
|
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -426,9 +426,13 @@ onUnmounted(() => {
|
|||||||
SSEManager.disconnect()
|
SSEManager.disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div id="app-root" class="root">
|
<div id="app-root" class="root">
|
||||||
<div class="loading-container" id="loading">
|
<div class="loading-container" id="loading">
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ export const amazonApi = {
|
|||||||
deleteProduct(productId: string) {
|
deleteProduct(productId: string) {
|
||||||
return http.post('/api/amazon/products/delete', { id: productId });
|
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() {
|
getProductStats() {
|
||||||
return http.get('/api/amazon/stats');
|
return http.get('/api/amazon/stats');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,9 +30,4 @@ export const rakutenApi = {
|
|||||||
getLatestProducts() {
|
getLatestProducts() {
|
||||||
return http.get('/api/rakuten/products/latest').then(res => unwrap<{ products: any[] }>(res));
|
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));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const zebraApi = {
|
|||||||
'/api/banma/shops', params as unknown as Record<string, unknown>
|
'/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>);
|
return http.get<ZebraOrdersResp>('/api/banma/orders', params as unknown as Record<string, unknown>);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,9 +70,6 @@ export const zebraApi = {
|
|||||||
getLatestOrders() {
|
getLatestOrders() {
|
||||||
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
|
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
|
||||||
},
|
},
|
||||||
exportAndSaveOrders(exportData: unknown) {
|
|
||||||
return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData);
|
|
||||||
},
|
|
||||||
getOrderStats() {
|
getOrderStats() {
|
||||||
return http.get('/api/banma/orders/stats');
|
return http.get('/api/banma/orders/stats');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const genmaiLoading = ref(false) // Genmai Spirit加载状态
|
|||||||
// 分页配置
|
// 分页配置
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(15)
|
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 amazonUpload = ref<HTMLInputElement | null>(null)
|
||||||
const dragActive = ref(false)
|
const dragActive = ref(false)
|
||||||
|
|
||||||
@@ -26,14 +25,6 @@ const paginatedData = computed(() => {
|
|||||||
return localProductData.value.slice(start, end)
|
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')
|
const region = ref('JP')
|
||||||
@@ -53,8 +44,7 @@ function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'i
|
|||||||
async function processExcelFile(file: File) {
|
async function processExcelFile(file: File) {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
tableLoading.value = true
|
// 不再立即清空表格数据,保留之前的数据
|
||||||
localProductData.value = []
|
|
||||||
progressPercentage.value = 0
|
progressPercentage.value = 0
|
||||||
progressVisible.value = false
|
progressVisible.value = false
|
||||||
|
|
||||||
@@ -70,7 +60,6 @@ async function processExcelFile(file: File) {
|
|||||||
showMessage(error.message || '处理文件失败', 'error')
|
showMessage(error.message || '处理文件失败', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
tableLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +88,7 @@ async function batchGetProductInfo(asinList: string[]) {
|
|||||||
try {
|
try {
|
||||||
currentAsin.value = '正在处理...'
|
currentAsin.value = '正在处理...'
|
||||||
progressPercentage.value = 0
|
progressPercentage.value = 0
|
||||||
localProductData.value = []
|
localProductData.value = [] // 开始采集时才清空表格数据
|
||||||
|
|
||||||
const batchId = `BATCH_${Date.now()}`
|
const batchId = `BATCH_${Date.now()}`
|
||||||
const batchSize = 2 // 每批处理2个ASIN
|
const batchSize = 2 // 每批处理2个ASIN
|
||||||
@@ -147,12 +136,7 @@ async function batchGetProductInfo(asinList: string[]) {
|
|||||||
progressPercentage.value = 100
|
progressPercentage.value = 100
|
||||||
currentAsin.value = '处理完成'
|
currentAsin.value = '处理完成'
|
||||||
|
|
||||||
// 结果提示
|
|
||||||
if (failedCount > 0) {
|
|
||||||
showMessage(`采集完成!共 ${asinList.length} 个ASIN,成功 ${asinList.length - failedCount} 个,失败 ${failedCount} 个`, 'warning')
|
|
||||||
} else {
|
|
||||||
showMessage(`采集完成!成功获取 ${asinList.length} 个产品信息`, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showMessage(error.message || '批量获取产品信息失败', 'error')
|
showMessage(error.message || '批量获取产品信息失败', 'error')
|
||||||
@@ -180,33 +164,53 @@ async function startQueuedFetch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出Excel数据
|
// 导出Excel数据
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
const exportProgress = ref(0)
|
||||||
|
const showExportProgress = ref(false)
|
||||||
|
|
||||||
async function exportToExcel() {
|
async function exportToExcel() {
|
||||||
if (!localProductData.value.length) {
|
if (!localProductData.value.length) {
|
||||||
showMessage('没有数据可供导出', 'warning')
|
showMessage('没有数据可供导出', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
exportLoading.value = true
|
||||||
loading.value = true
|
showExportProgress.value = true
|
||||||
showMessage('正在生成Excel文件,请稍候...', 'info')
|
exportProgress.value = 0
|
||||||
|
|
||||||
// 数据格式化 - 只保留核心字段
|
const progressInterval = setInterval(() => {
|
||||||
const exportData = localProductData.value.map(product => ({
|
if (exportProgress.value < 90) exportProgress.value += Math.random() * 20
|
||||||
asin: product.asin || '',
|
}, 100)
|
||||||
seller_shipper: getSellerShipperText(product),
|
|
||||||
price: product.price || '无货'
|
|
||||||
}))
|
|
||||||
|
|
||||||
await amazonApi.exportToExcel(exportData, {
|
// 生成Excel HTML格式
|
||||||
filename: `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
let html = `<table>
|
||||||
})
|
<tr><th>ASIN</th><th>卖家/配送方</th><th>当前售价</th></tr>`
|
||||||
|
|
||||||
showMessage('Excel文件导出成功', 'success')
|
localProductData.value.forEach(product => {
|
||||||
} catch (error: any) {
|
html += `<tr>
|
||||||
showMessage(error.message || '导出Excel失败', 'error')
|
<td>${product.asin || ''}</td>
|
||||||
} finally {
|
<td>${getSellerShipperText(product)}</td>
|
||||||
loading.value = false
|
<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)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时获取最新数据
|
// 组件挂载时获取最新数据
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -345,7 +350,14 @@ onMounted(async () => {
|
|||||||
<div class="step-card">
|
<div class="step-card">
|
||||||
<div class="step-header"><div class="title">导出数据</div></div>
|
<div class="step-header"><div class="title">导出数据</div></div>
|
||||||
<div class="action-buttons column">
|
<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>
|
<el-button size="small" class="w100 btn-blue" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,6 +510,10 @@ onMounted(async () => {
|
|||||||
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
|
.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; }
|
.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; }
|
.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-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-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; }
|
.table-wrapper { flex: 1; overflow: auto; }
|
||||||
@@ -528,6 +544,7 @@ onMounted(async () => {
|
|||||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
||||||
.empty-text { font-size: 14px; color: #909399; }
|
.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; }
|
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="version-info" @click="autoCheck">v{{ version || '-' }}</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 v-if="stage === 'check'" class="update-content">
|
||||||
<div class="update-layout">
|
<div class="update-layout">
|
||||||
<div class="left-pane">
|
<div class="left-pane">
|
||||||
@@ -39,30 +39,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="stage === 'downloading'" class="update-content">
|
<div v-else-if="stage === 'downloading'" class="update-content">
|
||||||
<div class="update-header text-center">
|
<div class="download-main">
|
||||||
<img src="/icon/icon.png" class="app-icon" alt="App Icon" />
|
<div class="download-icon">
|
||||||
<h3>正在更新 {{ appName }}</h3>
|
<img src="/icon/icon.png" class="app-icon" alt="App Icon" />
|
||||||
<p>正在下载更新文件...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="download-progress">
|
|
||||||
<div class="progress-info">
|
|
||||||
<span>{{ prog.current }} / {{ prog.total }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<el-progress
|
<div class="download-content">
|
||||||
:percentage="prog.percentage"
|
<div class="download-info">
|
||||||
:show-text="false"
|
<p>正在下载更新</p>
|
||||||
:stroke-width="6"
|
</div>
|
||||||
color="#409EFF" />
|
<div class="download-progress">
|
||||||
<div class="progress-details">
|
<el-progress
|
||||||
<span>{{ prog.percentage }}%</span>
|
:percentage="prog.percentage"
|
||||||
<span v-if="prog.speed">{{ prog.speed }}</span>
|
: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>
|
</div>
|
||||||
|
|
||||||
<div class="update-buttons">
|
|
||||||
<el-button @click="cancelDownload">取消下载</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="stage === 'completed'" class="update-content">
|
<div v-else-if="stage === 'completed'" class="update-content">
|
||||||
@@ -107,7 +104,7 @@ const show = computed({
|
|||||||
|
|
||||||
type Stage = 'check' | 'downloading' | 'completed'
|
type Stage = 'check' | 'downloading' | 'completed'
|
||||||
const stage = ref<Stage>('check')
|
const stage = ref<Stage>('check')
|
||||||
const appName = ref('ERP客户端')
|
const appName = ref('我了个电商')
|
||||||
const version = ref('2.0.0')
|
const version = ref('2.0.0')
|
||||||
const prog = ref({ percentage: 0, current: '0 MB', total: '0 MB', speed: '' as string | undefined })
|
const prog = ref({ percentage: 0, current: '0 MB', total: '0 MB', speed: '' as string | undefined })
|
||||||
const info = ref({
|
const info = ref({
|
||||||
@@ -149,14 +146,12 @@ async function start() {
|
|||||||
ElMessage({ message: '下载链接不可用', type: 'error' })
|
ElMessage({ message: '下载链接不可用', type: 'error' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!window.electronAPI) {
|
|
||||||
ElMessage({ message: '更新功能不可用', type: 'error' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stage.value = 'downloading'
|
stage.value = 'downloading'
|
||||||
prog.value = { percentage: 0, current: '0 MB', total: '0 MB', speed: '' }
|
prog.value = { percentage: 0, current: '0 MB', total: '0 MB', speed: '' }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.electronAPI.onDownloadProgress((progress) => {
|
window.electronAPI.onDownloadProgress((progress) => {
|
||||||
prog.value = {
|
prog.value = {
|
||||||
percentage: progress.percentage || 0,
|
percentage: progress.percentage || 0,
|
||||||
@@ -210,12 +205,6 @@ async function installUpdate() {
|
|||||||
type: 'warning'
|
type: 'warning'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!window.electronAPI) {
|
|
||||||
ElMessage({ message: '更新功能不可用', type: 'error' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await window.electronAPI.installUpdate()
|
const response = await window.electronAPI.installUpdate()
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -374,8 +363,53 @@ onUnmounted(() => {
|
|||||||
border-radius: 8px;
|
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 {
|
.download-progress {
|
||||||
margin: 24px 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-info {
|
.progress-info {
|
||||||
@@ -388,11 +422,15 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-details {
|
.progress-details {
|
||||||
margin-top: 8px;
|
margin-top: 12px;
|
||||||
font-size: 12px;
|
|
||||||
color: #909399;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-details span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-progress-bar__outer) {
|
:deep(.el-progress-bar__outer) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, onMounted} from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import {rakutenApi} from '../../api/rakuten'
|
import {rakutenApi} from '../../api/rakuten'
|
||||||
|
import { batchConvertImages } from '../../utils/imageProxy'
|
||||||
|
|
||||||
// UI 与加载状态
|
// UI 与加载状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -139,7 +141,7 @@ async function searchProductInternal(product: any) {
|
|||||||
|
|
||||||
function beforeUpload(file: File) {
|
function beforeUpload(file: File) {
|
||||||
const ok = /\.xlsx?$/.test(file.name)
|
const ok = /\.xlsx?$/.test(file.name)
|
||||||
if (!ok) alert('仅支持 .xlsx/.xls 文件')
|
if (!ok) ElMessage({ message: '仅支持 .xlsx/.xls 文件', type: 'warning' })
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,36 +290,104 @@ function nextTickSafe() {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
|
||||||
|
ElMessage({ message, type })
|
||||||
|
}
|
||||||
|
|
||||||
async function exportToExcel() {
|
async function exportToExcel() {
|
||||||
|
if (!allProducts.value.length) {
|
||||||
|
showMessage('没有数据可供导出', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exportLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (allProducts.value.length === 0) return alert('没有数据可导出')
|
const ExcelJS = (await import('exceljs')).default
|
||||||
exportLoading.value = true
|
const workbook = new ExcelJS.Workbook()
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
const worksheet = workbook.addWorksheet('乐天商品数据')
|
||||||
const fileName = `乐天商品数据_${timestamp}.xlsx`
|
|
||||||
const payload = {
|
worksheet.columns = [
|
||||||
products: allProducts.value,
|
{ header: '店铺名', key: 'shopName', width: 15 },
|
||||||
title: '乐天商品数据导出',
|
{ header: '商品图片', key: 'image', width: 15 },
|
||||||
fileName,
|
{ header: '商品链接', key: 'productUrl', width: 30 },
|
||||||
timestamp: new Date().toLocaleString('zh-CN'),
|
{ header: '商品标题', key: 'title', width: 30 },
|
||||||
// 传给后端的可选提示参数
|
{ header: '价格', key: 'price', width: 10 },
|
||||||
useMultiThread: true,
|
{ header: '排名', key: 'ranking', width: 8 },
|
||||||
chunkSize: 300,
|
{ header: '1688识图链接', key: 'mapLink', width: 30 },
|
||||||
skipImages: allProducts.value.length > 200,
|
{ 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}`)
|
const buffer = await workbook.xlsx.writeBuffer()
|
||||||
} catch (e: any) {
|
const blob = new Blob([buffer], {
|
||||||
alert(e?.message || '导出失败')
|
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 {
|
} finally {
|
||||||
exportLoading.value = false
|
exportLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onMounted(loadLatest)
|
onMounted(loadLatest)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="rakuten-root">
|
<div class="rakuten-root">
|
||||||
|
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="body-layout">
|
<div class="body-layout">
|
||||||
<!-- 左侧步骤栏 -->
|
<!-- 左侧步骤栏 -->
|
||||||
@@ -390,9 +460,10 @@ onMounted(loadLatest)
|
|||||||
<div class="step-header">
|
<div class="step-header">
|
||||||
<div class="title">导出数据</div>
|
<div class="title">导出数据</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-hint">点击下方按钮导出为 Excel</div>
|
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
|
||||||
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading" @click="exportToExcel">导出数据</el-button>
|
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
|
||||||
|
<!-- 导出进度条 -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -590,6 +661,10 @@ onMounted(loadLatest)
|
|||||||
color: #606266;
|
color: #606266;
|
||||||
padding-left: 2px;
|
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 {
|
.table-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
|
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
|
||||||
import AccountManager from '../common/AccountManager.vue'
|
import AccountManager from '../common/AccountManager.vue'
|
||||||
|
import { batchConvertImages } from '../../utils/imageProxy'
|
||||||
|
|
||||||
type Shop = { id: string; shopName: string }
|
type Shop = { id: string; shopName: string }
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const showProgress = ref(false)
|
|||||||
const allOrderData = ref<ZebraOrder[]>([])
|
const allOrderData = ref<ZebraOrder[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
|
const currentBatchId = ref('')
|
||||||
|
|
||||||
// 批量获取状态
|
// 批量获取状态
|
||||||
const fetchCurrentPage = ref(1)
|
const fetchCurrentPage = ref(1)
|
||||||
@@ -90,6 +92,7 @@ async function fetchData() {
|
|||||||
allOrderData.value = []
|
allOrderData.value = []
|
||||||
fetchCurrentPage.value = 1
|
fetchCurrentPage.value = 1
|
||||||
fetchTotalItems.value = 0
|
fetchTotalItems.value = 0
|
||||||
|
currentBatchId.value = `ZEBRA_${Date.now()}`
|
||||||
|
|
||||||
const [startDate = '', endDate = ''] = dateRange.value || []
|
const [startDate = '', endDate = ''] = dateRange.value || []
|
||||||
await fetchPageData(startDate, endDate)
|
await fetchPageData(startDate, endDate)
|
||||||
@@ -105,7 +108,8 @@ async function fetchPageData(startDate: string, endDate: string) {
|
|||||||
endDate,
|
endDate,
|
||||||
page: fetchCurrentPage.value,
|
page: fetchCurrentPage.value,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
shopIds: selectedShops.value.join(',')
|
shopIds: selectedShops.value.join(','),
|
||||||
|
batchId: currentBatchId.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const orders = data.orders || []
|
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() {
|
async function exportToExcel() {
|
||||||
if (!allOrderData.value.length) return
|
if (!allOrderData.value.length) {
|
||||||
|
showMessage('没有数据可供导出', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
exportLoading.value = true
|
exportLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await zebraApi.exportAndSaveOrders({ orders: allOrderData.value })
|
const ExcelJS = (await import('exceljs')).default
|
||||||
ElMessage({ message: `Excel文件已保存到: ${result.filePath}` as any, type: 'success' })
|
const workbook = new ExcelJS.Workbook()
|
||||||
} catch (e: any) {
|
const worksheet = workbook.addWorksheet('斑马订单数据')
|
||||||
ElMessage({ message: e?.message || '导出Excel失败', type: 'error' })
|
|
||||||
|
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 {
|
} finally {
|
||||||
exportLoading.value = false
|
exportLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -160,7 +253,8 @@ onMounted(async () => {
|
|||||||
await loadAccounts()
|
await loadAccounts()
|
||||||
try {
|
try {
|
||||||
const latest = await zebraApi.getLatestOrders()
|
const latest = await zebraApi.getLatestOrders()
|
||||||
allOrderData.value = latest?.orders || []
|
const data = (latest as any)?.data || latest
|
||||||
|
allOrderData.value = data?.orders || []
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,9 +392,10 @@ async function removeCurrentAccount() {
|
|||||||
<div class="step-index">4</div>
|
<div class="step-index">4</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<div class="step-title">导出数据</div>
|
<div class="step-title">导出数据</div>
|
||||||
<div class="tip">点击下方按钮,可导出数据为 Excel。</div>
|
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
|
||||||
<div class="btn-col">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -518,6 +613,10 @@ export default {
|
|||||||
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
.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-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; }
|
.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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import 'element-plus/dist/index.css'
|
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus, {
|
||||||
|
locale: zhCn,
|
||||||
|
})
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default interface ElectronApi {
|
|||||||
getDownloadProgress: () => Promise<{ percentage: number; current: string; total: string; speed: string; isDownloading: boolean }>
|
getDownloadProgress: () => Promise<{ percentage: number; current: string; total: string; speed: string; isDownloading: boolean }>
|
||||||
installUpdate: () => Promise<{ success: boolean; error?: string }>
|
installUpdate: () => Promise<{ success: boolean; error?: string }>
|
||||||
cancelDownload: () => Promise<{ success: boolean }>
|
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
|
onDownloadProgress: (callback: (progress: any) => void) => void
|
||||||
removeDownloadProgressListener: () => void
|
removeDownloadProgressListener: () => void
|
||||||
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>
|
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import com.tashow.erp.entity.AmazonProductEntity;
|
|||||||
import com.tashow.erp.repository.AmazonProductRepository;
|
import com.tashow.erp.repository.AmazonProductRepository;
|
||||||
import com.tashow.erp.service.IAmazonScrapingService;
|
import com.tashow.erp.service.IAmazonScrapingService;
|
||||||
import com.tashow.erp.utils.ExcelParseUtil;
|
import com.tashow.erp.utils.ExcelParseUtil;
|
||||||
|
import com.tashow.erp.utils.ExcelExportUtil;
|
||||||
import com.tashow.erp.utils.JsonData;
|
import com.tashow.erp.utils.JsonData;
|
||||||
import com.tashow.erp.utils.LoggerUtil;
|
import com.tashow.erp.utils.LoggerUtil;
|
||||||
|
import com.tashow.erp.fx.controller.JavaBridge;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Optional;
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/amazon")
|
@RequestMapping("/api/amazon")
|
||||||
public class AmazonController {
|
public class AmazonController {
|
||||||
@@ -20,6 +25,8 @@ public class AmazonController {
|
|||||||
private IAmazonScrapingService amazonScrapingService;
|
private IAmazonScrapingService amazonScrapingService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private AmazonProductRepository amazonProductRepository;
|
private AmazonProductRepository amazonProductRepository;
|
||||||
|
@Autowired
|
||||||
|
private JavaBridge javaBridge;
|
||||||
/**
|
/**
|
||||||
* 批量获取亚马逊产品信息
|
* 批量获取亚马逊产品信息
|
||||||
*/
|
*/
|
||||||
@@ -53,9 +60,6 @@ public class AmazonController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/import/asin")
|
@PostMapping("/import/asin")
|
||||||
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
|
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
|
||||||
if (file.isEmpty()) {
|
|
||||||
return JsonData.buildError("上传文件为空");
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
List<String> asinList = ExcelParseUtil.parseFirstColumn(file);
|
List<String> asinList = ExcelParseUtil.parseFirstColumn(file);
|
||||||
if (asinList.isEmpty()) {
|
if (asinList.isEmpty()) {
|
||||||
@@ -72,4 +76,5 @@ public class AmazonController {
|
|||||||
return JsonData.buildError("解析失败: " + e.getMessage());
|
return JsonData.buildError("解析失败: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,7 @@ public class BanmaOrderController {
|
|||||||
IBanmaOrderService banmaOrderService;
|
IBanmaOrderService banmaOrderService;
|
||||||
@Autowired
|
@Autowired
|
||||||
BanmaOrderRepository banmaOrderRepository;
|
BanmaOrderRepository banmaOrderRepository;
|
||||||
@Autowired
|
|
||||||
JavaBridge javaBridge;
|
|
||||||
@GetMapping("/orders")
|
@GetMapping("/orders")
|
||||||
public ResponseEntity<Map<String, Object>> getOrders(
|
public ResponseEntity<Map<String, Object>> getOrders(
|
||||||
@RequestParam(required = false, name = "accountId") Long accountId,
|
@RequestParam(required = false, name = "accountId") Long accountId,
|
||||||
@@ -30,7 +29,7 @@ public class BanmaOrderController {
|
|||||||
@RequestParam(required = false, name = "endDate") String endDate,
|
@RequestParam(required = false, name = "endDate") String endDate,
|
||||||
@RequestParam(defaultValue = "1", name = "page") int page,
|
@RequestParam(defaultValue = "1", name = "page") int page,
|
||||||
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
|
@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) {
|
@RequestParam(required = false, name = "shopIds") String shopIds) {
|
||||||
List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null;
|
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);
|
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()));
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ public class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过URL参数代理获取图片(为JavaFX WebView优化)
|
* 通过URL参数代理获取图片
|
||||||
* @param imageUrl 图片URL
|
* @param imageUrl 图片URL
|
||||||
* @return 图片字节数组
|
* @return 图片字节数组
|
||||||
*/
|
*/
|
||||||
@@ -104,7 +104,7 @@ public class ProxyController {
|
|||||||
|
|
||||||
// 设置缓存头以提升性能
|
// 设置缓存头以提升性能
|
||||||
responseHeaders.setCacheControl("max-age=3600");
|
responseHeaders.setCacheControl("max-age=3600");
|
||||||
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
// 删除手动CORS设置,使用WebConfig中的全局CORS配置
|
||||||
|
|
||||||
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -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 字段中的价格键,返回从小到大排序的价格列表
|
// 解析 skuPriceJson 或 skuPrice 字段中的价格键,返回从小到大排序的价格列表
|
||||||
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {
|
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class AmazonProductEntity {
|
|||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
@Column(unique = true, nullable = false)
|
@Column
|
||||||
private String asin;
|
private String asin;
|
||||||
|
|
||||||
@Column(name = "price")
|
@Column(name = "price")
|
||||||
@@ -34,7 +34,6 @@ public class AmazonProductEntity {
|
|||||||
@Column(name = "created_at")
|
@Column(name = "created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Column(name = "updated_at")
|
@Column(name = "updated_at")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
@@ -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();
|
List<AmazonProductEntity> findLatestProducts();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,4 +70,12 @@ public interface AmazonProductRepository extends JpaRepository<AmazonProductEnti
|
|||||||
@Transactional
|
@Transactional
|
||||||
@Query("DELETE FROM AmazonProductEntity a WHERE a.asin = :asin AND a.createdAt >= :cutoffTime")
|
@Query("DELETE FROM AmazonProductEntity a WHERE a.asin = :asin AND a.createdAt >= :cutoffTime")
|
||||||
void deleteByAsinAndCreatedAtAfter(@Param("asin") String asin, @Param("cutoffTime") LocalDateTime 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);
|
||||||
}
|
}
|
||||||
@@ -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();
|
List<RakutenProductEntity> findLatestProducts();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,4 +98,12 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
|
|||||||
* 根据产品URL列表查找产品
|
* 根据产品URL列表查找产品
|
||||||
*/
|
*/
|
||||||
List<RakutenProductEntity> findByProductUrlIn(List<String> productUrls);
|
List<RakutenProductEntity> findByProductUrlIn(List<String> productUrls);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定时间之前的所有数据(用于数据库清理)
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query("DELETE FROM RakutenProductEntity r WHERE r.createdAt < :beforeTime")
|
||||||
|
void deleteAllDataBefore(@Param("beforeTime") LocalDateTime beforeTime);
|
||||||
}
|
}
|
||||||
@@ -58,11 +58,13 @@ public class AmazonScrapingServiceImpl implements IAmazonScrapingService, PagePr
|
|||||||
// 提取价格
|
// 提取价格
|
||||||
String priceSymbol = html.xpath("//span[@class='a-price-symbol']/text()").toString();
|
String priceSymbol = html.xpath("//span[@class='a-price-symbol']/text()").toString();
|
||||||
String priceWhole = html.xpath("//span[@class='a-price-whole']/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)) {
|
if (isEmpty(price)) {
|
||||||
price = html.xpath("//span[@class='a-price-range']/text()").toString();
|
price = html.xpath("//span[@class='a-price-range']/text()").toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取卖家
|
// 提取卖家
|
||||||
String seller = html.xpath("//a[@id='sellerProfileTriggerId']/text()").toString();
|
String seller = html.xpath("//a[@id='sellerProfileTriggerId']/text()").toString();
|
||||||
if (isEmpty(seller)) {
|
if (isEmpty(seller)) {
|
||||||
@@ -101,39 +103,44 @@ public class AmazonScrapingServiceImpl implements IAmazonScrapingService, PagePr
|
|||||||
@Override
|
@Override
|
||||||
public List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId) {
|
public List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId) {
|
||||||
String sessionId = (batchId != null) ? batchId : "SINGLE_" + UUID.randomUUID();
|
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;
|
if (asin == null || asin.trim().isEmpty()) continue;
|
||||||
String cleanAsin = asin.replaceAll("[^a-zA-Z0-9]", "");
|
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;
|
String url = "https://www.amazon.co.jp/dp/" + cleanAsin;
|
||||||
RakutenProxyUtil proxyUtil = new RakutenProxyUtil();
|
RakutenProxyUtil proxyUtil = new RakutenProxyUtil();
|
||||||
|
|
||||||
synchronized (spiderLock) {
|
synchronized (spiderLock) {
|
||||||
activeSpider = Spider.create(this).addUrl(url).setDownloader(proxyUtil.createProxyDownloader(proxyUtil.detectSystemProxy(url))).thread(1);
|
activeSpider = Spider.create(this).addUrl(url).setDownloader(proxyUtil.createProxyDownloader(proxyUtil.detectSystemProxy(url))).thread(1);
|
||||||
activeSpider.run();
|
activeSpider.run();
|
||||||
activeSpider = null;
|
activeSpider = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
AmazonProductEntity entity = resultCache.getOrDefault(cleanAsin, new AmazonProductEntity());
|
AmazonProductEntity entity = resultCache.getOrDefault(cleanAsin, new AmazonProductEntity());
|
||||||
entity.setAsin(cleanAsin);
|
entity.setAsin(cleanAsin);
|
||||||
entity.setSessionId(sessionId);
|
entity.setSessionId(sessionId);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
try {
|
amazonProductRepository.save(entity);
|
||||||
amazonProductRepository.save(entity);
|
dataReportUtil.reportDataCollection("AMAZON", 1, "0");
|
||||||
dataReportUtil.reportDataCollection("AMAZON", 1, "0");
|
allProducts.put(cleanAsin, entity);
|
||||||
} catch (Exception e) {
|
}
|
||||||
logger.warn("保存商品数据失败: {}", cleanAsin);
|
|
||||||
}
|
|
||||||
return entity;
|
|
||||||
});
|
|
||||||
|
|
||||||
products.add(product);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return products;
|
return new ArrayList<>(allProducts.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isEmpty(String str) {
|
private boolean isEmpty(String str) {
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ import jakarta.annotation.PostConstruct;
|
|||||||
@Service
|
@Service
|
||||||
public class AuthServiceImpl implements IAuthService {
|
public class AuthServiceImpl implements IAuthService {
|
||||||
|
|
||||||
@Value("${api.server.base-url}")
|
|
||||||
private String serverApiUrl;
|
|
||||||
|
|
||||||
@Value("${project.version:2.1.0}")
|
@Value("${project.version:2.1.0}")
|
||||||
private String appVersion;
|
private String appVersion;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -50,6 +51,21 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void saveProductsWithSessionId(List<RakutenProduct> products, String sessionId) {
|
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()
|
List<RakutenProductEntity> entities = products.stream()
|
||||||
.map(product -> {
|
.map(product -> {
|
||||||
RakutenProductEntity entity = new RakutenProductEntity();
|
RakutenProductEntity entity = new RakutenProductEntity();
|
||||||
@@ -60,7 +76,7 @@ public class RakutenCacheServiceImpl implements IRakutenCacheService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
repository.saveAll(entities);
|
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
|
@Override
|
||||||
public boolean hasRecentData(String shopName) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理所有1小时前的旧数据,不分店铺全部清掉
|
||||||
|
LocalDateTime cutoffTime = LocalDateTime.now().minusHours(1);
|
||||||
|
repository.deleteAllDataBefore(cutoffTime);
|
||||||
|
log.info("清理1小时前的所有旧缓存数据");
|
||||||
|
|
||||||
// 根据产品的唯一标识(如productUrl)来查找并更新对应的数据库记录
|
// 根据产品的唯一标识(如productUrl)来查找并更新对应的数据库记录
|
||||||
List<String> productUrls = products.stream()
|
List<String> productUrls = products.stream()
|
||||||
.map(RakutenProduct::getProductUrl)
|
.map(RakutenProduct::getProductUrl)
|
||||||
|
|||||||
@@ -527,11 +527,7 @@
|
|||||||
this.currentAsin = '处理完成';
|
this.currentAsin = '处理完成';
|
||||||
this.tableLoading = false;
|
this.tableLoading = false;
|
||||||
|
|
||||||
if (failedCount > 0) {
|
|
||||||
this.$message.warning(`采集完成!共 ${asinList.length} 个ASIN,成功 ${asinList.length - failedCount} 个,失败 ${failedCount} 个`);
|
|
||||||
} else {
|
|
||||||
this.$message.success(`采集完成!成功获取 ${asinList.length} 个产品信息`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$message.error(error.message || '批量获取产品信息失败');
|
this.$message.error(error.message || '批量获取产品信息失败');
|
||||||
|
|||||||
Reference in New Issue
Block a user