Initial commit
This commit is contained in:
535
electron-vue-template/src/renderer/App.vue
Normal file
535
electron-vue-template/src/renderer/App.vue
Normal file
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, defineAsyncComponent } from 'vue'
|
||||
import { ElConfigProvider, ElMessage, ElMessageBox } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
// 图标已移至对应组件
|
||||
import 'element-plus/dist/index.css'
|
||||
import { authApi } from './api/auth'
|
||||
import { deviceApi, type DeviceItem, type DeviceQuota } from './api/device'
|
||||
import ZebraDashboard from './components/zebra/ZebraDashboard.vue'
|
||||
const LoginDialog = defineAsyncComponent(() => import('./components/auth/LoginDialog.vue'))
|
||||
const RegisterDialog = defineAsyncComponent(() => import('./components/auth/RegisterDialog.vue'))
|
||||
const NavigationBar = defineAsyncComponent(() => import('./components/layout/NavigationBar.vue'))
|
||||
const RakutenDashboard = defineAsyncComponent(() => import('./components/rakuten/RakutenDashboard.vue'))
|
||||
const AmazonDashboard = defineAsyncComponent(() => import('./components/amazon/AmazonDashboard.vue'))
|
||||
|
||||
// 导航历史栈
|
||||
const navigationHistory = ref<string[]>(['rakuten'])
|
||||
const currentHistoryIndex = ref(0)
|
||||
|
||||
// 应用状态
|
||||
const activeMenu = ref('rakuten')
|
||||
const isAuthenticated = ref(false)
|
||||
const showAuthDialog = ref(false)
|
||||
const showRegDialog = ref(false)
|
||||
const zhCnLocale = zhCn
|
||||
const currentUsername = ref('')
|
||||
const showDeviceDialog = ref(false)
|
||||
const deviceLoading = ref(false)
|
||||
const devices = ref<DeviceItem[]>([])
|
||||
const deviceQuota = ref<DeviceQuota>({ limit: 0, used: 0 })
|
||||
const userPermissions = ref<string>('')
|
||||
|
||||
// 菜单配置 - 复刻ERP客户端格式
|
||||
const menuConfig = [
|
||||
{ key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R' },
|
||||
{ key: 'amazon', name: 'Amazon', index: 'amazon', icon: 'A' },
|
||||
{ key: 'zebra', name: 'Zebra', index: 'zebra', icon: 'Z' },
|
||||
{ key: 'shopee', name: 'Shopee', index: 'shopee', icon: 'S' },
|
||||
]
|
||||
|
||||
// 权限检查 - 复刻ERP客户端逻辑
|
||||
function hasPermission(module: string) {
|
||||
// 默认显示的基础菜单(未登录时也显示)
|
||||
const defaultModules = ['rakuten', 'amazon', 'zebra']
|
||||
|
||||
if (!isAuthenticated.value) {
|
||||
return defaultModules.includes(module)
|
||||
}
|
||||
|
||||
const permissions = userPermissions.value
|
||||
if (!permissions) {
|
||||
return defaultModules.includes(module) // 没有权限信息时显示默认菜单
|
||||
}
|
||||
|
||||
// 简化权限检查:直接检查模块名是否在权限字符串中
|
||||
return permissions.includes(module)
|
||||
}
|
||||
|
||||
const visibleMenus = computed(() => menuConfig.filter(item => hasPermission(item.key)))
|
||||
|
||||
const canGoBack = computed(() => currentHistoryIndex.value > 0)
|
||||
const canGoForward = computed(() => currentHistoryIndex.value < navigationHistory.value.length - 1)
|
||||
|
||||
function showContent() {
|
||||
const loading = document.getElementById('loading')
|
||||
if (loading) {
|
||||
loading.style.opacity = '0'
|
||||
setTimeout(() => { loading.style.display = 'none' }, 100)
|
||||
}
|
||||
const app = document.getElementById('app-root')
|
||||
if (app) app.style.opacity = '1'
|
||||
}
|
||||
|
||||
function addToHistory(menu: string) {
|
||||
if (navigationHistory.value[currentHistoryIndex.value] !== menu) {
|
||||
navigationHistory.value = navigationHistory.value.slice(0, currentHistoryIndex.value + 1)
|
||||
navigationHistory.value.push(menu)
|
||||
currentHistoryIndex.value = navigationHistory.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (canGoBack.value) {
|
||||
currentHistoryIndex.value--
|
||||
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
|
||||
}
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
if (canGoForward.value) {
|
||||
currentHistoryIndex.value++
|
||||
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
|
||||
}
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleMenuSelect(key: string) {
|
||||
// 检查是否需要认证
|
||||
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
|
||||
if (!isAuthenticated.value && authRequiredMenus.includes(key)) {
|
||||
showAuthDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
activeMenu.value = key
|
||||
addToHistory(key)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess(data: { token: string; user: any }) {
|
||||
isAuthenticated.value = true
|
||||
showAuthDialog.value = false
|
||||
|
||||
try {
|
||||
currentUsername.value = data?.user?.username || currentUsername.value
|
||||
userPermissions.value = data?.permissions || data?.user?.permissions || ''
|
||||
} catch {}
|
||||
|
||||
// 登录成功后自动注册设备 - 简化版
|
||||
try {
|
||||
const username = data?.user?.username || currentUsername.value
|
||||
if (username) {
|
||||
await deviceApi.register({ username })
|
||||
}
|
||||
} catch (e) {
|
||||
// 设备注册失败不影响登录流程,静默处理
|
||||
console.warn('设备注册失败:', e)
|
||||
}
|
||||
}
|
||||
async function handleUserClick() {
|
||||
if (!isAuthenticated.value) {
|
||||
showAuthDialog.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning', confirmButtonText: '退出', cancelButtonText: '取消' })
|
||||
const token = localStorage.getItem('token') || ''
|
||||
try { await authApi.logout(token) } catch {}
|
||||
try { localStorage.removeItem('token') } catch {}
|
||||
isAuthenticated.value = false
|
||||
currentUsername.value = ''
|
||||
userPermissions.value = ''
|
||||
showAuthDialog.value = true
|
||||
showDeviceDialog.value = false
|
||||
ElMessage.success('已退出登录')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
function handleLoginCancel() {
|
||||
showAuthDialog.value = false
|
||||
}
|
||||
|
||||
function showRegisterDialog() {
|
||||
showAuthDialog.value = false
|
||||
showRegDialog.value = true
|
||||
}
|
||||
|
||||
function handleRegisterSuccess() {
|
||||
showRegDialog.value = false
|
||||
showAuthDialog.value = true
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
showRegDialog.value = false
|
||||
showAuthDialog.value = true
|
||||
}
|
||||
|
||||
// 检查认证状态 - 复刻ERP客户端逻辑
|
||||
async function checkAuth() {
|
||||
const token = localStorage.getItem('token')
|
||||
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const response = await authApi.verifyToken(token)
|
||||
if (response.success) {
|
||||
isAuthenticated.value = true
|
||||
if (!currentUsername.value) {
|
||||
const u = getUsernameFromToken(token)
|
||||
if (u) currentUsername.value = u
|
||||
}
|
||||
userPermissions.value = response.permissions || ''
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要显示登录弹框
|
||||
if (!isAuthenticated.value && authRequiredMenus.includes(activeMenu.value)) {
|
||||
showAuthDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function getClientIdFromToken(token?: string) {
|
||||
try {
|
||||
const t = token || localStorage.getItem('token') || ''
|
||||
const payload = JSON.parse(atob(t.split('.')[1] || ''))
|
||||
return payload.clientId || ''
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function getUsernameFromToken(token?: string) {
|
||||
try {
|
||||
const t = token || localStorage.getItem('token') || ''
|
||||
const payload = JSON.parse(atob(t.split('.')[1] || ''))
|
||||
return payload.username || ''
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
async function openDeviceManager() {
|
||||
if (!isAuthenticated.value) {
|
||||
showAuthDialog.value = true
|
||||
return
|
||||
}
|
||||
showDeviceDialog.value = true
|
||||
await fetchDeviceData()
|
||||
}
|
||||
|
||||
async function fetchDeviceData() {
|
||||
const username = (currentUsername.value || getUsernameFromToken()).trim()
|
||||
if (!username) {
|
||||
ElMessage.warning('未获取到用户名,请重新登录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
deviceLoading.value = true
|
||||
const [quota, list] = await Promise.all([
|
||||
deviceApi.getQuota(username),
|
||||
deviceApi.list(username),
|
||||
])
|
||||
deviceQuota.value = quota || { limit: 0, used: 0 }
|
||||
const clientId = getClientIdFromToken()
|
||||
devices.value = (list || []).map(d => ({ ...d, isCurrent: d.deviceId === clientId })) as any
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取设备列表失败')
|
||||
} finally {
|
||||
deviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemoveDevice(row: DeviceItem & { isCurrent?: boolean }) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要移除该设备吗?', '你确定要移除设备吗?', { confirmButtonText: '确定移除', cancelButtonText: '取消', type: 'warning' })
|
||||
await deviceApi.remove({ deviceId: row.deviceId })
|
||||
devices.value = devices.value.filter(d => d.deviceId !== row.deviceId)
|
||||
deviceQuota.value.used = Math.max(0, (deviceQuota.value.used || 0) - 1)
|
||||
if (row.isCurrent) {
|
||||
// 当前设备被移除,清理登录状态
|
||||
isAuthenticated.value = false
|
||||
showAuthDialog.value = true
|
||||
try { localStorage.removeItem('token') } catch {}
|
||||
}
|
||||
ElMessage.success('已移除设备')
|
||||
} catch (e) {
|
||||
/* 用户取消或失败 */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
showContent()
|
||||
await checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="zhCnLocale">
|
||||
<div id="app-root" class="root">
|
||||
<div class="loading-container" id="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="erp-container">
|
||||
<div class="sidebar">
|
||||
<div class="user-avatar">
|
||||
<img src="/icon/icon.png" alt="logo" />
|
||||
</div>
|
||||
<div class="menu-group-title">电商平台</div>
|
||||
<ul class="menu">
|
||||
<li
|
||||
v-for="item in visibleMenus"
|
||||
:key="item.key"
|
||||
class="menu-item"
|
||||
:class="{ active: activeMenu === item.key }"
|
||||
@click="handleMenuSelect(item.key)"
|
||||
>
|
||||
<span class="menu-text"><span class="menu-icon" :data-k="item.key">{{ item.icon }}</span>{{ item.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<NavigationBar
|
||||
:can-go-back="canGoBack"
|
||||
:can-go-forward="canGoForward"
|
||||
:active-menu="activeMenu"
|
||||
@go-back="goBack"
|
||||
@go-forward="goForward"
|
||||
@reload="reloadPage"
|
||||
@user-click="handleUserClick"
|
||||
@open-device="openDeviceManager" />
|
||||
<div class="content-body">
|
||||
<div
|
||||
class="dashboard-home"
|
||||
v-if="!isAuthenticated && (activeMenu === 'rakuten' || activeMenu === 'amazon' || activeMenu === 'zebra')">
|
||||
<div class="icon-container">
|
||||
<img src="/image/111.png" alt="ERP Logo" class="main-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<ZebraDashboard v-if="activeMenu === 'zebra'" />
|
||||
<RakutenDashboard v-else-if="activeMenu === 'rakuten'" />
|
||||
<AmazonDashboard v-else-if="activeMenu === 'amazon'" />
|
||||
<div v-else class="placeholder">
|
||||
<div class="placeholder-card">
|
||||
<div class="placeholder-title">{{ activeMenu.toUpperCase() }} 面板</div>
|
||||
<div class="placeholder-desc">功能开发中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证组件 -->
|
||||
<LoginDialog
|
||||
v-model="showAuthDialog"
|
||||
@login-success="handleLoginSuccess"
|
||||
@show-register="showRegisterDialog" />
|
||||
|
||||
<RegisterDialog
|
||||
v-model="showRegDialog"
|
||||
@register-success="handleRegisterSuccess"
|
||||
@back-to-login="backToLogin" />
|
||||
|
||||
<!-- 设备管理弹框 -->
|
||||
<el-dialog
|
||||
:title="`设备管理 (${deviceQuota.used || 0}/${deviceQuota.limit || 0})`"
|
||||
v-model="showDeviceDialog"
|
||||
width="560px"
|
||||
:close-on-click-modal="false">
|
||||
<div style="margin-bottom: 10px; color:#909399;">当前账号可以授权绑定 {{ deviceQuota.limit }} 台设备</div>
|
||||
<el-table :data="devices" size="small" :loading="deviceLoading" style="width:100%" stripe>
|
||||
<el-table-column label="设备名" min-width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.name || scope.row.deviceId }}</span>
|
||||
<el-tag v-if="scope.row.isCurrent" size="small" type="success" style="margin-left:6px;">本机</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status==='online' ? 'success' : 'info'" size="small">{{ scope.row.status==='online' ? '已登录' : '已登出' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近" min-width="130">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.lastActiveAt || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="scope">
|
||||
<el-button type="text" size="small" style="color:#F56C6C" @click="confirmRemoveDevice(scope.row)">移除设备</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="showDeviceDialog=false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #f5f5f5;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid #e6e6e6;
|
||||
border-top: 5px solid #409EFF;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.erp-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e8eaec;
|
||||
padding: 16px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.platform-icons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.picon { width: 28px; height: 28px; object-fit: contain; }
|
||||
.user-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
.user-avatar img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: contain;
|
||||
background: #ffffff;
|
||||
}
|
||||
.menu-group-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 8px 6px 10px;
|
||||
text-align: left; /* “电商平台”四个字靠左 */
|
||||
}
|
||||
.menu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: #333333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.menu-item.active {
|
||||
background: #ecf5ff !important;
|
||||
color: #409EFF !important;
|
||||
}
|
||||
.menu-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
.menu-text { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.menu-icon { display: inline-flex; width: 18px; height: 18px; border-radius: 4px; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
|
||||
.menu-icon[data-k="rakuten"] { background: #BF0000; }
|
||||
.menu-icon[data-k="amazon"] { background: #FF9900; color: #1A1A1A; }
|
||||
.menu-icon[data-k="zebra"] { background: #34495e; }
|
||||
.menu-icon[data-k="shopee"] { background: #EE4D2D; }
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 导航栏和认证相关样式已移至对应组件 */
|
||||
|
||||
.content-body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard-home {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ffffff;
|
||||
z-index: 100;
|
||||
}
|
||||
.icon-container { display: flex; justify-content: center; }
|
||||
.main-icon {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border-radius: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
.placeholder-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
color: #2c3e50;
|
||||
}
|
||||
.placeholder-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||
.placeholder-desc { font-size: 13px; color: #606266; }
|
||||
</style>
|
||||
38
electron-vue-template/src/renderer/api/amazon.ts
Normal file
38
electron-vue-template/src/renderer/api/amazon.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http';
|
||||
|
||||
export const amazonApi = {
|
||||
// 上传Excel文件解析ASIN列表
|
||||
importAsinFromExcel(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return http.upload<{ code: number, data: { asinList: string[], total: number }, msg: string | null }>('/api/amazon/import/asin', formData);
|
||||
},
|
||||
|
||||
getProductsBatch(asinList: string[], batchId: string) {
|
||||
return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId });
|
||||
},
|
||||
getLatestProducts() {
|
||||
return http.get<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/latest');
|
||||
},
|
||||
getProductsByBatch(batchId: string) {
|
||||
return http.get<{ products: any[] }>(`/api/amazon/products/batch/${batchId}`);
|
||||
},
|
||||
updateProduct(productData: unknown) {
|
||||
return http.post('/api/amazon/products/update', productData);
|
||||
},
|
||||
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');
|
||||
},
|
||||
searchProducts(searchParams: Record<string, unknown>) {
|
||||
return http.get('/api/amazon/products/search', searchParams);
|
||||
},
|
||||
openGenmaiSpirit() {
|
||||
return http.post('/api/genmai/open');
|
||||
},
|
||||
};
|
||||
90
electron-vue-template/src/renderer/api/auth.ts
Normal file
90
electron-vue-template/src/renderer/api/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { http } from './http';
|
||||
|
||||
// 统一响应处理函数 - 适配ERP客户端格式
|
||||
function unwrap<T>(res: any): T {
|
||||
if (res && typeof res.success === 'boolean') {
|
||||
if (!res.success) {
|
||||
const message: string = res.message || res.msg || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
// 兼容标准格式
|
||||
if (res && typeof res.code === 'number') {
|
||||
if (res.code !== 0) {
|
||||
const message: string = res.msg || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
return (res.data as T) ?? ({} as T);
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
|
||||
// 认证相关类型定义
|
||||
interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
permissions: string[];
|
||||
username: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface CheckUsernameResponse {
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
// 用户登录
|
||||
login(params: LoginRequest) {
|
||||
return http
|
||||
.post('/api/login', params)
|
||||
.then(res => unwrap<LoginResponse>(res));
|
||||
},
|
||||
|
||||
// 用户注册
|
||||
register(params: RegisterRequest) {
|
||||
return http
|
||||
.post('/api/register', params)
|
||||
.then(res => unwrap<RegisterResponse>(res));
|
||||
},
|
||||
|
||||
// 检查用户名可用性
|
||||
checkUsername(username: string) {
|
||||
return http
|
||||
.get('/api/check-username', { username })
|
||||
.then(res => {
|
||||
// checkUsername 使用标准格式 {code: 200, data: boolean}
|
||||
if (res && res.code === 200) {
|
||||
return { available: res.data };
|
||||
}
|
||||
throw new Error(res?.msg || '检查用户名失败');
|
||||
});
|
||||
},
|
||||
|
||||
// 验证token有效性
|
||||
verifyToken(token: string) {
|
||||
return http
|
||||
.post('/api/verify', { token })
|
||||
.then(res => unwrap<{ success: boolean }>(res));
|
||||
},
|
||||
|
||||
// 用户登出
|
||||
logout(token: string) {
|
||||
return http.postVoid('/api/logout', { token });
|
||||
},
|
||||
};
|
||||
48
electron-vue-template/src/renderer/api/device.ts
Normal file
48
electron-vue-template/src/renderer/api/device.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { http } from './http'
|
||||
|
||||
// 与老版保持相同的接口路径与参数
|
||||
const base = '/api/device'
|
||||
|
||||
export interface DeviceQuota {
|
||||
limit: number
|
||||
used: number
|
||||
}
|
||||
|
||||
export interface DeviceItem {
|
||||
deviceId: string
|
||||
name?: string
|
||||
status?: 'online' | 'offline'
|
||||
lastActiveAt?: string
|
||||
}
|
||||
|
||||
export const deviceApi = {
|
||||
getQuota(username: string) {
|
||||
return http.get<DeviceQuota | any>(`${base}/quota`, { username }).then((res: any) => {
|
||||
if (res && typeof res.limit !== 'undefined') return res as DeviceQuota
|
||||
if (res && typeof res.code === 'number') return (res.data as DeviceQuota) || { limit: 0, used: 0 }
|
||||
return (res?.data as DeviceQuota) || { limit: 0, used: 0 }
|
||||
})
|
||||
},
|
||||
|
||||
list(username: string) {
|
||||
return http.get<DeviceItem[] | any>(`${base}/list`, { username }).then((res: any) => {
|
||||
if (Array.isArray(res)) return res as DeviceItem[]
|
||||
if (res && typeof res.code === 'number') return (res.data as DeviceItem[]) || []
|
||||
return (res?.data as DeviceItem[]) || []
|
||||
})
|
||||
},
|
||||
|
||||
register(payload: { username: string }) {
|
||||
return http.post(`${base}/register`, payload)
|
||||
},
|
||||
|
||||
remove(payload: { deviceId: string }) {
|
||||
return http.postVoid(`${base}/remove`, payload)
|
||||
},
|
||||
|
||||
heartbeat(payload: { username: string; deviceId: string; version?: string }) {
|
||||
return http.postVoid(`${base}/heartbeat`, payload)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
78
electron-vue-template/src/renderer/api/http.ts
Normal file
78
electron-vue-template/src/renderer/api/http.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// 极简 HTTP 工具:仅封装 GET/POST,默认指向本地 8081
|
||||
export type HttpMethod = 'GET' | 'POST';
|
||||
|
||||
const BASE_URL = 'http://localhost:8081';
|
||||
|
||||
// 将对象转为查询字符串
|
||||
function buildQuery(params?: Record<string, unknown>): string {
|
||||
if (!params) return '';
|
||||
const usp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) return;
|
||||
usp.append(key, String(value));
|
||||
});
|
||||
const queryString = usp.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
// 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理
|
||||
async function request<T>(path: string, options: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
return (await res.text()) as unknown as T;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get<T>(path: string, params?: Record<string, unknown>) {
|
||||
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET' });
|
||||
},
|
||||
post<T>(path: string, body?: unknown) {
|
||||
return request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
|
||||
},
|
||||
// 用于无需读取响应体的 POST(如删除/心跳等),从根源避免读取中断
|
||||
postVoid(path: string, body?: unknown) {
|
||||
return fetch(`${BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(res => {
|
||||
if (!res.ok) return res.text().then(t => Promise.reject(new Error(t || `HTTP ${res.status}`)));
|
||||
return undefined as unknown as void;
|
||||
});
|
||||
},
|
||||
// 文件上传:透传 FormData,不设置 Content-Type 让浏览器自动处理
|
||||
upload<T>(path: string, form: FormData) {
|
||||
const res = fetch(`${BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
});
|
||||
return res.then(async response => {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
38
electron-vue-template/src/renderer/api/rakuten.ts
Normal file
38
electron-vue-template/src/renderer/api/rakuten.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { http } from './http';
|
||||
|
||||
function unwrap<T>(res: any): T {
|
||||
if (res && typeof res.code === 'number') {
|
||||
if (res.code !== 0) {
|
||||
const message: string = res.msg || '请求失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
return (res.data as T) ?? ({} as T);
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
|
||||
export const rakutenApi = {
|
||||
// 上传 Excel 或按店铺名查询
|
||||
getProducts(params: { file?: File; shopName?: string; batchId?: string }) {
|
||||
const formData = new FormData();
|
||||
if (params.file) formData.append('file', params.file);
|
||||
if (params.batchId) formData.append('batchId', params.batchId);
|
||||
if (params.shopName) formData.append('shopName', params.shopName);
|
||||
return http
|
||||
.upload('/api/rakuten/products', formData)
|
||||
.then(res => unwrap<{ products: any[]; total?: number; sessionId?: string }>(res));
|
||||
},
|
||||
search1688(imageUrl: string, sessionId?: string) {
|
||||
const payload: Record<string, unknown> = { imageUrl };
|
||||
if (sessionId) payload.sessionId = sessionId;
|
||||
return http.post('/api/rakuten/search1688', payload).then(res => unwrap<any>(res));
|
||||
},
|
||||
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));
|
||||
},
|
||||
};
|
||||
15
electron-vue-template/src/renderer/api/shopee.ts
Normal file
15
electron-vue-template/src/renderer/api/shopee.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { http } from './http';
|
||||
|
||||
export const shopeeApi = {
|
||||
getAdHosting(params: Record<string, unknown> = {}) {
|
||||
return http.get('/api/shopee/ad-hosting', params);
|
||||
},
|
||||
getReviews(params: Record<string, unknown> = {}) {
|
||||
return http.get('/api/shopee/reviews', params);
|
||||
},
|
||||
exportData(exportParams: Record<string, unknown> = {}) {
|
||||
return http.post('/api/shopee/export', exportParams);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
56
electron-vue-template/src/renderer/api/zebra.ts
Normal file
56
electron-vue-template/src/renderer/api/zebra.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// 斑马订单模型(根据页面所需字段精简定义)
|
||||
export interface ZebraOrder {
|
||||
orderedAt?: string;
|
||||
productImage?: string;
|
||||
productTitle?: string;
|
||||
shopOrderNumber?: string;
|
||||
timeSinceOrder?: string;
|
||||
priceJpy?: number;
|
||||
productQuantity?: number;
|
||||
shippingFeeJpy?: number;
|
||||
serviceFee?: string;
|
||||
productNumber?: string;
|
||||
poNumber?: string;
|
||||
shippingFeeCny?: number;
|
||||
internationalShippingFee?: number;
|
||||
poLogisticsCompany?: string;
|
||||
poTrackingNumber?: string;
|
||||
internationalTrackingNumber?: string;
|
||||
trackInfo?: string;
|
||||
}
|
||||
|
||||
export interface ZebraOrdersResp {
|
||||
orders: ZebraOrder[];
|
||||
total?: number;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
import { http } from './http';
|
||||
|
||||
// 斑马 API:与原 zebra-api.js 对齐的接口封装
|
||||
export const zebraApi = {
|
||||
getOrders(params: Record<string, unknown>) {
|
||||
return http.get<ZebraOrdersResp>('/api/banma/orders', params);
|
||||
},
|
||||
getOrdersByBatch(batchId: string) {
|
||||
return http.get<ZebraOrdersResp>(`/api/banma/orders/batch/${batchId}`);
|
||||
},
|
||||
getLatestOrders() {
|
||||
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
|
||||
},
|
||||
getShops() {
|
||||
return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>('/api/banma/shops');
|
||||
},
|
||||
refreshToken() {
|
||||
return http.post('/api/banma/refresh-token');
|
||||
},
|
||||
exportAndSaveOrders(exportData: unknown) {
|
||||
return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData);
|
||||
},
|
||||
getOrderStats() {
|
||||
return http.get('/api/banma/orders/stats');
|
||||
},
|
||||
searchOrders(searchParams: Record<string, unknown>) {
|
||||
return http.get('/api/banma/orders/search', searchParams);
|
||||
},
|
||||
};
|
||||
1
electron-vue-template/src/renderer/assets/vite.svg
Normal file
1
electron-vue-template/src/renderer/assets/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
electron-vue-template/src/renderer/assets/vue.svg
Normal file
1
electron-vue-template/src/renderer/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1 @@
|
||||
<template></template>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { amazonApi } from '../../api/amazon'
|
||||
|
||||
// 响应式状态
|
||||
const loading = ref(false) // 主加载状态
|
||||
const tableLoading = ref(false) // 表格加载状态
|
||||
const progressPercentage = ref(0) // 进度百分比
|
||||
const localProductData = ref<any[]>([]) // 本地产品数据
|
||||
const singleAsin = ref('') // 单个ASIN输入
|
||||
const currentAsin = ref('') // 当前处理的ASIN
|
||||
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)
|
||||
|
||||
// 计算属性 - 当前页数据
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return localProductData.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 通用消息提示
|
||||
function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'info' = 'info') {
|
||||
alert(`[${type.toUpperCase()}] ${message}`)
|
||||
}
|
||||
|
||||
// Excel文件上传处理 - 主要业务逻辑入口
|
||||
async function processExcelFile(file: File) {
|
||||
try {
|
||||
loading.value = true
|
||||
tableLoading.value = true
|
||||
localProductData.value = []
|
||||
progressPercentage.value = 0
|
||||
|
||||
const response = await amazonApi.importAsinFromExcel(file)
|
||||
const asinList = response.data.asinList
|
||||
|
||||
if (!asinList || asinList.length === 0) {
|
||||
showMessage('文件中未找到有效的ASIN数据', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
showMessage(`成功解析 ${asinList.length} 个ASIN`, 'success')
|
||||
await batchGetProductInfo(asinList)
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '处理文件失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExcelUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
await processExcelFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
|
||||
function onDragLeave() { dragActive.value = false }
|
||||
async function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragActive.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (!file) return
|
||||
const ok = /(\.csv|\.txt|\.xls|\.xlsx)$/i.test(file.name)
|
||||
if (!ok) return showMessage('仅支持 .csv/.txt/.xls/.xlsx 文件', 'warning')
|
||||
await processExcelFile(file)
|
||||
}
|
||||
|
||||
// 批量获取产品信息 - 核心数据处理逻辑
|
||||
async function batchGetProductInfo(asinList: string[]) {
|
||||
try {
|
||||
currentAsin.value = '正在处理...'
|
||||
progressPercentage.value = 0
|
||||
localProductData.value = []
|
||||
|
||||
const batchId = `BATCH_${Date.now()}`
|
||||
const batchSize = 2 // 每批处理2个ASIN
|
||||
const totalBatches = Math.ceil(asinList.length / batchSize)
|
||||
let processedCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
// 分批处理ASIN列表
|
||||
for (let i = 0; i < totalBatches && loading.value; i++) {
|
||||
const start = i * batchSize
|
||||
const end = Math.min(start + batchSize, asinList.length)
|
||||
const batchAsins = asinList.slice(start, end)
|
||||
|
||||
currentAsin.value = `正在处理第${i + 1}/${totalBatches}批 (${batchAsins.join(', ')})`
|
||||
|
||||
try {
|
||||
const result = await amazonApi.getProductsBatch(batchAsins, batchId)
|
||||
|
||||
if (result?.data?.products?.length > 0) {
|
||||
localProductData.value.push(...result.data.products)
|
||||
if (tableLoading.value) tableLoading.value = false // 首次数据到达后隐藏表格加载
|
||||
}
|
||||
|
||||
// 统计失败数量
|
||||
const expectedCount = batchAsins.length
|
||||
const actualCount = result?.data?.products?.length || 0
|
||||
failedCount += Math.max(0, expectedCount - actualCount)
|
||||
|
||||
} catch (error) {
|
||||
failedCount += batchAsins.length
|
||||
console.error(`批次${i + 1}失败:`, error)
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
processedCount += batchAsins.length
|
||||
progressPercentage.value = Math.round((processedCount / asinList.length) * 100)
|
||||
|
||||
// 批次间延迟,避免API频率限制
|
||||
if (i < totalBatches - 1 && loading.value) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1500))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理完成状态更新
|
||||
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')
|
||||
currentAsin.value = '处理失败'
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 单个ASIN查询
|
||||
async function searchSingleAsin() {
|
||||
const asin = singleAsin.value.trim()
|
||||
if (!asin) return
|
||||
|
||||
localProductData.value = []
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const resp = await amazonApi.getProductsBatch([asin], `SINGLE_${Date.now()}`)
|
||||
if (resp?.data?.products?.length > 0) {
|
||||
localProductData.value = resp.data.products
|
||||
showMessage('查询成功', 'success')
|
||||
singleAsin.value = ''
|
||||
} else {
|
||||
showMessage('未找到商品信息', 'warning')
|
||||
}
|
||||
} catch (e: any) {
|
||||
showMessage(e?.message || '查询失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出Excel数据
|
||||
async function exportToExcel() {
|
||||
if (!localProductData.value.length) {
|
||||
showMessage('没有数据可供导出', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
showMessage('正在生成Excel文件,请稍候...', 'info')
|
||||
|
||||
// 数据格式化 - 只保留核心字段
|
||||
const exportData = localProductData.value.map(product => ({
|
||||
asin: product.asin || '',
|
||||
seller_shipper: getSellerShipperText(product),
|
||||
price: product.price || '无货'
|
||||
}))
|
||||
|
||||
await amazonApi.exportToExcel(exportData, {
|
||||
filename: `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
})
|
||||
|
||||
showMessage('Excel文件导出成功', 'success')
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '导出Excel失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取卖家/配送方信息 - 数据处理辅助函数
|
||||
function getSellerShipperText(product: any) {
|
||||
let text = product.seller || '无货'
|
||||
if (product.shipper && product.shipper !== product.seller) {
|
||||
text += (text && text !== '无货' ? ' / ' : '') + product.shipper
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// 停止获取操作
|
||||
function stopFetch() {
|
||||
loading.value = false
|
||||
currentAsin.value = '已停止'
|
||||
showMessage('已停止获取产品数据', 'info')
|
||||
}
|
||||
|
||||
// 打开Genmai Spirit工具
|
||||
async function openGenmaiSpirit() {
|
||||
genmaiLoading.value = true
|
||||
try {
|
||||
await amazonApi.openGenmaiSpirit()
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || '打开跟卖精灵失败', 'error')
|
||||
} finally {
|
||||
genmaiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
function handleSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleCurrentChange(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 使用 Element Plus 的 jumper,不再需要手动跳转函数
|
||||
|
||||
function openAmazonUpload() {
|
||||
amazonUpload.value?.click()
|
||||
}
|
||||
// 组件挂载时获取最新数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await amazonApi.getLatestProducts()
|
||||
localProductData.value = resp.data?.products || []
|
||||
} catch {
|
||||
// 静默处理初始化失败
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="amazon-root">
|
||||
<div class="main-container">
|
||||
|
||||
<!-- 文件导入和操作区域 -->
|
||||
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
|
||||
<div class="import-controls">
|
||||
<!-- 文件上传按钮 -->
|
||||
<el-button type="primary" :disabled="loading" @click="openAmazonUpload">
|
||||
📂 {{ loading ? '处理中...' : '导入ASIN列表' }}
|
||||
</el-button>
|
||||
<input ref="amazonUpload" style="display:none" type="file" accept=".csv,.txt,.xls,.xlsx" @change="handleExcelUpload" :disabled="loading" />
|
||||
|
||||
<!-- 单个ASIN输入 -->
|
||||
<div class="single-input">
|
||||
<input class="text" v-model="singleAsin" placeholder="输入单个ASIN" :disabled="loading" @keyup.enter="searchSingleAsin" />
|
||||
<el-button type="info" :disabled="!singleAsin || loading" @click="searchSingleAsin">查询</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
|
||||
<el-button type="success" :disabled="!localProductData.length || loading" @click="exportToExcel">导出Excel</el-button>
|
||||
<el-button type="warning" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度条显示 -->
|
||||
<div class="progress-section" v-if="loading">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
<div class="current-status" v-if="currentAsin">{{ currentAsin }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="130">ASIN</th>
|
||||
<th>卖家/配送方</th>
|
||||
<th width="120">当前售价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="paginatedData.length === 0">
|
||||
<td colspan="3" class="empty-tip">暂无数据,请导入ASIN列表</td>
|
||||
</tr>
|
||||
<tr v-else v-for="row in paginatedData" :key="row.asin">
|
||||
<td>{{ row.asin }}</td>
|
||||
<td>
|
||||
<div class="seller-info">
|
||||
<span class="seller">{{ row.seller || '无货' }}</span>
|
||||
<span v-if="row.shipper && row.shipper !== row.seller" class="shipper">/ {{ row.shipper }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="price">{{ row.price || '无货' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩 -->
|
||||
<div v-if="tableLoading" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="localProductData.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.amazon-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
|
||||
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
|
||||
.import-section { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.single-input { display: flex; align-items: center; gap: 8px; }
|
||||
.text { width: 180px; height: 32px; padding: 0 10px; border: 1px solid #dcdfe6; border-radius: 4px; font-size: 14px; outline: none; transition: border-color 0.2s ease; }
|
||||
.text:focus { border-color: #409EFF; }
|
||||
.text:disabled { background: #f5f7fa; color: #c0c4cc; }
|
||||
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.progress-section { margin: 15px 0 10px 0; }
|
||||
.progress-box { padding: 8px 0; }
|
||||
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #ebeef5; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
|
||||
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
|
||||
.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; }
|
||||
.table-wrapper { height: 100%; overflow: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; }
|
||||
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||||
.table tbody tr:hover { background: #f9f9f9; }
|
||||
.seller-info { display: flex; align-items: center; gap: 4px; }
|
||||
.seller { color: #303133; font-weight: 500; }
|
||||
.shipper { color: #909399; font-size: 12px; }
|
||||
.price { color: #e6a23c; font-weight: 600; }
|
||||
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
|
||||
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
.pagination-fixed { flex-shrink: 0; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
|
||||
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
|
||||
.import-section[draggable], .import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'AmazonDashboard',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
import { authApi } from '../../api/auth'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'loginSuccess', data: { token: string; user: any }): void
|
||||
(e: 'showRegister'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const authForm = ref({ username: '', password: '' })
|
||||
const authLoading = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
async function handleAuth() {
|
||||
if (!authForm.value.username || !authForm.value.password) return
|
||||
|
||||
authLoading.value = true
|
||||
try {
|
||||
const data = await authApi.login(authForm.value)
|
||||
localStorage.setItem('token', data.token)
|
||||
emit('loginSuccess', {
|
||||
token: data.token,
|
||||
user: {
|
||||
username: data.username,
|
||||
permissions: data.permissions
|
||||
}
|
||||
})
|
||||
ElMessage.success('登录成功')
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
ElMessage.error((err as Error).message)
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAuth() {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
authForm.value = { username: '', password: '' }
|
||||
}
|
||||
|
||||
function showRegister() {
|
||||
emit('showRegister')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
title="用户登录"
|
||||
v-model="visible"
|
||||
:close-on-click-modal="false"
|
||||
width="400px"
|
||||
center>
|
||||
<div style="text-align: center; padding: 20px 0;">
|
||||
<div style="margin-bottom: 30px; color: #666;">
|
||||
<el-icon size="48" color="#409EFF"><User /></el-icon>
|
||||
<p style="margin-top: 15px; font-size: 16px;">请登录以使用系统功能</p>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="authForm.username"
|
||||
placeholder="请输入用户名"
|
||||
prefix-icon="User"
|
||||
size="large"
|
||||
style="margin-bottom: 15px;"
|
||||
:disabled="authLoading"
|
||||
@keyup.enter="handleAuth">
|
||||
</el-input>
|
||||
|
||||
<el-input
|
||||
v-model="authForm.password"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
size="large"
|
||||
style="margin-bottom: 20px;"
|
||||
:disabled="authLoading"
|
||||
@keyup.enter="handleAuth">
|
||||
</el-input>
|
||||
|
||||
<div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="authLoading"
|
||||
:disabled="!authForm.username || !authForm.password || authLoading"
|
||||
@click="handleAuth"
|
||||
style="width: 120px; margin-right: 10px;">
|
||||
登录
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
:disabled="authLoading"
|
||||
@click="cancelAuth"
|
||||
style="width: 120px;">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
<el-button type="text" @click="showRegister" :disabled="authLoading">
|
||||
还没有账号?点击注册
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User } from '@element-plus/icons-vue'
|
||||
import { authApi } from '../../api/auth'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'registerSuccess'): void
|
||||
(e: 'backToLogin'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const registerForm = ref({ username: '', password: '', confirmPassword: '' })
|
||||
const registerLoading = ref(false)
|
||||
const usernameCheckResult = ref<boolean | null>(null)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const canRegister = computed(() => {
|
||||
const { username, password, confirmPassword } = registerForm.value
|
||||
return username &&
|
||||
password.length >= 6 &&
|
||||
password === confirmPassword &&
|
||||
usernameCheckResult.value === true
|
||||
})
|
||||
|
||||
async function checkUsernameAvailability() {
|
||||
if (!registerForm.value.username) {
|
||||
usernameCheckResult.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await authApi.checkUsername(registerForm.value.username)
|
||||
usernameCheckResult.value = data.available
|
||||
} catch {
|
||||
usernameCheckResult.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
if (!canRegister.value) return
|
||||
|
||||
registerLoading.value = true
|
||||
try {
|
||||
const result = await authApi.register({
|
||||
username: registerForm.value.username,
|
||||
password: registerForm.value.password
|
||||
})
|
||||
ElMessage.success(result.message || '注册成功,请登录')
|
||||
emit('registerSuccess')
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
ElMessage.error((err as Error).message)
|
||||
} finally {
|
||||
registerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRegister() {
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
registerForm.value = { username: '', password: '', confirmPassword: '' }
|
||||
usernameCheckResult.value = null
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
emit('backToLogin')
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
title="账号注册"
|
||||
v-model="visible"
|
||||
:close-on-click-modal="false"
|
||||
width="450px"
|
||||
center>
|
||||
<div style="text-align: center; padding: 20px 0;">
|
||||
<div style="margin-bottom: 20px; color: #666;">
|
||||
<el-icon size="48" color="#67C23A"><User /></el-icon>
|
||||
<p style="margin-top: 15px; font-size: 16px;">创建新账号</p>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
prefix-icon="User"
|
||||
size="large"
|
||||
style="margin-bottom: 15px;"
|
||||
:disabled="registerLoading"
|
||||
@blur="checkUsernameAvailability">
|
||||
</el-input>
|
||||
|
||||
<div v-if="usernameCheckResult !== null" style="margin-bottom: 15px; text-align: left;">
|
||||
<span v-if="usernameCheckResult" style="color: #67C23A; font-size: 12px;">
|
||||
✓ 用户名可用
|
||||
</span>
|
||||
<span v-else style="color: #F56C6C; font-size: 12px;">
|
||||
✗ 用户名已存在
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
type="password"
|
||||
size="large"
|
||||
style="margin-bottom: 15px;"
|
||||
:disabled="registerLoading">
|
||||
</el-input>
|
||||
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
type="password"
|
||||
size="large"
|
||||
style="margin-bottom: 20px;"
|
||||
:disabled="registerLoading">
|
||||
</el-input>
|
||||
|
||||
<div>
|
||||
<el-button
|
||||
type="success"
|
||||
size="large"
|
||||
:loading="registerLoading"
|
||||
:disabled="!canRegister || registerLoading"
|
||||
@click="handleRegister"
|
||||
style="width: 120px; margin-right: 10px;">
|
||||
注册
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
:disabled="registerLoading"
|
||||
@click="cancelRegister"
|
||||
style="width: 120px;">
|
||||
取消
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; text-align: center;">
|
||||
<el-button type="text" @click="backToLogin" :disabled="registerLoading">
|
||||
已有账号?返回登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, ArrowRight, Refresh, Monitor, Setting, User } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
activeMenu: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'go-back'): void
|
||||
(e: 'go-forward'): void
|
||||
(e: 'reload'): void
|
||||
(e: 'user-click'): void
|
||||
(e: 'open-device'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="top-navbar">
|
||||
<div class="navbar-left">
|
||||
<div class="nav-controls">
|
||||
<button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</button>
|
||||
<button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward">
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center">
|
||||
<div class="breadcrumbs">
|
||||
<span>首页</span>
|
||||
<span class="separator">></span>
|
||||
<span>{{ activeMenu }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</button>
|
||||
<button class="nav-btn-round" title="设备管理" @click="$emit('open-device')">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</button>
|
||||
<button class="nav-btn-round" title="设置">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</button>
|
||||
<button class="nav-btn-round" title="用户" @click="$emit('user-click')">
|
||||
<el-icon><User /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-navbar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.navbar-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
background: #f5f7fa;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.nav-btn:focus,
|
||||
.nav-btn:active {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
background: #f5f5f5;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.nav-btn:not(:last-child) {
|
||||
border-right: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.nav-btn-round {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-btn-round:hover {
|
||||
background: #f5f7fa;
|
||||
color: #409EFF;
|
||||
border-color: #c6e2ff;
|
||||
}
|
||||
|
||||
.nav-btn-round:focus,
|
||||
.nav-btn-round:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 8px;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,632 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted} from 'vue'
|
||||
import {rakutenApi} from '../../api/rakuten'
|
||||
|
||||
// UI 与加载状态
|
||||
const loading = ref(false)
|
||||
const tableLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
|
||||
|
||||
// 查询与上传
|
||||
const singleShopName = ref('')
|
||||
const currentBatchId = ref('')
|
||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||
const dragActive = ref(false)
|
||||
|
||||
// 数据与分页
|
||||
const allProducts = ref<any[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return allProducts.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 进度(完成后仍保持显示)
|
||||
const progressStarted = ref(false)
|
||||
const progressPercentage = ref(0)
|
||||
const totalProducts = ref(0)
|
||||
const processedProducts = ref(0)
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleCurrentChange(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function openRakutenUpload() {
|
||||
uploadInputRef.value?.click()
|
||||
}
|
||||
|
||||
function parseSkuPrices(product: any) {
|
||||
if (!product.skuPrice) return []
|
||||
try {
|
||||
let skuStr = product.skuPrice
|
||||
if (typeof skuStr === 'string') {
|
||||
skuStr = skuStr.replace(/(\d+(?:\.\d+)?):"/g, '"$1":"')
|
||||
skuStr = JSON.parse(skuStr)
|
||||
}
|
||||
return Object.keys(skuStr).map(p => parseFloat(p)).filter(n => !isNaN(n)).sort((a, b) => a - b)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatest() {
|
||||
const resp = await rakutenApi.getLatestProducts()
|
||||
allProducts.value = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
|
||||
|
||||
}
|
||||
|
||||
async function searchProductInternal(product: any) {
|
||||
if (!product || !product.imgUrl) return
|
||||
if (product.mapRecognitionLink && String(product.mapRecognitionLink).trim() !== '') return
|
||||
const res = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
|
||||
const data = res
|
||||
Object.assign(product, {
|
||||
mapRecognitionLink: data.mapRecognitionLink,
|
||||
freight: data.freight,
|
||||
median: data.median,
|
||||
weight: data.weight,
|
||||
skuPrice: data.skuPrice,
|
||||
skuPrices: parseSkuPrices(data),
|
||||
image1688Url: data.mapRecognitionLink,
|
||||
detailUrl1688: data.mapRecognitionLink,
|
||||
})
|
||||
}
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const ok = /\.xlsx?$/.test(file.name)
|
||||
if (!ok) alert('仅支持 .xlsx/.xls 文件')
|
||||
return ok
|
||||
}
|
||||
|
||||
async function processFile(file: File) {
|
||||
if (!beforeUpload(file)) return
|
||||
progressStarted.value = true
|
||||
progressPercentage.value = 0
|
||||
totalProducts.value = 0
|
||||
processedProducts.value = 0
|
||||
loading.value = true
|
||||
tableLoading.value = true
|
||||
currentBatchId.value = `RAKUTEN_${Date.now()}`
|
||||
try {
|
||||
const resp = await rakutenApi.getProducts({file, batchId: currentBatchId.value})
|
||||
const products = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
|
||||
allProducts.value = products
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品`
|
||||
|
||||
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (needSearch.length > 0) {
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品,正在自动获取1688数据...`
|
||||
await startBatch1688Search(needSearch)
|
||||
} else {
|
||||
statusType.value = 'success'
|
||||
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品,所有数据已完整!`
|
||||
}
|
||||
} catch (e: any) {
|
||||
statusMessage.value = e?.message || '上传失败'
|
||||
statusType.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExcelUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files && input.files[0]
|
||||
if (!file) return
|
||||
await processFile(file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
|
||||
function onDragLeave() { dragActive.value = false }
|
||||
async function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragActive.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (!file) return
|
||||
await processFile(file)
|
||||
}
|
||||
|
||||
async function searchSingleShop() {
|
||||
const shop = singleShopName.value.trim()
|
||||
if (!shop) return
|
||||
|
||||
// 重置进度与状态
|
||||
progressStarted.value = true
|
||||
progressPercentage.value = 0
|
||||
totalProducts.value = 0
|
||||
processedProducts.value = 0
|
||||
loading.value = true
|
||||
tableLoading.value = true
|
||||
currentBatchId.value = `RAKUTEN_${Date.now()}`
|
||||
try {
|
||||
const resp = await rakutenApi.getProducts({shopName: shop, batchId: currentBatchId.value})
|
||||
allProducts.value = (resp.products || []).filter((p: any) => p.originalShopName === shop).map(p => ({ ...p, skuPrices: parseSkuPrices(p) }))
|
||||
statusMessage.value = `店铺 ${shop} 共 ${allProducts.value.length} 条`
|
||||
singleShopName.value = ''
|
||||
|
||||
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (needSearch.length > 0) {
|
||||
await startBatch1688Search(needSearch)
|
||||
} else if (allProducts.value.length > 0) {
|
||||
statusType.value = 'success'
|
||||
statusMessage.value = `店铺 ${shop} 的数据已加载完成,所有1688链接都已存在!`
|
||||
progressPercentage.value = 100
|
||||
}
|
||||
} catch (e: any) {
|
||||
statusMessage.value = e?.message || '查询失败'
|
||||
statusType.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopTask() {
|
||||
loading.value = false
|
||||
tableLoading.value = false
|
||||
statusType.value = 'warning'
|
||||
statusMessage.value = '任务已停止'
|
||||
// 保留进度条和当前进度
|
||||
allProducts.value = allProducts.value.map(p => ({...p, searching1688: false}))
|
||||
}
|
||||
|
||||
async function startBatch1688Search(products: any[]) {
|
||||
const items = (products || []).filter(p => p && p.imgUrl && !p.mapRecognitionLink)
|
||||
if (items.length === 0) {
|
||||
progressPercentage.value = 100
|
||||
statusType.value = 'success'
|
||||
statusMessage.value = '所有商品都已获取1688数据!'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
totalProducts.value = items.length
|
||||
processedProducts.value = 0
|
||||
progressStarted.value = true
|
||||
progressPercentage.value = 0
|
||||
statusType.value = 'info'
|
||||
statusMessage.value = `正在获取1688数据,共 ${totalProducts.value} 个商品...`
|
||||
await serialSearch1688(items)
|
||||
|
||||
if (processedProducts.value >= totalProducts.value) {
|
||||
progressPercentage.value = 100
|
||||
statusType.value = 'success'
|
||||
const successCount = allProducts.value.filter(p => p && p.mapRecognitionLink && String(p.mapRecognitionLink).trim() !== '').length
|
||||
statusMessage.value = `成功获取 ${successCount} 个`
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function serialSearch1688(products: any[]) {
|
||||
for (let i = 0; i < products.length && loading.value; i++) {
|
||||
const product = products[i]
|
||||
product.searching1688 = true
|
||||
await nextTickSafe()
|
||||
await searchProductInternal(product)
|
||||
product.searching1688 = false
|
||||
processedProducts.value++
|
||||
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
|
||||
if (i < products.length - 1 && loading.value) {
|
||||
await delay(500 + Math.random() * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function nextTickSafe() {
|
||||
// 不额外引入 nextTick,使用微任务刷新即可,保持体积精简
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function exportToExcel() {
|
||||
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 resp = await rakutenApi.exportAndSave(payload)
|
||||
alert(`Excel文件已保存到: ${resp.filePath}`)
|
||||
} catch (e: any) {
|
||||
alert(e?.message || '导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(loadLatest)
|
||||
</script>
|
||||
<template>
|
||||
<div class="rakuten-root">
|
||||
<div class="main-container">
|
||||
|
||||
<!-- 文件导入和操作区域 -->
|
||||
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
|
||||
<div class="import-controls">
|
||||
<!-- 文件上传按钮 -->
|
||||
<el-button type="primary" :disabled="loading" @click="openRakutenUpload">
|
||||
📂 {{ loading ? '处理中...' : '导入店铺名列表' }}
|
||||
</el-button>
|
||||
<input ref="uploadInputRef" style="display:none" type="file" accept=".xlsx,.xls" @change="handleExcelUpload"
|
||||
:disabled="loading"/>
|
||||
|
||||
<!-- 单个店铺名输入 -->
|
||||
<div class="single-input">
|
||||
<el-input v-model="singleShopName" placeholder="输入单个店铺名" :disabled="loading"
|
||||
@keyup.enter="searchSingleShop" style="width: 140px"/>
|
||||
<el-button type="info" :disabled="!singleShopName || loading" @click="searchSingleShop">查询</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="danger" :disabled="!loading" @click="stopTask">停止获取</el-button>
|
||||
<el-button type="success" :disabled="!allProducts.length || loading" @click="exportToExcel">导出Excel
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条显示 -->
|
||||
<div class="progress-section" v-if="progressStarted">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
<div class="current-status" v-if="statusMessage">{{ statusMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>店铺名</th>
|
||||
<th>商品链接</th>
|
||||
<th>商品图片</th>
|
||||
<th>排名</th>
|
||||
<th>商品标题</th>
|
||||
<th>价格</th>
|
||||
<th>1688识图链接</th>
|
||||
<th>1688运费</th>
|
||||
<th>1688中位价</th>
|
||||
<th>1688最低价</th>
|
||||
<th>1688中间价</th>
|
||||
<th>1688最高价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="paginatedData.length === 0">
|
||||
<td colspan="12" class="empty-tip">暂无数据,请导入店铺名列表</td>
|
||||
</tr>
|
||||
<tr v-else v-for="row in paginatedData" :key="row.productUrl + (row.productTitle || '')">
|
||||
<td class="truncate shop-col" :title="row.originalShopName">{{ row.originalShopName }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.productUrl" :value="row.productUrl" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="image-container" v-if="row.imgUrl">
|
||||
<img :src="row.imgUrl" class="thumb" alt="thumb"/>
|
||||
</div>
|
||||
<span v-else>无图片</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.ranking">{{ row.ranking }}</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td class="truncate" :title="row.productTitle">{{ row.productTitle || '--' }}</td>
|
||||
<td>{{ row.price ? row.price + '円' : '--' }}</td>
|
||||
<td class="truncate url-col">
|
||||
<el-input v-if="row.mapRecognitionLink" :value="row.mapRecognitionLink" readonly @click="$event.target.select()" size="small"/>
|
||||
<span v-else-if="row.searching1688">搜索中...</span>
|
||||
<span v-else>--</span>
|
||||
</td>
|
||||
<td>{{ row.freight ?? '--' }}</td>
|
||||
<td>{{ row.median ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[0] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[Math.floor(row.skuPrices.length / 2)] ?? '--' }}</td>
|
||||
<td>{{ row.skuPrices?.[row.skuPrices.length - 1] ?? '--' }}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩 -->
|
||||
<div v-if="tableLoading" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-fixed" >
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="allProducts.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rakuten-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.import-section {
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.import-controls {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.single-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin: 15px 0 10px 0;
|
||||
}
|
||||
|
||||
.progress-box {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-right: 50px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #ebeef5;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #409EFF, #66b1ff);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-size: 13px;
|
||||
color: #409EFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.current-status {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.empty-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
font-weight: 600;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 2px solid #ebeef5;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
max-width: 260px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.shop-col { max-width: 160px; }
|
||||
.url-col { max-width: 220px; }
|
||||
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
|
||||
.import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
|
||||
|
||||
.image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto;
|
||||
background: #f8f9fa;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.table-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
font-size: 24px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-fixed {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid #ebeef5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'RakutenDashboard',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { zebraApi, type ZebraOrder } from '../../api/zebra'
|
||||
|
||||
type Shop = { id: string; shopName: string }
|
||||
|
||||
const shopList = ref<Shop[]>([])
|
||||
const selectedShops = ref<string[]>([])
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
const loading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const progressPercentage = ref(0)
|
||||
const showProgress = ref(false)
|
||||
const allOrderData = ref<ZebraOrder[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(15)
|
||||
|
||||
// 批量获取状态
|
||||
const fetchCurrentPage = ref(1)
|
||||
const fetchTotalPages = ref(0)
|
||||
const fetchTotalItems = ref(0)
|
||||
const isFetching = ref(false)
|
||||
|
||||
const paginatedData = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return allOrderData.value.slice(start, end)
|
||||
})
|
||||
|
||||
function formatJpy(v?: number) {
|
||||
const n = Number(v || 0)
|
||||
return `¥${n.toLocaleString('ja-JP')}`
|
||||
}
|
||||
|
||||
function formatCny(v?: number) {
|
||||
const n = Number(v || 0)
|
||||
return `¥${n.toLocaleString('zh-CN')}`
|
||||
}
|
||||
|
||||
async function loadShops() {
|
||||
try {
|
||||
const resp = await zebraApi.getShops()
|
||||
const list = (resp as any)?.data?.data?.list ?? (resp as any)?.list ?? []
|
||||
shopList.value = list
|
||||
} catch (e) {
|
||||
console.error('获取店铺列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function handleCurrentChange(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (isFetching.value) return
|
||||
|
||||
loading.value = true
|
||||
isFetching.value = true
|
||||
showProgress.value = true
|
||||
progressPercentage.value = 0
|
||||
allOrderData.value = []
|
||||
fetchCurrentPage.value = 1
|
||||
fetchTotalItems.value = 0
|
||||
|
||||
const [startDate = '', endDate = ''] = dateRange.value || []
|
||||
await fetchPageData(startDate, endDate)
|
||||
}
|
||||
|
||||
async function fetchPageData(startDate: string, endDate: string) {
|
||||
if (!isFetching.value) return
|
||||
|
||||
try {
|
||||
const data = await zebraApi.getOrders({
|
||||
startDate,
|
||||
endDate,
|
||||
page: fetchCurrentPage.value,
|
||||
pageSize: 50,
|
||||
shopIds: selectedShops.value.join(',')
|
||||
})
|
||||
|
||||
const orders = data.orders || []
|
||||
allOrderData.value = [...allOrderData.value, ...orders]
|
||||
|
||||
fetchTotalPages.value = data.totalPages || 0
|
||||
fetchTotalItems.value = data.total || 0
|
||||
|
||||
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
|
||||
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
|
||||
fetchCurrentPage.value++
|
||||
setTimeout(() => fetchPageData(startDate, endDate), 200)
|
||||
} else {
|
||||
progressPercentage.value = 100
|
||||
finishFetching()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取订单数据失败:', e)
|
||||
finishFetching()
|
||||
}
|
||||
}
|
||||
|
||||
function finishFetching() {
|
||||
isFetching.value = false
|
||||
loading.value = false
|
||||
// 确保进度条完全填满
|
||||
progressPercentage.value = 100
|
||||
currentPage.value = 1
|
||||
// 进度条保留显示,不自动隐藏
|
||||
}
|
||||
|
||||
function stopFetch() {
|
||||
isFetching.value = false
|
||||
loading.value = false
|
||||
// 进度条保留显示,不自动隐藏
|
||||
}
|
||||
|
||||
async function exportToExcel() {
|
||||
if (!allOrderData.value.length) return
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const result = await zebraApi.exportAndSaveOrders({ orders: allOrderData.value })
|
||||
alert(`Excel文件已保存到: ${result.filePath}`)
|
||||
} catch (e) {
|
||||
alert('导出Excel失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadShops()
|
||||
try {
|
||||
const latest = await zebraApi.getLatestOrders()
|
||||
allOrderData.value = latest?.orders || []
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="zebra-root">
|
||||
<div class="main-container">
|
||||
|
||||
<!-- 筛选和操作区域 -->
|
||||
<div class="import-section">
|
||||
<div class="import-controls">
|
||||
<!-- 店铺选择 -->
|
||||
<el-select v-model="selectedShops" multiple placeholder="选择店铺" style="width: 260px;" :disabled="loading">
|
||||
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id"></el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 200px;"
|
||||
:disabled="loading"
|
||||
/>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" :disabled="loading" @click="fetchData">
|
||||
📂 {{ loading ? '处理中...' : '获取订单数据' }}
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
|
||||
<el-button type="success" :disabled="exportLoading || !allOrderData.length" @click="exportToExcel">导出Excel</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条显示 -->
|
||||
<div class="progress-section" v-if="showProgress">
|
||||
<div class="progress-box">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-text">{{ progressPercentage }}%</div>
|
||||
</div>
|
||||
<div class="current-status" v-if="fetchTotalItems > 0">
|
||||
{{ progressPercentage >= 100 ? '完成' : `获取中... (${allOrderData.length}/${fetchTotalItems})` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div class="table-container">
|
||||
<!-- 数据表格(无数据时也显示表头) -->
|
||||
<div class="table-section">
|
||||
<div class="table-wrapper">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>下单时间</th>
|
||||
<th>商品图片</th>
|
||||
<th>商品名称</th>
|
||||
<th>乐天订单号</th>
|
||||
<th>下单距今</th>
|
||||
<th>订单金额/日元</th>
|
||||
<th>数量</th>
|
||||
<th>税费/日元</th>
|
||||
<th>回款抽点rmb</th>
|
||||
<th>商品番号</th>
|
||||
<th>1688订单号</th>
|
||||
<th>采购金额/rmb</th>
|
||||
<th>国际运费/rmb</th>
|
||||
<th>国内物流</th>
|
||||
<th>国内单号</th>
|
||||
<th>日本单号</th>
|
||||
<th>地址状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="paginatedData.length === 0">
|
||||
<td colspan="16" class="empty-tip">暂无数据,请选择日期范围获取订单</td>
|
||||
</tr>
|
||||
<tr v-else v-for="row in paginatedData" :key="row.shopOrderNumber + (row.productNumber || '')">
|
||||
<td>{{ row.orderedAt || '-' }}</td>
|
||||
<td>
|
||||
<div class="image-container" v-if="row.productImage">
|
||||
<img :src="row.productImage" class="thumb" alt="thumb" />
|
||||
</div>
|
||||
<span v-else>无图片</span>
|
||||
</td>
|
||||
<td class="truncate" :title="row.productTitle">{{ row.productTitle }}</td>
|
||||
<td class="truncate" :title="row.shopOrderNumber">{{ row.shopOrderNumber }}</td>
|
||||
<td>{{ row.timeSinceOrder || '-' }}</td>
|
||||
<td><span class="price-tag">{{ formatJpy(row.priceJpy) }}</span></td>
|
||||
<td>{{ row.productQuantity || 0 }}</td>
|
||||
<td><span class="fee-tag">{{ formatJpy(row.shippingFeeJpy) }}</span></td>
|
||||
<td>{{ row.serviceFee || '-' }}</td>
|
||||
<td class="truncate" :title="row.productNumber">{{ row.productNumber }}</td>
|
||||
<td class="truncate" :title="row.poNumber">{{ row.poNumber }}</td>
|
||||
<td><span class="fee-tag">{{ formatCny(row.shippingFeeCny) }}</span></td>
|
||||
<td>{{ row.internationalShippingFee || '-' }}</td>
|
||||
<td>{{ row.poLogisticsCompany || '-' }}</td>
|
||||
<td class="truncate" :title="row.poTrackingNumber">{{ row.poTrackingNumber }}</td>
|
||||
<td class="truncate" :title="row.internationalTrackingNumber">{{ row.internationalTrackingNumber }}</td>
|
||||
<td>
|
||||
<span v-if="row.trackInfo" class="tag">{{ row.trackInfo }}</span>
|
||||
<span v-else>暂无</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 表格加载遮罩 -->
|
||||
<div v-if="loading && !allOrderData.length" class="table-loading">
|
||||
<div class="spinner">⟳</div>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-fixed">
|
||||
<el-pagination
|
||||
background
|
||||
:current-page="currentPage"
|
||||
:page-sizes="[15,30,50,100]"
|
||||
:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="allOrderData.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'ZebraDashboard',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
|
||||
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
|
||||
.import-section { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.progress-section { margin: 15px 0 10px 0; }
|
||||
.progress-box { padding: 8px 0; }
|
||||
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
|
||||
.progress-bar { flex: 1; height: 6px; background: #ebeef5; border-radius: 3px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
|
||||
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
|
||||
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
|
||||
.empty-section { flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; }
|
||||
.empty-container { text-align: center; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
|
||||
.empty-text { font-size: 14px; color: #909399; }
|
||||
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
|
||||
.table-wrapper { height: 100%; overflow: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; }
|
||||
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
||||
.table tbody tr:hover { background: #f9f9f9; }
|
||||
.truncate { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.image-container { display: flex; justify-content: center; align-items: center; width: 24px; height: 20px; margin: 0 auto; background: #f8f9fa; border-radius: 2px; }
|
||||
.thumb { width: 16px; height: 16px; object-fit: contain; border-radius: 2px; }
|
||||
.price-tag { color: #e6a23c; font-weight: bold; }
|
||||
.fee-tag { color: #909399; font-weight: 500; }
|
||||
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
|
||||
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
.pagination-fixed { flex-shrink: 0; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
|
||||
.tag { display: inline-block; padding: 2px 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
|
||||
</style>
|
||||
|
||||
|
||||
13
electron-vue-template/src/renderer/index.html
Normal file
13
electron-vue-template/src/renderer/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Vite + Vue template</title>
|
||||
<link rel="icon" href="/icon/icon.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
electron-vue-template/src/renderer/main.ts
Normal file
9
electron-vue-template/src/renderer/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css';
|
||||
import 'element-plus/dist/index.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
89
electron-vue-template/src/renderer/style.css
Normal file
89
electron-vue-template/src/renderer/style.css
Normal file
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
19
electron-vue-template/src/renderer/tsconfig.json
Normal file
19
electron-vue-template/src/renderer/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.d.ts", "./**/*.tsx", "./**/*.vue"],
|
||||
}
|
||||
|
||||
12
electron-vue-template/src/renderer/typings/electron.d.ts
vendored
Normal file
12
electron-vue-template/src/renderer/typings/electron.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Should match main/preload.ts for typescript support in renderer
|
||||
*/
|
||||
export default interface ElectronApi {
|
||||
sendMessage: (message: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronApi,
|
||||
}
|
||||
}
|
||||
6
electron-vue-template/src/renderer/typings/shims-vue.d.ts
vendored
Normal file
6
electron-vue-template/src/renderer/typings/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user