feat(product-center): implement Figma-designed UI, upload module, and website tests

- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases)
- Add API upload module (multer) with static serving for uploads/public assets
- Add OriginGood.delisted flag and SDS request retry logic
- Add admin ImageUpload component and goods import/upload flows
- Add vitest suite for website components and composables (32 tests)
- Add skills, docs, plans and PRODUCT.md
This commit is contained in:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
+589 -72
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Search,
FolderAdd, Aim,
Plus, Edit, Delete, Search, Top,
FolderAdd, Aim, ArrowDown,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, Position,
@@ -17,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups'
import { positionsApi } from '@/api/positions'
import { originGoodsApi } from '@/api/origin-goods'
const mode = ref<'category' | 'country'>('category')
const mode = ref<'category' | 'country' | 'global'>('category')
const loading = ref(false)
const leftTreeRef = ref()
@@ -32,12 +33,26 @@ const allGoods = ref<Good[]>([])
const searchKeyword = ref('')
const selectedCountryIds = ref<string[]>([])
const selectedTagIds = ref<string[]>([])
const sortBy = ref(localStorage.getItem('goods-sort-by') || 'default')
const SORT_OPTIONS = [
{ label: '默认排序', value: 'default' },
{ label: '优先级 高→低', value: 'priority-desc' },
{ label: '优先级 低→高', value: 'priority-asc' },
{ label: '名称 A→Z', value: 'name-asc' },
{ label: '名称 Z→A', value: 'name-desc' },
{ label: '最新创建', value: 'created-desc' },
{ label: '最早创建', value: 'created-asc' },
]
watch(sortBy, (val) => localStorage.setItem('goods-sort-by', val))
const treeProps = { label: 'label', children: 'children' }
const leftPct = ref(55)
const showAllLeft = ref(false)
const showAllRight = ref(false)
const showUnconfiguredOnly = ref(false)
function onSplitterMouseDown(e: MouseEvent) {
e.preventDefault()
@@ -62,16 +77,21 @@ function onSplitterMouseDown(e: MouseEvent) {
function setExpand(treeRef: any, data: any[], expand: boolean) {
nextTick(() => {
if (!treeRef.value) return
if (expand) {
const keys: string[] = []
function collect(arr: any[]) {
for (const n of arr) { keys.push(n.id); if (n.children) collect(n.children) }
const tree = treeRef?.value ?? treeRef
if (!tree?.getNode) return
const keys: string[] = []
function collect(arr: any[]) {
for (const n of arr) {
keys.push(n.id)
if (n.children?.length) collect(n.children)
}
}
collect(data)
for (const key of keys) {
const node = tree.getNode(key)
if (node && node.childNodes?.length) {
node.expanded = expand
}
collect(data)
treeRef.value.setExpandedKeys(keys)
} else {
treeRef.value.setExpandedKeys([])
}
})
}
@@ -106,6 +126,31 @@ async function loadAll() {
finally { loading.value = false }
}
// Set of origin good IDs currently visible in the right tree (active/non-delisted)
const activeOriginGoodIds = computed(() => {
const ids = new Set<string>()
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && n.rawId) ids.add(String(n.rawId))
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return ids
})
// Sort tags by their group's sortOrder, then by tag's sortOrder within group
function sortTagsByGroup(tags: Tag[]): Tag[] {
const groupOrder = new Map<string, number>()
allTagGroups.value.forEach((g, i) => groupOrder.set(g.id, i))
return [...tags].sort((a, b) => {
const ga = a.tagGroupId ? (groupOrder.get(a.tagGroupId) ?? 999) : 999
const gb = b.tagGroupId ? (groupOrder.get(b.tagGroupId) ?? 999) : 999
if (ga !== gb) return ga - gb
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
})
}
function goodToNode(g: Good): any {
return {
id: 'good-' + g.id,
@@ -116,17 +161,18 @@ function goodToNode(g: Good): any {
goodName: g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || null,
country: g.country?.countryName || g.countryId,
tags: g.tags || [],
tags: sortTagsByGroup(g.tags || []),
priority: g.goodPriority,
originGoodId: g.originGoodId,
originGoodName: g.originGood?.goodName || null,
originGoodImage: g.originGood?.goodImage || null,
originGoodPrice: g.originGood?.goodPrice || null,
sdsGoodId: g.originGood?.sdsGoodId || null,
originDelisted: !!g.originGoodId && !activeOriginGoodIds.value.has(String(g.originGoodId)),
}
}
// Computed: goods after applying search + country + tag filters
// Computed: goods after applying search + country + tag filters + sort
const filteredGoods = computed(() => {
let result = allGoods.value
if (searchKeyword.value) {
@@ -141,7 +187,28 @@ const filteredGoods = computed(() => {
return selectedTagIds.value.some(id => ids.includes(id))
})
}
return result
const sorted = [...result]
switch (sortBy.value) {
case 'priority-desc':
sorted.sort((a, b) => (b.goodPriority || 0) - (a.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
break
case 'priority-asc':
sorted.sort((a, b) => (a.goodPriority || 0) - (b.goodPriority || 0) || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
break
case 'name-asc':
sorted.sort((a, b) => (a.goodName || '').localeCompare(b.goodName || '', 'zh-Hans-CN'))
break
case 'name-desc':
sorted.sort((a, b) => (b.goodName || '').localeCompare(a.goodName || '', 'zh-Hans-CN'))
break
case 'created-desc':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
break
case 'created-asc':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
break
}
return sorted
})
function mapCatNodes(nodes: CategoryTree[], goods: Good[]): any[] {
@@ -195,22 +262,37 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
goodImage: og.goodImage,
goodPrice: og.goodPrice,
sdsGoodId: og.sdsGoodId,
configuredCount: og.configuredCount ?? 0,
configuredCountries: og.configuredCountries ?? [],
}))
return {
id: 'rc-' + node.categoryId,
label: node.categoryName,
configuredCount: node.configuredCount ?? 0,
totalCount: node.totalCount ?? 0,
children: [...children, ...goods],
}
}
rightTreeData.value = tree.tree.map(mapCat)
}
function rightFilterNode(value: string, data: any) {
if (!value) return true
if (data.isOG) return data.label.includes(value)
function rightFilterNode(_value: string, data: any) {
if (data.isOG) {
if (showUnconfiguredOnly.value && data.configuredCount > 0) return false
if (searchKeyword.value && !data.label.includes(searchKeyword.value)) return false
return true
}
return true
}
function toggleUnconfiguredFilter() {
showUnconfiguredOnly.value = !showUnconfiguredOnly.value
rightTreeRef.value?.filter?.('')
if (showUnconfiguredOnly.value) {
nextTick(() => setExpand(rightTreeRef.value, rightTreeData.value, true))
}
}
// Right tree search filter
watch(searchKeyword, () => {
rightTreeRef.value?.filter?.('')
@@ -273,24 +355,36 @@ const configOG = ref<any>(null)
const configDropTarget = ref<any>(null)
const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '',
tagIds: [] as string[], positionId: '', priority: 0, goodImage: '',
tagIds: [] as string[], positionId: '', goodImage: '',
})
const configPositions = ref<Position[]>([])
function openConfigModal(og: any, dropTarget: any) {
configOG.value = og
configDropTarget.value = dropTarget
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', priority: 0, goodImage: og.goodImage || '' }
if (mode.value === 'category') {
configForm.value.categoryId = dropTarget.id
configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id)
} else {
configForm.value.countryId = dropTarget.id
configForm.value = { countryId: '', cascaderCategory: [], categoryId: '', tagIds: [], positionId: '', goodImage: og.goodImage || '' }
if (dropTarget) {
if (mode.value === 'category') {
configForm.value.categoryId = dropTarget.id
configForm.value.cascaderCategory = findCategoryPath(allCategories.value, dropTarget.id)
} else {
configForm.value.countryId = dropTarget.id
}
}
loadConfigPositions()
configVisible.value = true
}
function openConfigFromRightTree(data: any) {
openConfigModal({
rawId: data.rawId,
goodName: data.goodName,
goodImage: data.goodImage,
goodPrice: data.goodPrice,
sdsGoodId: data.sdsGoodId,
}, null)
}
async function loadConfigPositions() {
const params: any = { page: 1, pageSize: 200 }
if (configForm.value.countryId) params.countryId = configForm.value.countryId
@@ -319,11 +413,11 @@ async function handleConfigSubmit() {
categoryId: Number(configForm.value.categoryId),
tagIds: configForm.value.tagIds.map(Number),
positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined,
goodPriority: configForm.value.priority,
} as any)
ElMessage.success('配置成功')
configVisible.value = false
refreshLeftTree()
refreshRightTree()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '配置失败')
} finally { configLoading.value = false }
@@ -335,7 +429,7 @@ const editLoading = ref(false)
const editGood = ref<Good | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '', priority: 0,
categoryId: '', tagIds: [] as string[], positionId: '',
})
function openEdit(g: Good) {
@@ -347,7 +441,7 @@ function openEdit(g: Good) {
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
categoryId: g.categoryId,
tagIds: (g.tags || []).map(t => t.id),
positionId: g.positionId || '', priority: g.goodPriority,
positionId: g.positionId || '',
}
editVisible.value = true
}
@@ -362,11 +456,11 @@ async function handleEditSubmit() {
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
goodPriority: editForm.value.priority,
} as any)
ElMessage.success('更新成功')
editVisible.value = false
refreshLeftTree()
refreshRightTree()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
@@ -386,6 +480,7 @@ async function handleDeleteGood(g: Good) {
ElMessage.success('删除成功')
editVisible.value = false
refreshLeftTree()
refreshRightTree()
}
// ─── Right tree locate ───
@@ -424,6 +519,41 @@ function locateInRightTree(originGoodId: string) {
}, 250)
}
function locateInLeftTree(originGoodId: string) {
const good = allGoods.value.find(g => g.originGoodId === originGoodId)
if (!good) {
ElMessage.warning('该原产品尚未配置到官网')
return
}
const targetKey = 'good-' + good.id
const tree = leftTreeRef.value
if (!tree?.getNode) return
// Build the category path to expand
const catPath: string[] = []
function findCatPath(nodes: CategoryTree[], targetCatId: string): boolean {
for (const n of nodes) {
if (n.id === targetCatId) { catPath.push(n.id); return true }
if (n.children?.length && findCatPath(n.children, targetCatId)) {
catPath.unshift(n.id); return true
}
}
return false
}
findCatPath(allCategories.value, good.categoryId)
// Expand ancestors
for (const keyId of catPath) {
const node = tree.getNode(keyId)
if (node) node.expanded = true
}
setTimeout(() => {
tree.setCurrentKey(targetKey)
nextTick(() => {
const el = document.querySelector('.gv-left .el-tree-node.is-current') as HTMLElement
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}, 250)
}
// ─── Left Tree: Category CRUD ───
const catEditVisible = ref(false)
const catEditMode = ref<'create' | 'edit'>('create')
@@ -732,6 +862,26 @@ const tagTreeData = computed<TreeNode[]>(() => {
return groupNodes
})
const groupedTagOptions = computed(() => {
const groups = allTagGroups.value
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((g) => ({
id: g.id,
label: g.groupName,
tags: allTags.value
.filter((t) => t.tagGroupId === g.id)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)),
}))
const ungrouped = allTags.value
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
if (ungrouped.length > 0) {
groups.push({ id: 'ungrouped', label: '未分组', tags: ungrouped })
}
return groups
})
function toggleTagInSelection(tagId: string): void {
const idx = selectedTagIds.value.indexOf(tagId)
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
@@ -799,8 +949,150 @@ async function quickCreateTag(targetForm: () => void) {
} catch {}
}
async function quickCreateTagGroup() {
try {
const { value } = await ElMessageBox.prompt('请输入分组名称', '新建分组', {
confirmButtonText: '新建', cancelButtonText: '取消', inputPlaceholder: '分组名称',
})
if (!value.trim()) return
const maxSort = Math.max(0, ...allTagGroups.value.map(g => g.sortOrder))
await tagGroupsApi.createTagGroup({ groupName: value.trim(), sortOrder: maxSort + 1 } as any)
await reloadTags()
ElMessage.success('已创建分组')
} catch {}
}
function onModeChange() {}
// ─── Global mode: flat list with move-to-top ───
const globalGoodsNodes = computed(() =>
filteredGoods.value.map(g => goodToNode(g))
)
const { list: virtualList, containerProps: virtualContainer, wrapperProps: virtualWrapper } = useVirtualList(
globalGoodsNodes,
{ itemHeight: 64, overscan: 5 },
)
const moveTopLoading = ref<string | null>(null)
const globalSelectedIds = ref<Set<string>>(new Set())
// ─── Drag-to-reorder in global mode ───
const dragGoodId = ref<string | null>(null)
const dragOverGoodId = ref<string | null>(null)
const dragOverPos = ref<'before' | 'after'>('before')
function onGlobalDragStart(_e: DragEvent, goodId: string) {
dragGoodId.value = goodId
}
function onGlobalDragOver(e: DragEvent, goodId: string) {
if (!dragGoodId.value || dragGoodId.value === goodId) return
e.preventDefault()
dragOverGoodId.value = goodId
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dragOverPos.value = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
function onGlobalDragLeave(_e: DragEvent, goodId: string) {
if (dragOverGoodId.value === goodId) dragOverGoodId.value = null
}
async function onGlobalDrop(_e: DragEvent) {
const draggedId = dragGoodId.value
const targetId = dragOverGoodId.value
const pos = dragOverPos.value
dragGoodId.value = null
dragOverGoodId.value = null
if (!draggedId || !targetId || draggedId === targetId) return
// Build the new order
const nodes = [...globalGoodsNodes.value]
const fromIdx = nodes.findIndex(n => n.goodId === draggedId)
const toIdx = nodes.findIndex(n => n.goodId === targetId)
if (fromIdx === -1 || toIdx === -1) return
const [moved] = nodes.splice(fromIdx, 1)
let insertIdx = nodes.findIndex(n => n.goodId === targetId)
if (pos === 'after') insertIdx++
nodes.splice(insertIdx, 0, moved)
// Recalculate priorities: first item gets highest priority
const items = nodes.map((n, i) => ({
id: Number(n.goodId),
priority: nodes.length - i,
}))
moveTopLoading.value = 'drag'
try {
await goodsApi.batchUpdatePriority({ items })
await refreshLeftTree()
} catch {
ElMessage.error('排序失败')
} finally {
moveTopLoading.value = null
}
}
function onGlobalDragEnd() {
dragGoodId.value = null
dragOverGoodId.value = null
}
const canDragGlobal = computed(() => {
return sortBy.value === 'default' || sortBy.value === 'priority-desc'
})
const isAllSelected = computed(() => {
const nodes = globalGoodsNodes.value
return nodes.length > 0 && nodes.every(n => globalSelectedIds.value.has(n.goodId))
})
const isIndeterminate = computed(() => {
const c = globalGoodsNodes.value.filter(n => globalSelectedIds.value.has(n.goodId)).length
return c > 0 && c < globalGoodsNodes.value.length
})
function toggleSelectAll() {
if (isAllSelected.value) globalSelectedIds.value.clear()
else globalGoodsNodes.value.forEach(n => globalSelectedIds.value.add(n.goodId))
}
function toggleSelectItem(goodId: string) {
if (globalSelectedIds.value.has(goodId)) globalSelectedIds.value.delete(goodId)
else globalSelectedIds.value.add(goodId)
}
function clearSelection() { globalSelectedIds.value.clear() }
async function moveToTop(goodId: string) {
moveTopLoading.value = goodId
try {
const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0))
await goodsApi.batchUpdatePriority({ items: [{ id: Number(goodId), priority: maxPriority + 1 }] })
ElMessage.success('已置顶')
await refreshLeftTree()
} catch { ElMessage.error('操作失败') }
finally { moveTopLoading.value = null }
}
async function batchMoveToTop() {
const ids = [...globalSelectedIds.value]
if (!ids.length) return
moveTopLoading.value = 'batch'
try {
const maxPriority = Math.max(0, ...allGoods.value.map(g => g.goodPriority || 0))
await goodsApi.batchUpdatePriority({
items: ids.map((id, i) => ({ id: Number(id), priority: maxPriority + ids.length - i })),
})
ElMessage.success(`已置顶 ${ids.length} 个商品`)
clearSelection()
await refreshLeftTree()
} catch { ElMessage.error('操作失败') }
finally { moveTopLoading.value = null }
}
function onSearch() {
rightTreeRef.value?.filter?.('')
}
onMounted(() => loadAll())
</script>
@@ -808,9 +1100,9 @@ onMounted(() => loadAll())
<div class="gv-root" v-loading="loading">
<!-- Filter Bar -->
<div class="gv-filter">
<el-input v-model="searchKeyword" size="small" style="width: 160px" placeholder="搜索..." clearable>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="sortBy" size="small" style="width: 140px">
<el-option v-for="opt in SORT_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-model="selectedCountryIds" multiple collapse-tags collapse-tags-tooltip size="small" placeholder="国家筛选" style="width: 150px" popper-class="filter-popper">
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id">
@@ -826,12 +1118,13 @@ onMounted(() => loadAll())
placement="bottom-start"
:width="280"
trigger="click"
transition="el-zoom-in-top"
popper-class="tag-filter-popper"
>
<template #reference>
<div
class="tag-select-trigger"
:class="{ 'is-filled': selectedTagIds.length > 0 }"
:class="{ 'is-filled': selectedTagIds.length > 0, 'is-active': tagPopoverVisible }"
>
<template v-if="selectedTagIds.length === 0">
<span class="placeholder">标签筛选</span>
@@ -849,11 +1142,14 @@ onMounted(() => loadAll())
</el-tag>
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
</template>
<i class="fa-solid fa-chevron-down arrow"></i>
<el-icon class="tag-arrow"><ArrowDown /></el-icon>
</div>
</template>
<div class="tag-tree-panel">
<div class="tag-tree-toolbar">
<el-button text size="small" :icon="Plus" @click="quickCreateTagGroup">新建分组</el-button>
</div>
<el-tree
:data="tagTreeData"
node-key="id"
@@ -900,15 +1196,21 @@ onMounted(() => loadAll())
</div>
</el-popover>
<el-input v-model="searchKeyword" size="small" style="width: 160px" placeholder="搜索商品..." clearable @keyup.enter="onSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" type="primary" :icon="Search" @click="onSearch">搜索</el-button>
<div class="gv-filter-spacer" />
<el-radio-group v-model="mode" size="small" @change="onModeChange">
<el-radio-button value="category">品类</el-radio-button>
<el-radio-button value="country">国家</el-radio-button>
<el-radio-button value="global">全局</el-radio-button>
</el-radio-group>
</div>
<!-- Dual Tree -->
<div class="gv-trees">
<!-- Dual Tree (hidden in global mode) -->
<div v-show="mode !== 'global'" class="gv-trees">
<!-- Left: 官网分类 + 商品 -->
<div class="gv-panel gv-left" :style="{ width: leftPct + '%', flexShrink: 0 }">
<div class="gv-panel-head">
@@ -935,6 +1237,9 @@ onMounted(() => loadAll())
@dragover="onCatDragOver($event)"
@drop="onCatDrop($event, data)"
>
<el-image v-if="data.icon" :src="data.icon" fit="cover" class="cat-icon">
<template #error><div class="cat-icon-placeholder" /></template>
</el-image>
<span class="cat-label">{{ data.label }}</span>
<span v-if="data.goodCount > 0" class="cat-count">{{ data.goodCount }}</span>
<span class="cat-actions" @click.stop>
@@ -973,10 +1278,6 @@ onMounted(() => loadAll())
>{{ t.tagName }}</span>
</div>
</div>
<div class="gt-row">
<div class="gt-label">优先级</div>
<div class="gt-val">{{ data.priority }}</div>
</div>
<div v-if="data.originGoodName" class="gt-row">
<div class="gt-label">原产品</div>
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
@@ -993,7 +1294,8 @@ onMounted(() => loadAll())
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
</span>
</div>
<div v-if="data.country || data.tags?.length" class="good-meta">
<div v-if="data.country || data.tags?.length || data.originDelisted" class="good-meta">
<span v-if="data.originDelisted" class="good-delisted-badge">下架</span>
<span v-if="data.country" class="good-country">{{ data.country }}</span>
<span
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
@@ -1017,9 +1319,14 @@ onMounted(() => loadAll())
<div class="gv-panel gv-right">
<div class="gv-panel-head">
<span>原产品库</span>
<el-button size="small" link @click="showAllRight = !showAllRight; setExpand(rightTreeRef, rightTreeData, showAllRight)">
{{ showAllRight ? '收起' : '展开' }}
</el-button>
<div style="display:flex;align-items:center;gap:6px">
<el-button size="small" link :type="showUnconfiguredOnly ? 'primary' : ''" @click="toggleUnconfiguredFilter">
{{ showUnconfiguredOnly ? ' 仅未配置' : '仅未配置' }}
</el-button>
<el-button size="small" link @click="showAllRight = !showAllRight; setExpand(rightTreeRef, rightTreeData, showAllRight)">
{{ showAllRight ? '收起' : '展开' }}
</el-button>
</div>
</div>
<el-tree
ref="rightTreeRef"
@@ -1033,15 +1340,119 @@ onMounted(() => loadAll())
<template #default="{ data }">
<div v-if="data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)">
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
<div v-else class="og-thumb og-thumb-placeholder" />
<span class="og-name">{{ data.label }}</span>
<span
v-if="data.configuredCount > 0"
class="og-badge og-badge--ok"
title="已配置 {{ data.configuredCount }} 国"
>已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }}</span>
<span
v-else
class="og-badge og-badge--warn"
>未配置</span>
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
<el-button
v-if="data.configuredCount > 0"
size="small" link :icon="Aim" title="定位到官网商品"
@click.stop="locateInLeftTree(data.rawId)"
/>
<el-button
v-if="!data.configuredCount"
size="small" type="primary" link class="og-config-btn"
@click.stop="openConfigFromRightTree(data)"
>配置</el-button>
</div>
<div v-else class="og-cat-node">
<span>{{ data.label }}</span>
<span v-if="data.totalCount" class="og-cat-count">
<template v-if="data.configuredCount < data.totalCount">
{{ data.configuredCount }}/{{ data.totalCount }}
</template>
<template v-else>{{ data.totalCount }}</template>
</span>
</div>
<span v-else>{{ data.label }}</span>
</template>
</el-tree>
</div>
</div>
<!-- Global Mode: flat list with same panel styling -->
<div v-if="mode === 'global'" class="gv-panel gv-global">
<div class="gv-panel-head">
<template v-if="globalSelectedIds.size > 0">
<el-checkbox :model-value="isAllSelected" :indeterminate="isIndeterminate" @change="toggleSelectAll">全选</el-checkbox>
<span class="gl-batch-count">已选 {{ globalSelectedIds.size }} </span>
</template>
<template v-else>
<span>全部商品 ({{ globalGoodsNodes.length }})</span>
<span v-if="!canDragGlobal" class="gl-drag-hint">切换为默认排序可拖拽排序</span>
</template>
<div style="display:flex;align-items:center;gap:6px">
<template v-if="globalSelectedIds.size > 0">
<el-button size="small" type="primary" :icon="Top" :loading="moveTopLoading === 'batch'" @click="batchMoveToTop">批量置顶</el-button>
<el-button size="small" text @click="clearSelection">取消</el-button>
</template>
<el-button v-else size="small" link @click="setExpand(virtualContainer.ref ?? null, [], true)">展开</el-button>
</div>
</div>
<div class="gv-global-list" v-bind="virtualContainer">
<div v-bind="virtualWrapper">
<div
v-for="{ data } in virtualList"
:key="data.id"
class="gl-item"
:class="{
'is-checked': globalSelectedIds.has(data.goodId),
'is-dragging': dragGoodId === data.goodId,
'is-drop-before': dragOverGoodId === data.goodId && dragOverPos === 'before',
'is-drop-after': dragOverGoodId === data.goodId && dragOverPos === 'after',
}"
:draggable="canDragGlobal"
@dragstart="onGlobalDragStart($event, data.goodId)"
@dragover="onGlobalDragOver($event, data.goodId)"
@dragleave="onGlobalDragLeave($event, data.goodId)"
@drop="onGlobalDrop"
@dragend="onGlobalDragEnd"
>
<el-checkbox
:model-value="globalSelectedIds.has(data.goodId)"
class="gl-checkbox"
@change="toggleSelectItem(data.goodId)"
@click.stop
/>
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="gl-thumb" />
<div v-else class="gl-thumb gl-thumb-placeholder" />
<div class="gl-info">
<div class="gl-name">{{ data.label }}</div>
<div class="gl-meta">
<span v-if="data.originDelisted" class="gl-delisted-badge">下架</span>
<span v-if="data.country" class="gl-country">{{ data.country }}</span>
<span
v-for="t in (data.tags || []).slice(0, 3)" :key="t.id"
class="gl-tag" :style="{ '--tag-color': t.tagColor || '#ccc' }"
>{{ t.tagName }}</span>
<span v-if="(data.tags?.length || 0) > 3" class="gl-tag-more">+{{ data.tags!.length - 3 }}</span>
</div>
</div>
<div class="gl-actions">
<el-button
size="small" link :icon="Top"
:loading="moveTopLoading === data.goodId"
:title="data.priority > 0 ? '已置顶' : '置顶'"
@click.stop="moveToTop(data.goodId)"
/>
<el-button size="small" link :icon="Edit" @click.stop="openEdit(data.raw)" />
<el-button size="small" link type="danger" :icon="Delete" @click.stop="handleDeleteGood(data.raw)" />
</div>
</div>
</div>
</div>
<div v-if="globalGoodsNodes.length === 0" class="gv-global-empty">
<el-empty description="没有匹配的商品" :image-size="60" />
</div>
</div>
<!-- Config Modal -->
<el-dialog v-model="configVisible" title="配置原产品" width="520px" destroy-on-close>
<div v-if="configOG" class="config-og-info">
@@ -1052,7 +1463,7 @@ onMounted(() => loadAll())
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<div v-if="mode === 'category'" class="select-inline">
<div v-if="mode === 'category' || !configDropTarget" class="select-inline">
<el-select v-model="configForm.countryId" placeholder="请选择国家" filterable @change="loadConfigPositions">
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
@@ -1061,19 +1472,18 @@ onMounted(() => loadAll())
<el-tag v-else>{{ allCountries.find(c => c.id === configForm.countryId)?.countryName }}</el-tag>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-if="mode === 'country'" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" />
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item label="图片">
<div class="img-edit-row">
<el-image v-if="configForm.goodImage" :src="configForm.goodImage" fit="cover" class="img-preview" />
<el-input v-model="configForm.goodImage" placeholder="图片 URL(可选)" />
</div>
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="标签">
<div class="select-inline">
<el-select v-model="configForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id" />
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) configForm.value.tagIds.push(latest.id) })" />
</div>
@@ -1083,9 +1493,6 @@ onMounted(() => loadAll())
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-input-number v-model="configForm.priority" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
@@ -1107,10 +1514,7 @@ onMounted(() => loadAll())
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<div class="img-edit-row">
<el-image v-if="editForm.goodImage" :src="editForm.goodImage" fit="cover" class="img-preview" />
<el-input v-model="editForm.goodImage" placeholder="图片 URL(留空则使用原产品图片)" />
</div>
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
@@ -1125,13 +1529,14 @@ onMounted(() => loadAll())
</el-form-item>
<el-form-item label="标签">
<div class="select-inline">
<el-select v-model="editForm.tagIds" multiple filterable style="flex:1">
<el-option v-for="t in allTags" :key="t.id" :label="t.tagName" :value="t.id" />
<el-select v-model="editForm.tagIds" multiple filterable placeholder="选择标签" style="flex:1">
<el-option-group v-for="g in groupedTagOptions" :key="g.id" :label="g.label">
<el-option v-for="t in g.tags" :key="t.id" :label="t.tagName" :value="t.id" />
</el-option-group>
</el-select>
<el-button text :icon="Plus" @click="quickCreateTag(() => { const latest = allTags.value[allTags.value.length - 1]; if (latest) editForm.value.tagIds.push(latest.id) })" />
</div>
</el-form-item>
<el-form-item label="优先级"><el-input-number v-model="editForm.priority" :min="0" :max="9999" /></el-form-item>
</el-form>
<template #footer>
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
@@ -1144,7 +1549,7 @@ onMounted(() => loadAll())
<el-dialog v-model="catEditVisible" :title="catEditMode === 'create' ? '新增分类' : '编辑分类'" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="catEditForm.categoryName" placeholder="分类名称" /></el-form-item>
<el-form-item label="图标"><el-input v-model="catEditForm.categoryIcon" placeholder="图标 URL(可选)" /></el-form-item>
<el-form-item label="图标"><ImageUpload v-model="catEditForm.categoryIcon" label="上传图标" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catEditVisible = false">取消</el-button>
@@ -1156,7 +1561,7 @@ onMounted(() => loadAll())
<el-dialog v-model="countryEditVisible" :title="countryEditMode === 'create' ? '新增国家' : '编辑国家'" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="countryEditForm.countryName" placeholder="国家名称" /></el-form-item>
<el-form-item label="图标"><el-input v-model="countryEditForm.countryIcon" placeholder="图标 URL(可选)" /></el-form-item>
<el-form-item label="图标"><ImageUpload v-model="countryEditForm.countryIcon" label="上传图标" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="countryEditVisible = false">取消</el-button>
@@ -1233,6 +1638,77 @@ onMounted(() => loadAll())
font-weight: 600; font-size: 14px; color: #303133;
}
/* ─── Global mode ─── */
.gv-global {
flex: 1; min-height: 0; display: flex; flex-direction: column;
background: #fff; border-radius: 8px; overflow: hidden;
border: 1px solid #e4e7ed;
}
.gv-global-list {
flex: 1; min-height: 0; overflow-y: auto;
}
/* Virtual list item */
.gl-item {
display: flex; align-items: center; gap: 10px;
height: 64px; padding: 0 12px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer; transition: background 0.12s;
box-sizing: border-box;
}
.gl-item:hover { background: #f5f7fa; }
.gl-item.is-checked { background: #ecf5ff; }
.gl-item.is-dragging { opacity: 0.4; }
.gl-item.is-drop-before { box-shadow: inset 0 2px 0 0 var(--brand-color, #ff6800); }
.gl-item.is-drop-after { box-shadow: inset 0 -2px 0 0 var(--brand-color, #ff6800); }
.gl-checkbox { flex-shrink: 0; margin-right: -4px; }
.gl-batch-count { font-size: 13px; font-weight: 600; color: var(--brand-color, #ff6800); }
.gl-drag-hint { font-size: 11px; color: #c0c4cc; margin-left: 4px; }
.gl-thumb {
width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; object-fit: cover;
}
.gl-thumb-placeholder {
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
}
.gl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.gl-name {
font-size: 13px; font-weight: 500; color: #303133;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gl-meta { display: flex; align-items: center; gap: 4px; }
.gl-country {
font-size: 11px; color: #909399; line-height: 1;
background: #f5f7fa; padding: 2px 6px; border-radius: 8px; flex-shrink: 0;
}
.gl-tag {
font-size: 11px; color: #606266; line-height: 1;
padding: 2px 6px 2px 12px; border-radius: 8px;
background: #fafafa; position: relative; flex-shrink: 0;
max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.gl-tag::before {
content: ''; position: absolute; left: 5px; top: 50%;
transform: translateY(-50%); width: 5px; height: 5px; border-radius: 50%;
background: var(--tag-color, #ccc);
}
.gl-tag-more { font-size: 11px; color: #c0c4cc; flex-shrink: 0; }
.gl-delisted-badge {
font-size: 10px; font-weight: 600; color: #f56c6c;
background: #fef0f0;
padding: 2px 8px; border-radius: 10px; flex-shrink: 0;
}
.gl-actions {
display: flex; gap: 2px; flex-shrink: 0;
opacity: 0; transition: opacity 0.15s;
}
.gl-item:hover .gl-actions { opacity: 1; }
.gv-global-empty { padding: 40px 0; }
/* Splitter */
.gv-splitter {
flex-shrink: 0; width: 8px; cursor: col-resize;
@@ -1252,7 +1728,10 @@ onMounted(() => loadAll())
.gv-left :deep(.el-tree-node__content > .el-tree-node__expand-icon) { flex-shrink: 0; }
/* Left tree: Category node */
.cat-node { display: flex; align-items: center; flex: 1; min-width: 0; padding: 2px 8px; }
.cat-node { display: flex; align-items: center; flex: 1; min-width: 0; padding: 2px 8px; gap: 6px; }
.cat-icon { width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; object-fit: cover; }
.cat-icon :deep(img) { width: 20px; height: 20px; border-radius: 4px; }
.cat-icon-placeholder { width: 20px; height: 20px; border-radius: 4px; background: linear-gradient(135deg, #f5f7fa, #e9ecef); }
.cat-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.cat-count {
background: #f0f0f0; color: #909399; border-radius: 10px;
@@ -1299,6 +1778,11 @@ onMounted(() => loadAll())
background: #f5f7fa; padding: 2px 6px; border-radius: 8px;
flex-shrink: 0; white-space: nowrap;
}
.good-delisted-badge {
font-size: 11px; color: #f56c6c; line-height: 1;
background: #fef0f0; padding: 2px 6px; border-radius: 8px;
flex-shrink: 0; white-space: nowrap;
}
.good-tag {
font-size: 11px; color: #606266; line-height: 1;
padding: 2px 6px 2px 12px; border-radius: 8px;
@@ -1327,8 +1811,32 @@ onMounted(() => loadAll())
.og-node:active { cursor: grabbing; }
.og-node:hover { background: #f5f7fa; }
.og-thumb { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; object-fit: cover; }
.og-thumb-placeholder {
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
}
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
.og-badge {
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px;
flex-shrink: 0; white-space: nowrap;
}
.og-badge--ok {
color: #67c23a; background: #f0f9eb;
}
.og-badge--warn {
color: #ff6800; background: #fff2e8;
}
.og-config-btn {
flex-shrink: 0;
font-size: 12px;
}
.og-cat-node {
display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0;
}
.og-cat-count {
font-size: 10px; color: #909399; background: #f0f0f0;
padding: 1px 6px; border-radius: 8px; flex-shrink: 0;
}
/* Image edit row */
.img-edit-row { display: flex; align-items: center; gap: 8px; width: 100%; }
@@ -1441,21 +1949,26 @@ onMounted(() => loadAll())
.tag-select-trigger:hover {
border-color: #c0c4cc;
}
.tag-select-trigger.is-active {
border-color: var(--brand-color);
}
.tag-select-trigger .placeholder {
color: #a8abb2;
}
.tag-select-trigger .arrow {
.tag-select-trigger .tag-arrow {
margin-left: auto;
font-size: 10px;
color: #c0c4cc;
transition: transform 0.2s;
font-size: 12px;
color: #a8abb2;
transition: transform 0.3s ease, color 0.2s;
flex-shrink: 0;
}
.tag-select-trigger.is-active .tag-arrow {
transform: rotate(180deg);
color: var(--brand-color);
}
.tag-select-trigger.is-filled {
color: #111;
}
.tag-select-trigger.is-filled .arrow {
color: #909399;
}
.tag-select-trigger .more-tag {
display: inline-flex;
align-items: center;
@@ -1476,6 +1989,10 @@ onMounted(() => loadAll())
overflow-y: auto;
padding: 4px 0;
}
.tag-tree-toolbar {
padding: 4px 8px 6px;
border-bottom: 1px solid #f0f0f0;
}
.tag-filter-popper .el-tree {
padding: 0 4px;
}
+375 -84
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Box } from '@element-plus/icons-vue'
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)
const currentType = ref<'PRODUCTS' | 'CATEGORIES'>('PRODUCTS')
let timer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
async function refreshLogs() {
loading.value = true
@@ -23,138 +25,427 @@ async function refreshLogs() {
}
}
async function handleSync() {
async function pollUntilDone(type: 'PRODUCTS' | 'CATEGORIES') {
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(`${type === 'PRODUCTS' ? '产品' : '分类'}同步完成`)
} else {
ElMessage.error(`${type === 'PRODUCTS' ? '产品' : '分类'}同步失败`)
}
await refreshLogs()
}
} catch { /* ignore poll errors */ }
}, 3000)
}
async function handleSyncProducts() {
await doSync('PRODUCTS')
}
async function handleSyncCategories() {
await doSync('CATEGORIES')
}
async function doSync(type: 'PRODUCTS' | 'CATEGORIES') {
const label = type === 'PRODUCTS' ? '产品' : '分类'
try {
await ElMessageBox.confirm(
'确定立即执行产品同步吗?此操作可能需要一些时间。',
`确定立即执行${label}同步吗?${type === 'PRODUCTS' ? '此操作可能需要几分钟。' : ''}`,
'确认',
{ type: 'info', confirmButtonText: '执行', cancelButtonText: '取消' }
)
} catch { return }
syncing.value = true
currentType.value = type
try {
await syncApi.syncProducts()
ElMessage.success('产品同步完成')
await refreshLogs()
if (type === 'PRODUCTS') {
await syncApi.syncProducts()
} else {
await syncApi.syncCategories()
}
ElMessage.info(`${label}同步已开始`)
pollUntilDone(type)
} catch {
ElMessage.error('产品同步失败')
} finally {
ElMessage.error(`${label}同步启动失败`)
syncing.value = false
}
}
function formatDate(s?: string): string {
function formatTime(s?: string): string {
if (!s) return '-'
return new Date(s).toLocaleString()
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 = total - success
const lastLog = logs.value[0]
return { total, success, failed, lastLog }
})
onMounted(() => {
refreshLogs()
timer = setInterval(refreshLogs, 30_000)
timer = setInterval(refreshLogs, 60_000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="page-container">
<div class="sync-cards">
<el-card class="sync-card">
<template #header>
<div class="sync-card-header">
<div class="sync-card-title">
<el-icon><Box /></el-icon>
<span>产品同步</span>
</div>
</div>
</template>
<p class="sync-card-desc">
从上游拉取最新产品和原产品并与本地数据库进行同步
</p>
<el-button type="primary" :loading="syncing" @click="handleSync">
<el-icon><Refresh /></el-icon>
<span>执行产品同步</span>
<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 系统拉取最新分类和产品数据并同步到本地数据库</p>
</div>
</div>
<div class="sync-action-buttons">
<el-button
size="large"
:loading="syncing && currentType === 'CATEGORIES'"
:disabled="syncing"
@click="handleSyncCategories"
>
同步分类
</el-button>
</el-card>
</div>
<div class="page-card logs-card">
<div class="logs-toolbar">
<h3 class="logs-title">同步日志</h3>
<el-button @click="refreshLogs">
<el-icon><Refresh /></el-icon>
<span>刷新</span>
<el-button
type="primary"
size="large"
:loading="syncing && currentType === 'PRODUCTS'"
:disabled="syncing"
:icon="Refresh"
@click="handleSyncProducts"
>
同步产品
</el-button>
</div>
</div>
<el-table v-loading="loading" :data="logs" border stripe>
<el-table-column label="状态" width="100">
<template #default="{ row }: { row: SyncLog }">
<el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'" size="small">
{{ row.status === 'SUCCESS' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="200">
<template #default="{ row }: { row: SyncLog }">
{{ formatDate(row.startTime) }}
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="200" show-overflow-tooltip />
<template #empty>
<el-empty description="暂无同步记录" />
</template>
</el-table>
<!-- 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.startTime) : '-' }}</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">{{ log.type === 'PRODUCTS' ? '产品' : '分类' }}</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.startTime, log.endTime)" class="timeline-duration">
<el-icon :size="11"><Clock /></el-icon>
{{ formatDuration(log.startTime, log.endTime) }}
</span>
</div>
<p v-if="log.message" class="timeline-message">{{ log.message }}</p>
<span class="timeline-time">{{ formatTime(log.startTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.sync-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
.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-card-header {
.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-card-title {
.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;
font-weight: 600;
font-size: 15px;
margin-bottom: 4px;
}
.sync-card-title :deep(.el-icon) {
color: var(--brand-color);
}
.sync-card-desc {
color: #6b7280;
.timeline-status {
font-size: 13px;
margin: 0 0 16px;
min-height: 40px;
}
.logs-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.logs-title {
margin: 0;
font-size: 16px;
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>