Files
inkreach-official-website/apps/admin/src/views/sync/SyncView.vue
T

475 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Box, Clock, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
import type { SyncLog } from '@/types'
import { syncApi } from '@/api/sync'
const logs = ref<SyncLog[]>([])
const loading = ref(false)
const syncing = ref(false)
type SyncType = 'PRODUCTS' | 'CATEGORIES' | 'PRODUCT_DETAILS'
const currentType = ref<SyncType>('PRODUCTS')
let timer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
async function refreshLogs() {
loading.value = true
try {
const data = await syncApi.getSyncStatus(50) as any
logs.value = Array.isArray(data) ? data : []
} catch (err) {
console.warn('Failed to fetch sync status', err)
} finally {
loading.value = false
}
}
function syncTypeLabel(type: SyncType): string {
if (type === 'CATEGORIES') return '分类'
if (type === 'PRODUCT_DETAILS') return '全部原产品详情'
return '商品列表'
}
async function pollUntilDone(type: SyncType) {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
try {
const data = await syncApi.getSyncStatus(5) as any
const latest = Array.isArray(data) ? data : []
if (latest.length > 0) logs.value = [...latest, ...logs.value.slice(latest.length)]
const top = latest.find((l: SyncLog) => l.type === type)
if (top && top.status !== 'RUNNING') {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
syncing.value = false
if (top.status === 'SUCCESS') {
ElMessage.success(`${syncTypeLabel(type)}同步完成`)
} else {
ElMessage.error(`${syncTypeLabel(type)}同步失败`)
}
await refreshLogs()
}
} catch { /* ignore poll errors */ }
}, 3000)
}
async function handleSyncProducts() {
await doSync('PRODUCTS')
}
async function handleSyncCategories() {
await doSync('CATEGORIES')
}
async function handleSyncProductDetails() {
await doSync('PRODUCT_DETAILS')
}
async function doSync(type: SyncType) {
const label = syncTypeLabel(type)
try {
await ElMessageBox.confirm(
`确定立即执行${label}同步吗?${type === 'PRODUCT_DETAILS' ? '将同步全部有效原产品,耗时取决于原产品数量。' : type !== 'CATEGORIES' ? '此操作可能需要几分钟。' : ''}`,
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch { return }
syncing.value = true
currentType.value = type
try {
if (type === 'PRODUCTS') {
await syncApi.syncProducts()
} else if (type === 'PRODUCT_DETAILS') {
await syncApi.syncProductDetails()
} else {
await syncApi.syncCategories()
}
ElMessage.info(`${label}同步已开始`)
pollUntilDone(type)
} catch {
ElMessage.error(`${label}同步启动失败`)
syncing.value = false
}
}
function formatTime(s?: string): string {
if (!s) return '-'
const d = new Date(s)
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
function formatDuration(start?: string, end?: string): string | null {
if (!start || !end) return null
const ms = new Date(end).getTime() - new Date(start).getTime()
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
const stats = computed(() => {
const total = logs.value.length
const success = logs.value.filter(l => l.status === 'SUCCESS').length
const failed = logs.value.filter(l => l.status === 'FAILED').length
const lastLog = logs.value[0]
return { total, success, failed, lastLog }
})
onMounted(() => {
refreshLogs()
timer = setInterval(refreshLogs, 60_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="sync-page" v-loading="loading">
<!-- Action Card -->
<div class="sync-action-card">
<div class="sync-action-info">
<div class="sync-action-icon">
<el-icon :size="28"><Box /></el-icon>
</div>
<div>
<h2 class="sync-action-title">数据同步</h2>
<p class="sync-action-desc">分类和产品每小时自动同步全部 SDS 原产品详情每天 03:30 自动同步也可手动执行</p>
</div>
</div>
<div class="sync-action-buttons">
<el-button
size="large"
:loading="syncing && currentType === 'CATEGORIES'"
:disabled="syncing"
@click="handleSyncCategories"
>
同步分类
</el-button>
<el-button
type="primary"
size="large"
:loading="syncing && currentType === 'PRODUCTS'"
:disabled="syncing"
:icon="Refresh"
@click="handleSyncProducts"
>
同步产品
</el-button>
<el-button
type="success"
size="large"
:loading="syncing && currentType === 'PRODUCT_DETAILS'"
:disabled="syncing"
:icon="Refresh"
@click="handleSyncProductDetails"
>
同步全部原产品详情
</el-button>
</div>
</div>
<!-- Stats Row -->
<div class="sync-stats">
<div class="stat-item">
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">总同步次数</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value stat-success">{{ stats.success }}</span>
<span class="stat-label">成功</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value stat-failed">{{ stats.failed }}</span>
<span class="stat-label">失败</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value stat-time">{{ stats.lastLog ? formatTime(stats.lastLog.startedAt) : '-' }}</span>
<span class="stat-label">最近同步</span>
</div>
</div>
<!-- Log Timeline -->
<div class="sync-logs">
<div class="sync-logs-head">
<h3 class="sync-logs-title">同步日志</h3>
<el-button text :icon="Refresh" @click="refreshLogs">刷新</el-button>
</div>
<div v-if="logs.length === 0 && !loading" class="sync-empty">
<el-empty description="暂无同步记录" :image-size="80" />
</div>
<div v-else class="sync-timeline">
<div
v-for="log in logs"
:key="log.id"
class="timeline-item"
>
<div class="timeline-dot" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
<el-icon :size="12">
<Loading v-if="log.status === 'RUNNING'" />
<CircleCheck v-else-if="log.status === 'SUCCESS'" />
<CircleClose v-else />
</el-icon>
</div>
<div class="timeline-content">
<div class="timeline-header">
<span class="timeline-type">{{ syncTypeLabel(log.type) }}</span>
<span class="timeline-status" :class="log.status === 'SUCCESS' ? 'is-success' : (log.status === 'RUNNING' ? 'is-running' : 'is-failed')">
{{ log.status === 'SUCCESS' ? '成功' : log.status === 'RUNNING' ? '进行中' : '失败' }}
</span>
<span v-if="formatDuration(log.startedAt, log.finishedAt || undefined)" class="timeline-duration">
<el-icon :size="11"><Clock /></el-icon>
{{ formatDuration(log.startedAt, log.finishedAt || undefined) }}
</span>
</div>
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
<span class="timeline-time">{{ formatTime(log.startedAt) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.sync-page {
height: 100%;
overflow-y: auto;
padding: 4px;
}
/* Action Card */
.sync-action-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px;
background: #fff;
border-radius: 12px;
border: 1px solid #ebeef5;
margin-bottom: 16px;
}
.sync-action-info {
display: flex;
align-items: center;
gap: 16px;
}
.sync-action-icon {
width: 56px;
height: 56px;
border-radius: 12px;
background: linear-gradient(135deg, #fff2e8, #ffe0c2);
display: flex;
align-items: center;
justify-content: center;
color: var(--brand-color, #ff6800);
flex-shrink: 0;
}
.sync-action-title {
margin: 0 0 4px;
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.sync-action-desc {
margin: 0;
font-size: 13px;
color: #909399;
}
.sync-action-buttons {
display: flex;
gap: 12px;
}
/* Stats */
.sync-stats {
display: flex;
align-items: center;
gap: 0;
padding: 16px 28px;
background: #fff;
border-radius: 12px;
border: 1px solid #ebeef5;
margin-bottom: 16px;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #1f2937;
line-height: 1;
}
.stat-value.stat-success { color: #67c23a; }
.stat-value.stat-failed { color: #f56c6c; }
.stat-value.stat-time { font-size: 14px; font-weight: 600; color: #606266; }
.stat-label {
font-size: 12px;
color: #909399;
}
.stat-divider {
width: 1px;
height: 32px;
background: #ebeef5;
}
/* Logs */
.sync-logs {
background: #fff;
border-radius: 12px;
border: 1px solid #ebeef5;
overflow: hidden;
}
.sync-logs-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f5f5f5;
}
.sync-logs-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.sync-empty {
padding: 40px 0;
}
/* Timeline */
.sync-timeline {
padding: 16px 20px;
max-height: 500px;
overflow-y: auto;
}
.timeline-item {
display: flex;
gap: 12px;
padding-bottom: 20px;
position: relative;
}
.timeline-item:not(:last-child)::before {
content: '';
position: absolute;
left: 7px;
top: 22px;
bottom: 0;
width: 2px;
background: #f0f0f0;
}
.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
margin-top: 2px;
}
.timeline-dot.is-success {
background: #f0f9eb;
color: #67c23a;
}
.timeline-dot.is-failed {
background: #fef0f0;
color: #f56c6c;
}
.timeline-dot.is-running {
background: #ecf5ff;
color: #409eff;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.timeline-content {
flex: 1;
min-width: 0;
}
.timeline-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.timeline-status {
font-size: 13px;
font-weight: 600;
}
.timeline-status.is-success { color: #67c23a; }
.timeline-status.is-failed { color: #f56c6c; }
.timeline-status.is-running { color: #409eff; }
.timeline-type {
font-size: 12px;
font-weight: 600;
color: #606266;
background: #f5f7fa;
padding: 2px 8px;
border-radius: 4px;
}
.timeline-duration {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: #909399;
background: #f5f7fa;
padding: 2px 6px;
border-radius: 8px;
}
.timeline-message {
margin: 0 0 4px;
font-size: 12px;
color: #606266;
line-height: 1.5;
word-break: break-all;
}
.timeline-time {
font-size: 11px;
color: #c0c4cc;
}
</style>