Files
inkreach-official-website/apps/admin/src/views/goods/GoodsView.vue
T
yeuimu fe6ab3825d fix(admin): family-aware right-tree badges; flatten union size-chart cells
- 右树徽标/定位/配置按钮改按族语义:族内任一链接已配置 → 全族显示绿色
  已配置(本链接未单独建商品时标题注明并入该款),配置入口在全族未配置时
  才出现;拖拽已配置族链接配置时拦截提示(公开契约一族一款,重复商品官网
  不可见);分组头计数与仅未配置筛选同步族感知(AUTM003/DG120 场景)
- 编辑弹窗族级尺码表把 measurements 数组摊平成列键(与成员级渲染一致),
  修复列头在、格子全空的问题
2026-08-28 19:16:15 +08:00

1588 lines
60 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, nextTick, onMounted, ref, watch } from 'vue'
import { useVirtualList } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Search, Top, Refresh,
FolderAdd, Aim,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
OriginGoodsTreeResponse,
} from '@/types'
import { goodsApi } from '@/api/goods'
import { countriesApi } from '@/api/countries'
import { categoriesApi } from '@/api/categories'
import { tagsApi } from '@/api/tags'
import { tagGroupsApi } from '@/api/tag-groups'
import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync'
import { sameFamily } from '@/utils/family-match'
import { cleanLinkName } from '@/utils/origin-name'
import GoodsConfigDialog from './components/GoodsConfigDialog.vue'
import CustomGoodDialog from './components/CustomGoodDialog.vue'
import GoodsEditDialog from './components/GoodsEditDialog.vue'
import TagFilterPopover from './components/TagFilterPopover.vue'
const mode = ref<'category' | 'country' | 'global'>('category')
const loading = ref(false)
const leftTreeRef = ref()
const rightTreeRef = ref()
const rightTreeData = ref<any[]>([])
const allCategories = ref<CategoryTree[]>([])
const allCountries = ref<Country[]>([])
const allTags = ref<Tag[]>([])
const allTagGroups = ref<TagGroup[]>([])
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)
const syncingOriginGoodIds = ref(new Set<string>())
function onSplitterMouseDown(e: MouseEvent) {
e.preventDefault()
const startX = e.clientX
const startPct = leftPct.value
const container = (e.target as HTMLElement).parentElement!
const containerW = container.clientWidth
function onMove(ev: MouseEvent) {
leftPct.value = Math.min(75, Math.max(35, startPct + ((ev.clientX - startX) / containerW) * 100))
}
function onUp() {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function setExpand(treeRef: any, data: any[], expand: boolean) {
nextTick(() => {
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
}
}
})
}
async function loadAll() {
loading.value = true
try {
const [cats, countries, tags, goodsRes, ogTree] = await Promise.all([
categoriesApi.getCategoryTree(),
countriesApi.getCountriesList({ page: 1, pageSize: 200 } as any),
tagsApi.getTagsList({ page: 1, pageSize: 200 } as any),
goodsApi.getGoodsList({ page: 1, pageSize: 1000 } as any),
originGoodsApi.getTree(),
]) as any[]
allCategories.value = (Array.isArray(cats) ? cats : (cats.items ?? []))
.filter((c: any) => !c.sdsCategoryId)
allCountries.value = Array.isArray(countries) ? countries : (countries.items ?? [])
allTags.value = Array.isArray(tags) ? tags : (tags.items ?? [])
allTagGroups.value = await tagGroupsApi.getTagGroupsList()
allGoods.value = goodsRes?.items ?? []
buildRightTree(ogTree)
} catch (e) { console.error('加载失败', e) }
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,
label: g.goodName,
isGood: true,
raw: g,
goodId: g.id,
goodName: g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || null,
country: g.country?.countryName || g.countryId,
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,
isCustom: g.originGood?.isCustom === true,
mergedCount: 1 + (g.mergedOriginGoods?.length ?? 0),
originDelisted: g.originGood?.source === 'SDS' && !activeOriginGoodIds.value.has(String(g.originGoodId)),
}
}
// Computed: goods after applying search + country + tag filters + sort
const filteredGoods = computed(() => {
let result = allGoods.value
if (searchKeyword.value) {
const kw = searchKeyword.value
// 同时匹配原始链接名与解析后的「品名 型号」展示名
result = result.filter(g => g.goodName?.includes(kw) || cleanLinkName(g.goodName).includes(kw))
}
if (selectedCountryIds.value.length) {
result = result.filter(g => selectedCountryIds.value.includes(g.countryId))
}
if (selectedTagIds.value.length) {
result = result.filter(g => {
const ids = (g.tags || []).map(t => t.id)
return selectedTagIds.value.some(id => ids.includes(id))
})
}
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[] {
return nodes.map(n => {
const childNodes = n.children?.length ? mapCatNodes(n.children, goods) : []
const myGoods = goods.filter(g => g.categoryId === n.id).map(goodToNode)
const children = [...childNodes, ...myGoods]
const goodCount = children.reduce((sum: number, c: any) => sum + (c.goodCount ?? (c.isGood ? 1 : 0)), 0)
return {
id: n.id,
label: n.categoryName,
icon: n.categoryIcon,
isCat: true,
raw: n,
goodCount,
children,
}
})
}
// Computed: left tree data — auto-rebuilds when any dependency changes
const leftTreeData = computed(() => {
const goods = filteredGoods.value
if (mode.value === 'category') {
return mapCatNodes(allCategories.value, goods)
} else {
return allCountries.value.map(c => {
const cGoods = goods.filter(g => g.countryId === c.id)
return {
id: c.id,
label: c.countryName,
icon: c.countryIcon,
isCat: true,
raw: c,
goodCount: cGoods.length,
children: cGoods.map(goodToNode),
}
})
}
})
function buildRightTree(tree: OriginGoodsTreeResponse) {
function mapCat(node: any): any {
const children = (node.children || []).map(mapCat)
const goods = (node.originGoods || []).map((og: any) => ({
id: 'og-' + og.id,
label: og.goodName,
isOG: true,
rawId: og.id,
parentId: 'rc-' + node.categoryId,
goodName: og.goodName,
goodImage: og.goodImage,
goodPrice: og.goodPrice,
sdsGoodId: og.sdsGoodId,
configuredCount: og.configuredCount ?? 0,
configuredCountries: og.configuredCountries ?? [],
hasDetail: Boolean(og.hasDetail),
detailSyncedAt: og.detailSyncedAt ?? null,
variantCount: og.variantCount ?? 0,
sizeRowCount: og.sizeRowCount ?? 0,
packageRowCount: og.packageRowCount ?? 0,
familyId: og.familyId ?? null,
familyCode: og.familyCode ?? null,
familyName: og.familyName ?? null,
familyStale: og.familyStale ?? null,
}))
return {
id: 'rc-' + node.categoryId,
label: node.categoryName,
configuredCount: node.configuredCount ?? 0,
totalCount: node.totalCount ?? 0,
children: [...children, ...goods],
}
}
const roots = tree.tree.map(mapCat)
// 族级配置聚合:公开契约一族一款 —— 族内任一链接已配置,全族视为已配置
const ogNodes: any[] = []
;(function collect(nodes: any[]) {
for (const n of nodes) {
if (n.isOG) ogNodes.push(n)
else collect(n.children ?? [])
}
})(roots)
const familyConfigured = new Map<string, number>()
for (const og of ogNodes) {
if (og.familyId) {
familyConfigured.set(og.familyId, (familyConfigured.get(og.familyId) ?? 0) + og.configuredCount)
}
}
for (const og of ogNodes) {
og.familyConfiguredCount = og.familyId ? (familyConfigured.get(og.familyId) ?? 0) : 0
og.configuredEff = og.configuredCount > 0 || og.familyConfiguredCount > 0 ? 1 : 0
}
// 分组头部已配置数 = 有效已配置的链接数(含族配置带动)
;(function fillCategory(nodes: any[]): number {
let count = 0
for (const n of nodes) {
n.configuredEff = n.isOG ? n.configuredEff : fillCategory(n.children ?? [])
count += n.configuredEff
}
return count
})(roots)
rightTreeData.value = roots
}
function rightFilterNode(_value: string, data: any) {
if (data.isOG) {
const configured = data.configuredCount > 0 || data.familyConfiguredCount > 0
if (showUnconfiguredOnly.value && configured) 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?.('')
})
function refreshLeftTree() {
goodsApi.getGoodsList({ page: 1, pageSize: 1000 } as any).then((res: any) => {
allGoods.value = res?.items ?? []
})
}
function refreshRightTree() {
originGoodsApi.getTree().then((tree: any) => buildRightTree(tree))
}
// ─── Right → Left Drag (origin product → category/country) ───
function onOGDragStart(event: DragEvent, data: any) {
if (!event.dataTransfer || !data.isOG) return
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('text/plain', JSON.stringify({
rawId: data.rawId,
goodName: data.goodName,
goodImage: data.goodImage,
goodPrice: data.goodPrice,
sdsGoodId: data.sdsGoodId,
}))
}
function onCatDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function onCatDrop(event: DragEvent, data: any) {
event.preventDefault()
if (!data?.isCat) return
const raw = event.dataTransfer?.getData('text/plain')
if (!raw) return
try {
const og = JSON.parse(raw)
openConfigModal(og, data)
} catch {}
}
// ─── Config ModalGoodsConfigDialog 组件) ───
const configVisible = ref(false)
const configOG = ref<any>(null)
const configDropTarget = ref<any>(null)
const configSiblings = ref<any[]>([])
const configDefaultChecked = ref<string[]>([])
function findOgNodeById(rawId: string | number): any {
let found: any = null
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && String(n.rawId) === String(rawId)) { found = n; return }
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return found
}
function collectSiblings(ogNode: any): any[] {
const result: any[] = []
function traverse(nodes: any[]) {
for (const n of nodes) {
// 同分类下的全部其他链接(含已配置的,勾选归族操作幂等)
if (n.isOG && n.parentId === ogNode.parentId && String(n.rawId) !== String(ogNode.rawId)) result.push(n)
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return result
}
function openConfigModal(og: any, dropTarget: any) {
const ogNode = findOgNodeById(og.rawId)
// 公开契约一族一款:族已配置后再配置成员只会产出官网不可见的重复商品
if ((ogNode?.familyConfiguredCount ?? 0) > 0 || (ogNode?.configuredCount ?? 0) > 0) {
ElMessage.info('该链接所属族已配置为官网商品,无需重复配置')
return
}
configOG.value = { ...og, familyId: og.familyId ?? ogNode?.familyId ?? null }
configDropTarget.value = dropTarget
configSiblings.value = ogNode ? collectSiblings(ogNode) : []
configDefaultChecked.value = configSiblings.value
.filter((s) => sameFamily(ogNode ?? { goodName: og.goodName }, s))
.map((s) => String(s.rawId))
configVisible.value = true
}
function openConfigFromRightTree(data: any) {
openConfigModal({
rawId: data.rawId,
goodName: data.goodName,
goodImage: data.goodImage,
goodPrice: data.goodPrice,
sdsGoodId: data.sdsGoodId,
}, null)
}
function onConfigured() {
refreshLeftTree()
refreshRightTree()
}
// ─── Custom GoodCustomGoodDialog 组件) ───
const customVisible = ref(false)
function openCustomCreate() {
customVisible.value = true
}
function onCustomCreated(created: any) {
refreshLeftTree()
openEdit(created)
}
// ─── Edit GoodGoodsEditDialog 组件) ───
const editDialogVisible = ref(false)
const editGoodId = ref('')
function openEdit(g: Good | GoodDetail) {
editGoodId.value = g.id
editDialogVisible.value = true
}
function onEditChanged() {
refreshLeftTree()
refreshRightTree()
}
async function handleDeleteGood(g: Good) {
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
refreshLeftTree()
refreshRightTree()
}
async function handleSyncOriginDetail(data: any) {
if (!data.sdsGoodId || syncingOriginGoodIds.value.has(data.sdsGoodId)) return
syncingOriginGoodIds.value = new Set(syncingOriginGoodIds.value).add(data.sdsGoodId)
try {
const result = await syncApi.syncOneProductDetail(data.sdsGoodId)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
await refreshRightTree()
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally {
const next = new Set(syncingOriginGoodIds.value)
next.delete(data.sdsGoodId)
syncingOriginGoodIds.value = next
}
}
// ─── Right tree locate ───
function findRightTreePath(nodes: any[], targetId: string): string[] | null {
for (const node of nodes) {
if (node.id === targetId) return [node.id]
if (node.children?.length) {
const sub = findRightTreePath(node.children, targetId)
if (sub) return [node.id, ...sub]
}
}
return null
}
function locateInRightTree(originGoodId: string) {
const targetId = 'og-' + originGoodId
const path = findRightTreePath(rightTreeData.value, targetId)
if (!path) {
ElMessage.warning('未在原产品库中找到对应商品')
return
}
const tree = rightTreeRef.value
if (!tree) return
// 1. Expand all ancestors
for (const keyId of path.slice(0, -1)) {
const node = tree.getNode(keyId)
if (node) node.expanded = true
}
// 2. Wait for expanded children to render, then highlight + scroll to center
setTimeout(() => {
tree.setCurrentKey(targetId)
nextTick(() => {
const el = document.querySelector('.gv-right .el-tree-node.is-current') as HTMLElement
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}, 250)
}
/** 右侧定位到左侧后短暂闪烁的商品 id(用于行级高亮动画) */
const locateFlashGoodId = ref<string>('')
function locateInLeftTree(originGoodId: string) {
// 链接 → 商品:主链接精确匹配 → 同族商品 → 旧副源合并商品
const ogNode = findOgNodeById(originGoodId)
const familyId = ogNode?.familyId ?? null
const good = allGoods.value.find(g => String(g.originGoodId) === String(originGoodId))
?? (familyId
? allGoods.value.find(g => g.originGood?.family?.familyId === familyId)
: null)
?? allGoods.value.find(g => (g.mergedOriginGoods ?? []).some(m => String(m.id) === String(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)
// 闪烁高亮定位行(2.4s 后自动消失)
locateFlashGoodId.value = good.id
setTimeout(() => { if (locateFlashGoodId.value === good.id) locateFlashGoodId.value = '' }, 2400)
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')
const catEditParentId = ref<string | null>(null)
const catEditForm = ref({ id: '', categoryName: '', categoryIcon: '' })
const catEditLoading = ref(false)
function openCatCreate(parentId?: string) {
catEditMode.value = 'create'
catEditParentId.value = parentId || null
catEditForm.value = { id: '', categoryName: '', categoryIcon: '' }
catEditVisible.value = true
}
function openCatEdit(node: any) {
catEditMode.value = 'edit'
catEditForm.value = {
id: node.raw?.id || node.id,
categoryName: node.raw?.categoryName || node.label,
categoryIcon: node.raw?.categoryIcon || '',
}
catEditVisible.value = true
}
async function handleCatSubmit() {
if (!catEditForm.value.categoryName.trim()) { ElMessage.warning('请输入分类名称'); return }
catEditLoading.value = true
try {
if (catEditMode.value === 'create') {
await categoriesApi.createCategory({
categoryName: catEditForm.value.categoryName,
categoryIcon: catEditForm.value.categoryIcon || undefined,
parentCategoryId: catEditParentId.value ? Number(catEditParentId.value) : undefined,
} as any)
} else {
await categoriesApi.updateCategory(catEditForm.value.id, {
categoryName: catEditForm.value.categoryName,
categoryIcon: catEditForm.value.categoryIcon || undefined,
} as any)
}
ElMessage.success('保存成功')
catEditVisible.value = false
await reloadCategories()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '操作失败')
} finally { catEditLoading.value = false }
}
async function handleCatDelete(node: any) {
const name = node.raw?.categoryName || node.label
try {
await ElMessageBox.confirm(`确定删除分类「${name}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
try {
await categoriesApi.deleteCategory(node.raw?.id || node.id)
ElMessage.success('删除成功')
await reloadCategories()
} catch { ElMessage.error('删除失败,可能有关联商品') }
}
async function reloadCategories() {
const cats = await categoriesApi.getCategoryTree() as any
allCategories.value = (Array.isArray(cats) ? cats : (cats.items ?? []))
.filter((c: any) => !c.sdsCategoryId)
}
async function onLeftTreeDragEnd(dragNode: any, dropNode: any, position: string) {
if (mode.value !== 'category') return
if (dragNode.data?.isGood) return
const draggedId = dragNode.data?.raw?.id
if (!draggedId) return
let newParentId: string | null = null
if (position === 'inner' && dropNode && !dropNode.data?.isGood) {
newParentId = dropNode.data?.raw?.id || null
} else if (dropNode?.parent) {
newParentId = dropNode.parent.data?.raw?.id || null
}
try {
await categoriesApi.updateCategory(draggedId, {
parentCategoryId: newParentId ? Number(newParentId) : null,
} as any)
ElMessage.success('层级调整成功')
await reloadCategories()
} catch { ElMessage.error('调整失败'); await reloadCategories() }
}
// ─── Country CRUD ───
const countryEditVisible = ref(false)
const countryEditMode = ref<'create' | 'edit'>('create')
const countryEditForm = ref({ id: '', countryName: '', countryIcon: '' })
const countryEditLoading = ref(false)
function openCountryCreate() {
countryEditMode.value = 'create'
countryEditForm.value = { id: '', countryName: '', countryIcon: '' }
countryEditVisible.value = true
}
function openCountryEdit(node: any) {
countryEditMode.value = 'edit'
countryEditForm.value = {
id: node.raw?.id || node.id,
countryName: node.raw?.countryName || node.label,
countryIcon: node.raw?.countryIcon || '',
}
countryEditVisible.value = true
}
async function handleCountrySubmit() {
if (!countryEditForm.value.countryName.trim()) { ElMessage.warning('请输入国家名称'); return }
countryEditLoading.value = true
try {
if (countryEditMode.value === 'create') {
await countriesApi.createCountry({ countryName: countryEditForm.value.countryName, countryIcon: countryEditForm.value.countryIcon || undefined } as any)
} else {
await countriesApi.updateCountry(countryEditForm.value.id, { countryName: countryEditForm.value.countryName, countryIcon: countryEditForm.value.countryIcon || undefined } as any)
}
ElMessage.success('保存成功')
countryEditVisible.value = false
await reloadCountries()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
finally { countryEditLoading.value = false }
}
async function handleCountryDelete(node: any) {
try {
await ElMessageBox.confirm(`确定删除国家「${node.label}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await countriesApi.deleteCountry(node.raw?.id || node.id)
ElMessage.success('删除成功')
await reloadCountries()
} catch { ElMessage.error('删除失败') }
}
async function reloadCountries() {
const res = await countriesApi.getCountriesList({ page: 1, pageSize: 200 } as any) as any
allCountries.value = Array.isArray(res) ? res : (res.items ?? [])
}
async function reloadTags() {
const res = await tagsApi.getTagsList({ page: 1, pageSize: 200 } as any) as any
allTags.value = Array.isArray(res) ? res : (res.items ?? [])
allTagGroups.value = await tagGroupsApi.getTagGroupsList()
}
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
})
// ─── Country edit (from filter dropdown) ───
function openCountryEditFromFilter(c: Country) {
countryEditMode.value = 'edit'
countryEditForm.value = { id: c.id, countryName: c.countryName, countryIcon: c.countryIcon || '' }
countryEditVisible.value = true
}
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>
<template>
<div class="gv-root" v-loading="loading">
<!-- Filter Bar -->
<div class="gv-filter">
<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">
<div class="filter-opt">
<span>{{ c.countryName }}</span>
<el-button text size="small" :icon="Edit" @click.stop="openCountryEditFromFilter(c)" />
</div>
</el-option>
</el-select>
<TagFilterPopover
v-model:selected="selectedTagIds"
:tags="allTags"
:tag-groups="allTagGroups"
@dictionaries-changed="reloadTags"
/>
<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>
<el-button size="small" type="success" :icon="Plus" @click="openCustomCreate">新增自定义商品</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 (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">
<span>{{ mode === 'category' ? '官网分类' : '国家列表' }}</span>
<div style="display:flex;align-items:center;gap:6px">
<el-button size="small" link @click="showAllLeft = !showAllLeft; setExpand(leftTreeRef, leftTreeData, showAllLeft)">
{{ showAllLeft ? '收起' : '展开' }}
</el-button>
<el-button size="small" type="primary" link :icon="Plus" @click="mode === 'category' ? openCatCreate() : openCountryCreate()">新增</el-button>
</div>
</div>
<el-tree
ref="leftTreeRef"
:data="leftTreeData"
:props="treeProps"
node-key="id"
highlight-current
:draggable="mode === 'category'"
:expand-on-click-node="true"
@node-drag-end="onLeftTreeDragEnd"
>
<template #default="{ data }">
<!-- Category/Country node: editable, droppable -->
<div v-if="data.isCat" class="cat-node"
@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>
<el-button v-if="mode === 'category'" size="small" link :icon="FolderAdd" @click="openCatCreate(data.id)" />
<el-button size="small" link :icon="Edit" @click="mode === 'category' ? openCatEdit(data) : openCountryEdit(data)" />
<el-button size="small" link type="danger" :icon="Delete" @click="mode === 'category' ? handleCatDelete(data) : handleCountryDelete(data)" />
</span>
</div>
<!-- Good node: two-line with meta -->
<div
v-else-if="data.isGood"
class="good-node"
:class="{ 'good-node--flash': data.goodId === locateFlashGoodId }"
@click="openEdit(data.raw)"
>
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="good-thumb" />
<div v-else class="good-thumb-placeholder" />
<div class="good-body">
<div class="good-row">
<div class="good-name-col">
<el-tooltip placement="top" :show-after="500" :hide-after="0" effect="light" popper-class="good-tip-popper">
<template #content>
<div class="gt-card">
<div class="gt-head">
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="gt-img" />
<div v-else class="gt-img gt-img-empty" />
<div class="gt-title-area">
<div class="gt-title">{{ cleanLinkName(data.goodName) }}</div>
<div class="gt-country">{{ data.country }}</div>
</div>
</div>
<div class="gt-divider" />
<div class="gt-info">
<div v-if="data.tags?.length" class="gt-row">
<div class="gt-label">标签</div>
<div class="gt-tags">
<span
v-for="t in data.tags" :key="t.id"
class="gt-chip"
:style="{ '--dot': t.tagColor || '#ccc' }"
>{{ t.tagName }}</span>
</div>
</div>
<div v-if="data.originGoodName" class="gt-row">
<div class="gt-label">{{ data.isCustom ? '来源' : '原产品' }}</div>
<div class="gt-val gt-val-ellipsis">{{ cleanLinkName(data.originGoodName) }}</div>
</div>
</div>
</div>
</template>
<span class="good-name" :title="data.goodName">{{ cleanLinkName(data.goodName) }}</span>
<span v-if="data.mergedCount > 1" class="gv-merged-badge">×{{ data.mergedCount }}</span>
</el-tooltip>
</div>
<span class="good-actions" @click.stop>
<el-button v-if="!data.isCustom" size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" />
<el-button size="small" link :icon="Edit" @click="openEdit(data.raw)" />
<el-button size="small" link type="danger" :icon="Delete" @click="handleDeleteGood(data.raw)" />
</span>
</div>
<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.isCustom" class="good-tag">自定义</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"
class="good-tag" :style="{ '--tag-color': t.tagColor || '#ccc' }"
>{{ t.tagName }}</span>
<span v-if="(data.tags?.length || 0) > 3" class="good-tag-more">+{{ data.tags!.length - 3 }}</span>
</div>
</div>
</div>
<span v-else>{{ data.label }}</span>
</template>
</el-tree>
</div>
<!-- Splitter -->
<div class="gv-splitter" @mousedown="onSplitterMouseDown">
<div class="gv-splitter-handle" />
</div>
<!-- Right: 原产品 (read-only reference) -->
<div class="gv-panel gv-right">
<div class="gv-panel-head">
<span>原产品库</span>
<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"
:data="rightTreeData"
:props="treeProps"
:filter-node-method="rightFilterNode"
node-key="id"
highlight-current
:expand-on-click-node="true"
>
<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" :title="data.goodName">{{ 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-if="data.familyConfiguredCount > 0"
class="og-badge og-badge--ok"
:title="`族 ${data.familyCode || ''} 已配置为官网商品,本链接并入该款`"
>已配置</span>
<span
v-else-if="data.familyId"
class="og-badge og-badge--family"
:title="`族 ${data.familyCode || ''}:已归族但尚未配置为官网商品`"
>成族</span>
<span
v-else
class="og-badge og-badge--warn"
>未配置</span>
<span
class="og-badge"
:class="data.hasDetail ? 'og-badge--detail' : 'og-badge--missing'"
:title="data.detailSyncedAt ? `详情同步于 ${new Date(data.detailSyncedAt).toLocaleString()}` : '尚未同步商品详情'"
>
{{ data.hasDetail ? `详情 · ${data.variantCount} SKU` : '缺详情' }}
</span>
<span v-if="data.goodPrice" class="og-price">¥{{ data.goodPrice }}</span>
<el-button
size="small"
link
:icon="Refresh"
:loading="syncingOriginGoodIds.has(data.sdsGoodId)"
:title="data.hasDetail ? '重新同步商品详情' : '同步商品详情'"
@click.stop="handleSyncOriginDetail(data)"
/>
<el-button
v-if="data.configuredCount > 0 || data.familyConfiguredCount > 0"
size="small" link :icon="Aim" title="定位到官网商品"
@click.stop="locateInLeftTree(data.rawId)"
/>
<el-button
v-if="!data.configuredCount && !data.familyConfiguredCount"
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.configuredEff < data.totalCount">
{{ data.configuredEff }}/{{ data.totalCount }}
</template>
<template v-else>{{ data.totalCount }}</template>
</span>
</div>
</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 / Custom / Edit 弹窗组件化 -->
<GoodsConfigDialog
v-model:visible="configVisible"
:og="configOG"
:siblings="configSiblings"
:default-checked-ids="configDefaultChecked"
:drop-target="configDropTarget"
:mode="mode"
:countries="allCountries"
:categories="allCategories"
@configured="onConfigured"
/>
<CustomGoodDialog
v-model:visible="customVisible"
:countries="allCountries"
:categories="allCategories"
:tag-option-groups="groupedTagOptions"
@created="onCustomCreated"
@dictionaries-changed="reloadCountries"
/>
<GoodsEditDialog
v-model:visible="editDialogVisible"
:good-id="editGoodId"
:countries="allCountries"
:categories="allCategories"
:tag-option-groups="groupedTagOptions"
@changed="onEditChanged"
@deleted="onEditChanged"
@dictionaries-changed="reloadCountries"
/>
<!-- Category Edit Modal -->
<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="图标"><ImageUpload v-model="catEditForm.categoryIcon" label="上传图标" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catEditVisible = false">取消</el-button>
<el-button type="primary" :loading="catEditLoading" @click="handleCatSubmit">保存</el-button>
</template>
</el-dialog>
<!-- Country Edit Modal -->
<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="图标"><ImageUpload v-model="countryEditForm.countryIcon" label="上传图标" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="countryEditVisible = false">取消</el-button>
<el-button type="primary" :loading="countryEditLoading" @click="handleCountrySubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.gv-root { display: flex; flex-direction: column; height: 100%; gap: 8px; }
.gv-filter {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 12px; background: #fff; border-radius: 8px; border: 1px solid #e4e7ed;
}
.gv-filter-spacer { flex: 1; }
.gv-trees { display: flex; gap: 0; flex: 1; min-height: 0; }
.gv-panel {
background: #fff; border-radius: 8px; border: 1px solid #e4e7ed;
display: flex; flex-direction: column; overflow: hidden;
}
.gv-panel :deep(.el-tree) { flex: 1; overflow-y: auto; padding: 4px; }
.gv-left { flex-shrink: 0; }
.gv-right { flex: 1; min-width: 0; }
.gv-panel-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; border-bottom: 1px solid #f0f0f0;
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;
display: flex; align-items: center; justify-content: center; z-index: 10;
}
.gv-splitter:hover .gv-splitter-handle,
.gv-splitter:active .gv-splitter-handle {
background: var(--el-color-primary, #ff6800); opacity: 1;
}
.gv-splitter-handle {
width: 3px; height: 40px; border-radius: 2px;
background: #dcdfe6; opacity: 0.6; transition: all 0.15s;
}
/* Left tree: override el-tree node height to auto for two-line goods */
.gv-left :deep(.el-tree-node__content) { height: auto; min-height: 32px; }
.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; 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;
padding: 0 6px; font-size: 11px; min-width: 18px; text-align: center;
}
.cat-actions { visibility: hidden; gap: 2px; flex-shrink: 0; display: flex; }
.cat-node:hover .cat-actions { visibility: visible; }
/* Left tree: Good node (two-line) */
.good-node {
display: flex; align-items: center; gap: 8px;
flex: 1; min-width: 0;
padding: 4px 8px; margin: 2px 0;
cursor: pointer; border-radius: 6px;
transition: background 0.12s;
}
.good-node:hover { background: #f0f2f5; }
.good-thumb {
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
object-fit: cover;
}
.good-thumb-placeholder {
width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0;
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
}
.good-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.good-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
.good-name {
display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; line-height: 1.4; cursor: pointer;
max-width: calc(100% - 40px);
}
.good-name-col { flex: 1; min-width: 0; overflow: hidden; }
.gv-merged-badge {
margin-left: 4px;
padding: 0 5px;
border-radius: 8px;
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-size: 11px;
vertical-align: middle;
flex-shrink: 0;
}
.good-actions {
display: flex; gap: 2px; flex-shrink: 0;
visibility: hidden;
}
.good-node:hover .good-actions { visibility: visible; }
.good-meta {
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
overflow: hidden;
}
.good-country {
font-size: 11px; color: #909399; line-height: 1;
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;
background: #fafafa; position: relative; flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 80px;
}
.good-tag::before {
content: ''; position: absolute; left: 5px; top: 50%;
transform: translateY(-50%); width: 5px; height: 5px; border-radius: 50%;
background: var(--tag-color, #ccc);
}
.good-tag-more {
font-size: 11px; color: #c0c4cc; flex-shrink: 0; line-height: 1;
}
/* Right tree: override node height to auto (same as left) */
.gv-right :deep(.el-tree-node__content) { height: auto; min-height: 32px; }
/* Right tree: Origin good node */
.og-node {
display: flex; align-items: center; gap: 6px;
flex: 1; min-width: 0;
cursor: grab; padding: 3px 8px; margin: 2px 0;
border-radius: 6px; transition: background 0.15s;
}
.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--family {
color: #337ecc; background: #ecf5ff;
}
.og-badge--warn {
color: #ff6800; background: #fff2e8;
}
.og-badge--detail { color: #337ecc; background: #ecf5ff; }
.og-badge--missing { color: #909399; background: #f4f4f5; }
.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%; }
.img-preview { width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; border: 1px solid #e4e7ed; }
.origin-ref { display: flex; align-items: center; gap: 8px; }
/* 左树定位高亮:el-tree 当前节点底色 + 行级闪烁动画 */
.gv-left :deep(.el-tree-node.is-current > .el-tree-node__content) {
background: var(--el-color-primary-light-8);
}
.good-node--flash { animation: good-node-flash 1.2s ease-in-out 2; }
@keyframes good-node-flash {
0%, 100% { background: transparent; }
50% { background: var(--el-color-primary-light-7); box-shadow: inset 0 0 0 1px var(--el-color-primary-light-5); }
}
/* Tag mgmt */
.tag-mgmt { max-height: 400px; overflow-y: auto; }
.tag-mgmt-form { display: flex; gap: 6px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.tag-mgmt-list { display: flex; flex-direction: column; gap: 4px; }
.tag-mgmt-item {
display: flex; align-items: center; gap: 6px;
padding: 4px 0; border-bottom: 1px solid #f5f5f5;
}
/* Inline create */
.select-inline { display: flex; align-items: center; gap: 4px; width: 100%; }
.select-inline .el-select { flex: 1; }
</style>
<style>
.good-tip-popper {
max-width: 320px; padding: 0; border: 1px solid #ebeef5 !important;
border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.08);
}
/* Card */
.gt-card { padding: 12px 14px; }
.gt-head { display: flex; gap: 10px; align-items: center; }
.gt-img { width: 56px; height: 56px; border-radius: 8px; flex-shrink: 0; }
.gt-img-empty { background: linear-gradient(135deg, #f5f7fa, #e9ecef); }
.gt-title-area { min-width: 0; }
.gt-title {
font-size: 14px; font-weight: 600; color: #303133; line-height: 1.4;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px;
}
.gt-country { font-size: 12px; color: #909399; margin-top: 2px; }
.gt-divider { height: 1px; background: #f0f0f0; margin: 10px 0; }
.gt-info { display: flex; flex-direction: column; gap: 6px; }
.gt-row { display: flex; align-items: flex-start; gap: 8px; }
.gt-label {
font-size: 11px; color: #c0c4cc; width: 36px; flex-shrink: 0;
line-height: 20px;
}
.gt-val { font-size: 12px; color: #606266; line-height: 20px; }
.gt-val-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.gt-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.gt-chip {
font-size: 11px; padding: 1px 8px; border-radius: 10px;
background: #f5f7fa; color: #606266; position: relative; padding-left: 16px;
}
.gt-chip::before {
content: ''; position: absolute; left: 6px; top: 50%; transform: translateY(-50%);
width: 6px; height: 6px; border-radius: 50%; background: var(--dot, #ccc);
}
/* Filter option with rename (teleported dropdown — needs global scope) */
.filter-popper .el-select-dropdown__item {
display: flex !important;
align-items: center;
padding-right: 20px;
}
.filter-popper .el-select-dropdown__item .filter-opt {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.filter-opt-tag { position: relative; padding-left: 10px; }
.filter-opt-tag::before {
content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 6px; height: 6px; border-radius: 50%; background: var(--c, #ccc);
}
</style>