Files
inkreach-official-website/apps/admin/src/views/sync/SyncView.vue
T
yeuimu 5e8183c220 feat(admin): family-folded left tree, toolbar regroup, sync log humanize
视觉走查驱动的后台优化(feature/admin-ui-polish-dev,确认后再合并):
- 左树(国家/品类模式)同款多配置折叠为一行族组节点:代表名 + "N 个配置"
  徽标,展开看各行;locateInLeftTree 沿父链展开保证定位有效;国家模式
  拖拽排序适配族嵌套(同容器内排序,按折叠后展示顺序保序重分配优先级)
- 工具栏分区重排:模式切换(品类/国家/全局)移至最左,筛选组居中,
  新增自定义商品靠右,消除 1680 宽下的折行错乱
- 商品行属性小字/国家徽标颜色加深提升对比度
- 右树原产品行加拖拽提示 title
- 数据同步日志人话化:inserted/updated/total/delisted 等报文渲染为
  "新增 0 · 更新 536 · 共计 536",原始报文进 tooltip
- 计划文档 plans/feature/admin-ui-polish-feature.md(含一项误报修正:
  右树 29/3 计数为截图裁切误读,实际 29/33 无 bug)

验证:admin typecheck + 22/22 + 构建绿;浏览器实机复拍国家模式折叠、
族组展开、同步页文案均生效
2026-08-28 23:22:04 +08:00

496 lines
13 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 }
})
const SYNC_KEY_ZH: Record<string, string> = {
inserted: '新增',
updated: '更新',
total: '共计',
delisted: '下架',
reactivated: '恢复上架',
leafCategories: '叶子分类',
}
/** 后端报文(inserted=0 updated=536 ...)渲染为中文摘要;未知键原样保留 */
function humanizeSyncMessage(message?: string | null): string {
if (!message) return ''
const prefixMatch = message.match(/^([^:]+)[:]\s*/)
const prefix = prefixMatch ? prefixMatch[1] : ''
const body = prefixMatch ? message.slice(prefixMatch[0].length) : message
const pairs = [...body.matchAll(/(\w+)=([^\s=]+)/g)]
if (!pairs.length) return message
const text = pairs.map(([, k, v]) => `${SYNC_KEY_ZH[k] ?? k} ${v}`).join(' · ')
return prefix ? `${prefix}${text}` : text
}
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" :title="log.message">{{ humanizeSyncMessage(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>