Initial commit

This commit is contained in:
2025-09-22 11:51:16 +08:00
commit c32381f8ed
1191 changed files with 130140 additions and 0 deletions

View File

@@ -0,0 +1 @@
<template></template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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