Files
erp_sb/electron-vue-template/src/renderer/components/zebra/ZebraDashboard.vue
zhangzijienbplus 281ae6a846 refactor(api):重构API服务接口与实现
- 移除多余的接口定义文件,简化依赖关系- 更新控制器和服务实现类的注入方式-优化请求参数处理逻辑
- 统一响应数据结构格式- 调整方法签名以提高一致性
- 删除冗余注释和无用代码- 修改系统API调用路径引用位置
- 简化认证服务实现并移除不必要的抽象层
- 优化Excel文件解析相关功能
- 清理无用的工具类和配置项
- 调整错误上报机制的依赖注入方式
- 更新跟卖精灵服务的实现细节- 优化HTTP请求工具函数结构
- 移除废弃的缓存管理服务接口定义
- 调整设备配额检查逻辑复用性
- 优化订单服务的数据返回格式
- 更新产品服务中的数据处理方式
- 重构客户端账户控制器中的设备限制检查逻辑
2025-10-21 10:15:33 +08:00

666 lines
28 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, defineAsyncComponent, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态
const props = defineProps<{
isVip: boolean
}>()
type Shop = { id: string; shopName: string }
const accounts = ref<BanmaAccount[]>([])
const accountId = ref<number>()
// 收起功能移除
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 currentBatchId = ref('')
// 批量获取状态
const fetchCurrentPage = ref(1)
const fetchTotalPages = ref(0)
const fetchTotalItems = ref(0)
const isFetching = ref(false)
let abortController: AbortController | null = null
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
function selectAccount(id: number) {
accountId.value = id
loadShops()
}
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({ accountId: Number(accountId.value) || undefined })
const list = (resp as any)?.data?.data?.list ?? (resp as any)?.list ?? []
shopList.value = list
} catch (e) {
console.error('获取店铺列表失败:', e)
}
}
async function loadAccounts() {
try {
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0]
accountId.value = def?.id
await loadShops()
} catch (e) {
accounts.value = []
}
}
function handleSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function handleCurrentChange(page: number) {
currentPage.value = page
}
async function fetchData() {
if (isFetching.value) return
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查
if (!props.isVip) {
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
showTrialExpiredDialog.value = true
return
}
abortController = new AbortController()
loading.value = true
isFetching.value = true
showProgress.value = true
progressPercentage.value = 0
allOrderData.value = []
fetchCurrentPage.value = 1
fetchTotalItems.value = 0
currentBatchId.value = `ZEBRA_${Date.now()}`
const [startDate = '', endDate = ''] = dateRange.value || []
await fetchPageData(startDate, endDate)
}
async function fetchPageData(startDate: string, endDate: string) {
if (!isFetching.value) return
try {
const response = await zebraApi.getOrders({
accountId: Number(accountId.value) || undefined,
startDate,
endDate,
page: fetchCurrentPage.value,
pageSize: 50,
shopIds: selectedShops.value.join(','),
batchId: currentBatchId.value
}, abortController?.signal)
const data = (response as any)?.data || response
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: any) {
if (e.name !== 'AbortError') {
console.error('获取订单数据失败:', e)
}
finishFetching()
}
}
function finishFetching() {
isFetching.value = false
loading.value = false
abortController = null
// 确保进度条完全填满
progressPercentage.value = 100
currentPage.value = 1
// 进度条保留显示,不自动隐藏
}
function stopFetch() {
abortController?.abort()
abortController = null
isFetching.value = false
loading.value = false
// 进度条保留显示,不自动隐藏
}
function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
ElMessage({ message, type })
}
async function exportToExcel() {
if (!allOrderData.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
exportLoading.value = true
try {
const ExcelJS = (await import('exceljs')).default
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet('斑马订单数据')
worksheet.columns = [
{ header: '下单时间', key: 'orderedAt', width: 15 },
{ header: '商品图片', key: 'image', width: 15 },
{ header: '商品名称', key: 'productTitle', width: 25 },
{ header: '乐天订单号', key: 'shopOrderNumber', width: 20 },
{ header: '下单距今', key: 'timeSinceOrder', width: 12 },
{ header: '订单金额/日元', key: 'priceJpy', width: 15 },
{ header: '数量', key: 'productQuantity', width: 8 },
{ header: '税费/日元', key: 'shippingFeeJpy', width: 12 },
{ header: '回款抽点rmb', key: 'serviceFee', width: 15 },
{ header: '商品番号', key: 'productNumber', width: 15 },
{ header: '1688订单号', key: 'poNumber', width: 15 },
{ header: '采购金额/rmb', key: 'shippingFeeCny', width: 15 },
{ header: '国际运费/rmb', key: 'internationalShippingFee', width: 15 },
{ header: '国内物流', key: 'poLogisticsCompany', width: 12 },
{ header: '国内单号', key: 'poTrackingNumber', width: 15 },
{ header: '日本单号', key: 'internationalTrackingNumber', width: 15 },
{ header: '地址状态', key: 'trackInfo', width: 12 }
]
const imageUrls = allOrderData.value.map(order => order.productImage || '')
const imageBase64s = await batchConvertImages(imageUrls, 80)
for (let i = 0; i < allOrderData.value.length; i++) {
const order = allOrderData.value[i]
const base64Image = imageBase64s[i]
const row = worksheet.addRow({
orderedAt: order.orderedAt || '',
image: base64Image ? '图片' : '无图片',
productTitle: order.productTitle || '',
shopOrderNumber: order.shopOrderNumber || '',
timeSinceOrder: order.timeSinceOrder || '',
priceJpy: formatJpy(order.priceJpy),
productQuantity: order.productQuantity || 0,
shippingFeeJpy: formatJpy(order.shippingFeeJpy),
serviceFee: order.serviceFee || '',
productNumber: order.productNumber || '',
poNumber: order.poNumber || '',
shippingFeeCny: formatCny(order.shippingFeeCny),
internationalShippingFee: order.internationalShippingFee || '',
poLogisticsCompany: order.poLogisticsCompany || '',
poTrackingNumber: order.poTrackingNumber || '',
internationalTrackingNumber: order.internationalTrackingNumber || '',
trackInfo: order.trackInfo || ''
})
if (base64Image) {
const base64Data = base64Image.split(',')[1]
if (base64Data) {
const imageId = workbook.addImage({
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
}
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('zebra', blob, fileName)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
} catch (error) {
showMessage('导出失败', 'error')
} finally {
exportLoading.value = false
}
}
onMounted(async () => {
await loadAccounts()
try {
const latest = await zebraApi.getLatestOrders()
const data = (latest as any)?.data || latest
allOrderData.value = data?.orders || []
} catch {}
})
// 账号对话框
const accountDialogVisible = ref(false)
const accountForm = ref<BanmaAccount>({ isDefault: 0, status: 1 })
const isEditMode = ref(false)
const formUsername = ref('')
const formPassword = ref('')
const rememberPwd = ref(true)
const managerVisible = ref(false)
const accountManagerRef = ref()
function openAddAccount() {
isEditMode.value = false
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
formUsername.value = ''
formPassword.value = ''
accountDialogVisible.value = true
}
function openManageAccount() {
const cur = accounts.value.find(a => a.id === accountId.value)
if (!cur) return
isEditMode.value = true
accountForm.value = { ...cur }
formUsername.value = cur.username || ''
formPassword.value = localStorage.getItem(`banma:pwd:${cur.username || ''}`) || ''
accountDialogVisible.value = true
}
async function submitAccount() {
if (!formUsername.value) { ElMessage({ message: '请输入账号', type: 'warning' }); return }
const payload: BanmaAccount = {
id: accountForm.value.id,
name: accountForm.value.name || formUsername.value,
username: formUsername.value,
password: formPassword.value || '',
isDefault: accountForm.value.isDefault || 0,
status: accountForm.value.status || 1,
}
try {
const username = getUsernameFromToken()
const res = await zebraApi.saveAccount(payload, username)
const id = (res as any)?.data?.id || (res as any)?.id
if (!id) throw new Error((res as any)?.msg || '保存失败')
accountDialogVisible.value = false
await loadAccounts()
if (id) accountId.value = id
if (managerVisible.value && accountManagerRef.value?.load) {
accountManagerRef.value.load()
}
} catch (e: any) {
ElMessage({ message: e?.message || '账号或密码错误无法获取Token', type: 'error' })
}
}
async function removeCurrentAccount() {
if (!isEditMode.value || !accountForm.value.id) return
try {
await ElMessageBox.confirm('确认删除该账号?', '提示', { type: 'warning' })
} catch { return }
await zebraApi.removeAccount(accountForm.value.id)
accountDialogVisible.value = false
await loadAccounts()
}
</script>
<template>
<div class="zebra-root">
<div class="layout">
<aside class="aside">
<div class="aside-header">
<span>操作流程</span>
</div>
<div class="aside-steps">
<section class="step step-accounts">
<div class="step-index">1</div>
<div class="step-body">
<div class="step-title">需要查询的账号</div>
<div class="tip">请选择需要查询数据的账号如未添加账号请点击添加账号</div>
<template v-if="accounts.length">
<el-scrollbar :class="['account-list', { 'scroll-limit': accounts.length > 3 }]">
<div>
<div
v-for="a in accounts"
:key="a.id"
:class="['acct-item', { selected: accountId === a.id }]"
@click="selectAccount(Number(a.id))"
>
<span class="acct-row">
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" />
<span class="acct-text">{{ a.name || a.username }}</span>
<span v-if="accountId === a.id" class="acct-check"></span>
</span>
</div>
</div>
</el-scrollbar>
</template>
<template v-else>
<div class="placeholder-box">
<img class="placeholder-img" src="/icon/image.png" alt="add-account" />
<div class="placeholder-tip">请添加 斑马ERP 账号</div>
</div>
</template>
<div class="step-actions btn-row sticky-actions">
<el-button size="small" class="w50" @click="openAddAccount">添加账号</el-button>
<el-button size="small" class="w50 btn-blue" @click="managerVisible = true">账号管理</el-button>
</div>
</div>
</section>
<section class="step">
<div class="step-index">2</div>
<div class="step-body">
<div class="step-title">需要查询的日期</div>
<div class="tip">请选择查询数据的日期范围</div>
<el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%">
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" />
</el-select>
<div style="height: 8px"></div>
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :disabled="loading || !accounts.length" size="small" style="width: 100%" />
</div>
</section>
<section class="step">
<div class="step-index">3</div>
<div class="step-body">
<div class="step-title">获取数据</div>
<div class="tip">点击下方按钮开始查询订单数据</div>
<div class="btn-col">
<el-button size="small" class="w100 btn-blue" :disabled="loading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button>
<el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button>
</div>
</div>
</section>
<section class="step">
<div class="step-index">4</div>
<div class="step-body">
<div class="step-title">导出数据</div>
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
<div class="btn-col">
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div>
</div>
</section>
</div>
</aside>
<div class="content">
<!-- 数据表格无数据时也显示表头 -->
<div class="table-section">
<div v-if="showProgress" class="progress-section">
<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>
</div>
<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-for="row in paginatedData" :key="row.shopOrderNumber + (row.productNumber || '')">
<td>{{ row.orderedAt || '-' }}</td>
<td>
<div class="image-container" v-if="row.productImage">
<el-image :src="row.productImage" class="thumb" fit="contain" :preview-src-list="[row.productImage]" />
</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="paginatedData.length === 0" class="empty-abs">
<div v-if="loading" class="empty-container">
<div class="spinner"></div>
<div>加载中...</div>
</div>
<div v-else class="empty-container">
<div class="empty-icon" style="font-size:48px;">📄</div>
<div class="empty-text">暂无数据请获取订单</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>
<!-- 账号新增/编辑对话框 -->
<el-dialog v-model="accountDialogVisible" width="420px" class="add-account-dialog">
<template #header>
<div class="aad-header">
<img class="aad-icon" src="/icon/image.png" alt="logo" />
<div class="aad-title">添加账号</div>
</div>
</template>
<div class="aad-row">
<el-input v-model="formUsername" placeholder="请输入账号" />
</div>
<div class="aad-row">
<el-input v-model="formPassword" placeholder="请输入密码" type="password" show-password />
</div>
<div class="aad-row aad-opts">
<el-checkbox v-model="rememberPwd">保存密码</el-checkbox>
</div>
<template #footer>
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button>
</template>
</el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @add="openAddAccount" @refresh="loadAccounts" />
</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; }
.layout { background: #fff; border-radius: 4px; padding: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; }
.aside { border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease; }
.aside.collapsed { width: 56px; overflow: hidden; }
.aside-header { display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px; }
.aside-steps { position: relative; }
.step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0; }
.step + .step { border-top: 1px dashed #ebeef5; }
.step-index { width: 22px; height: 22px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px; }
.step-body { min-width: 0; text-align: left; }
.step-title { font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left; }
.aside-steps:before { content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6); }
.account-list {height: auto; }
.step-actions { margin-top: 8px; display: flex; gap: 8px; }
.step-accounts { position: relative; }
.sticky-actions { position: sticky; bottom: 0; background: #fafafa; padding-top: 8px; }
.scroll-limit { max-height: 160px; }
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.btn-col { display: flex; flex-direction: column; gap: 6px; }
.w50 { width: 48%; }
.w100 { width: 100%; }
.placeholder-box { display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
.placeholder-img { width: 120px; opacity: 0.9; }
.placeholder-tip { margin-top: 6px; font-size: 12px; color: #a8abb2; }
.aside :deep(.el-date-editor) { width: 100%; }
.aside :deep(.el-range-editor.el-input__wrapper) { width: 100%; box-sizing: border-box; }
.aside :deep(.el-input),
.aside :deep(.el-input__wrapper),
.aside :deep(.el-select) { width: 100%; box-sizing: border-box; }
.aside :deep(.el-button + .el-button) { margin-left: 0 !important; }
.btn-row :deep(.el-button) { width: 100%; }
.btn-col :deep(.el-button) { width: 100%; }
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
.tip { color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left; }
.avatar { width: 22px; height: 22px; border-radius: 50%; margin-right: 6px; vertical-align: -2px; }
.acct-text { vertical-align: middle; }
.acct-row { display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%; }
.acct-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.status-dot.on { background: #22c55e; }
.status-dot.off { background: #f87171; }
.acct-item { padding: 6px 8px; border-radius: 8px; cursor: pointer; }
.acct-item.selected { background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff; }
.acct-check { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px; }
.account-list::-webkit-scrollbar { width: 0; height: 0; }
.add-account-dialog .aad-header { display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%; }
.add-account-dialog .aad-icon { width: 120px; height: auto; }
.add-account-dialog .aad-title { font-weight: 600; font-size: 18px; text-align: center; }
.add-account-dialog .aad-row { margin-top: 12px; }
.add-account-dialog .aad-opts { display:flex; align-items:center; }
/* 居中 header避免右上角关闭按钮影响视觉中心 */
:deep(.add-account-dialog .el-dialog__header) { text-align: center; padding-right: 0; display: block; }
.content { display: grid; grid-template-rows: 1fr auto; min-height: 0; }
.table-section { min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
.table-wrapper { flex: 1; overflow: auto; overflow-x: auto; }
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
.table { width: max-content; min-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; 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: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.image-container { display: flex; justify-content: center; align-items: center; width: 28px; height: 24px; margin: 0 auto; background: #f8f9fa; border-radius: 2px; }
.thumb { width: 22px; height: 22px; 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 { position: sticky; bottom: 0; z-index: 2; 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: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.progress-section { margin: 0px 12px 0px 12px; }
.progress-box { padding: 4px 0; }
.progress-container { display: flex; align-items: center; gap: 8px; }
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
.export-progress-fill { height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease; }
.export-progress-text { font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right; }
</style>