Files
inkreach-official-website/apps/admin/src/views/goods/GoodsView.vue
T

2221 lines
84 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, ArrowDown,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, Position,
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 { positionsApi } from '@/api/positions'
import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync'
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
}
}
})
}
interface CascadeNode { value: string; label: string; children?: CascadeNode[] }
function buildCascader(tree: CategoryTree[]): CascadeNode[] {
return tree.map(n => ({
value: n.id, label: n.categoryName,
children: n.children?.length ? buildCascader(n.children) : undefined,
}))
}
const categoryCascader = computed(() => buildCascader(allCategories.value))
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: 200 } 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,
originDelisted: !!g.originGoodId && !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) {
result = result.filter(g => g.goodName?.includes(searchKeyword.value))
}
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,
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,
}))
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 (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?.('')
})
function refreshLeftTree() {
goodsApi.getGoodsList({ page: 1, pageSize: 200 } as any).then((res: any) => {
allGoods.value = res?.items ?? []
})
}
function refreshRightTree() {
originGoodsApi.getTree().then((tree: any) => buildRightTree(tree))
}
function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] {
for (const n of nodes) {
if (n.id === targetId) return [n.id]
if (n.children?.length) {
const sub = findCategoryPath(n.children, targetId)
if (sub.length) return [n.id, ...sub]
}
}
return []
}
// ─── 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 Modal ───
const configVisible = ref(false)
const configLoading = ref(false)
const configOG = ref<any>(null)
const configDropTarget = ref<any>(null)
const configForm = ref({
countryId: '', cascaderCategory: [] as string[], categoryId: '',
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: '', 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
if (configForm.value.categoryId) params.categoryId = configForm.value.categoryId
try {
const res = await positionsApi.getPositionsList(params) as any
configPositions.value = Array.isArray(res) ? res : (res.items ?? [])
} catch { configPositions.value = [] }
}
function onConfigCascaderChange(val: string[]) {
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
loadConfigPositions()
}
async function handleConfigSubmit() {
if (!configForm.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!configForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
configLoading.value = true
try {
await goodsApi.createGood({
goodName: configOG.value.goodName,
goodImage: configForm.value.goodImage || undefined,
originGoodId: Number(configOG.value.rawId),
countryId: Number(configForm.value.countryId),
categoryId: Number(configForm.value.categoryId),
tagIds: configForm.value.tagIds.map(Number),
positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined,
} as any)
ElMessage.success('配置成功')
configVisible.value = false
refreshLeftTree()
refreshRightTree()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '配置失败')
} finally { configLoading.value = false }
}
// ─── Edit Good (replaces detail — click opens edit directly) ───
const editVisible = ref(false)
const editLoading = ref(false)
const editDetailLoading = ref(false)
const detailSyncing = ref(false)
const editGood = ref<Good | GoodDetail | null>(null)
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '',
})
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? [])
const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? [])
const editSizeRows = computed(() => {
const rows = editOriginDetail.value?.sizeChart?.rows ?? []
return rows.map((row: any) => ({
...row,
...(row.measurements ?? []).reduce((out: Record<string, string>, item: any) => {
out[item.key] = item.cm ?? '-'
return out
}, {}),
}))
})
const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? [])
async function openEdit(g: Good) {
editGood.value = g
editForm.value = {
id: g.id, goodName: g.goodName,
goodImage: g.goodImage || g.originGood?.goodImage || '',
countryId: g.countryId,
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
categoryId: g.categoryId,
tagIds: (g.tags || []).map(t => t.id),
positionId: g.positionId || '',
}
editVisible.value = true
editDetailLoading.value = true
try {
editGood.value = await goodsApi.getGoodById(g.id)
} catch {
ElMessage.warning('商品详情加载失败,当前显示列表数据')
} finally {
editDetailLoading.value = false
}
}
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
}
}
async function handleSyncOneDetail() {
const goodId = editGood.value?.originGood?.sdsGoodId
if (!goodId) return
detailSyncing.value = true
try {
const result = await syncApi.syncOneProductDetail(goodId)
editGood.value = await goodsApi.getGoodById(editGood.value!.id)
ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`)
await Promise.all([refreshLeftTree(), refreshRightTree()])
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '商品详情同步失败')
} finally {
detailSyncing.value = false
}
}
async function handleEditSubmit() {
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
ElMessage.success('更新成功')
editVisible.value = false
refreshLeftTree()
refreshRightTree()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
function onEditCascaderChange(val: string[]) {
editForm.value.categoryId = val.length ? val[val.length - 1] : ''
}
async function handleDeleteGood(g: Good) {
try {
await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
} catch { return }
await goodsApi.deleteGood(g.id)
ElMessage.success('删除成功')
editVisible.value = false
refreshLeftTree()
refreshRightTree()
}
// ─── 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)
}
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')
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 ?? [])
}
// ─── Tag edit modal (from filter dropdown) ───
const tagEditVisible = ref(false)
const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' })
const tagEditLoading = ref(false)
function openTagEditFromFilter(t: Tag) {
tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' }
tagEditVisible.value = true
}
async function handleTagEditSubmit() {
if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return }
tagEditLoading.value = true
try {
await tagsApi.updateTag(tagEditForm.value.id, { tagName: tagEditForm.value.tagName.trim(), tagColor: tagEditForm.value.tagColor, tagFontColor: tagEditForm.value.tagFontColor } as any)
ElMessage.success('保存成功')
tagEditVisible.value = false
await reloadTags()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
finally { tagEditLoading.value = false }
}
async function handleTagEditDelete() {
try {
await ElMessageBox.confirm(`确定删除标签「${tagEditForm.value.tagName}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await tagsApi.deleteTag(tagEditForm.value.id)
ElMessage.success('删除成功')
tagEditVisible.value = false
await reloadTags()
} catch { ElMessage.error('删除失败') }
}
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()
}
// ─── Group edit modal ───
const groupEditVisible = ref(false)
const groupEditLoading = ref(false)
const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' })
function openGroupEdit(node: TreeNode): void {
// Don't allow editing the virtual "未分组" node
if (node.id === 'g-ungrouped') return
groupEditForm.value = { id: node.rawId!, groupName: node.label }
groupEditVisible.value = true
}
async function handleGroupSave(): Promise<void> {
const name = groupEditForm.value.groupName.trim()
if (!name) { ElMessage.warning('请输入分组名称'); return }
groupEditLoading.value = true
try {
await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name })
ElMessage.success('已保存')
groupEditVisible.value = false
await reloadTags()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存失败')
} finally {
groupEditLoading.value = false
}
}
async function handleGroupDelete(): Promise<void> {
try {
await ElMessageBox.confirm(
`删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`,
'确认删除',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch { return }
groupEditLoading.value = true
try {
await tagGroupsApi.deleteTagGroup(groupEditForm.value.id)
ElMessage.success('已删除')
groupEditVisible.value = false
await reloadTags()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '删除失败')
} finally {
groupEditLoading.value = false
}
}
// ─── Tag tree (filter dropdown) ───
interface TreeNode {
id: string
rawId: string | null
type: 'group' | 'tag'
label: string
sortOrder?: number
disabled?: boolean
children?: TreeNode[]
}
const tagPopoverVisible = ref(false)
const hoveredNodeId = ref<string | null>(null)
const MAX_VISIBLE_TAGS = 1
const displayedSelectedTagIds = computed(() =>
selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS),
)
const hiddenSelectedCount = computed(() =>
Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS),
)
function getTagName(id: string): string {
return allTags.value.find((t) => t.id === id)?.tagName ?? id
}
function removeSelectedTag(id: string): void {
const idx = selectedTagIds.value.indexOf(id)
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
}
const tagTreeData = computed<TreeNode[]>(() => {
const groupNodes: TreeNode[] = allTagGroups.value
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((g) => ({
id: `g-${g.id}`,
rawId: g.id,
type: 'group',
label: g.groupName,
sortOrder: g.sortOrder,
disabled: true,
children: allTags.value
.filter((t) => t.tagGroupId === g.id)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag',
label: t.tagName,
})),
}))
const ungrouped = allTags.value
.filter((t) => !t.tagGroupId)
.sort((a, b) => a.tagName.localeCompare(b.tagName))
.map((t) => ({
id: `t-${t.id}`,
rawId: t.id,
type: 'tag',
label: t.tagName,
}))
if (ungrouped.length > 0) {
groupNodes.push({
id: 'g-ungrouped',
rawId: null,
type: 'group',
label: '未分组',
disabled: true,
children: ungrouped,
})
}
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)
else selectedTagIds.value.push(tagId)
}
function onTreeNodeClick(data: TreeNode): void {
if (data.type === 'group' && !data.disabled) {
// Click group = toggle all its tags
const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean)
if (tagIds.length === 0) return
const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id))
if (allSelected) {
// Deselect all
tagIds.forEach((id) => {
const idx = selectedTagIds.value.indexOf(id)
if (idx >= 0) selectedTagIds.value.splice(idx, 1)
})
} else {
// Select all (add missing ones)
tagIds.forEach((id) => {
if (!selectedTagIds.value.includes(id)) selectedTagIds.value.push(id)
})
}
}
// Tag click is handled by its checkbox
}
function openTagEditFromFilterById(id: string): void {
const t = allTags.value.find((tag) => tag.id === id)
if (t) openTagEditFromFilter(t)
}
// ─── 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
}
// ─── Quick create (inline in edit/config modal) ───
async function quickCreateCountry(targetForm: () => void) {
try {
const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称',
})
if (!value.trim()) return
const res = await countriesApi.createCountry({ countryName: value.trim() } as any) as any
await reloadCountries()
targetForm()
ElMessage.success('已创建并选中')
} catch {}
}
async function quickCreateTag(targetForm: () => void) {
try {
const { value } = await ElMessageBox.prompt('请输入标签名称', '新增标签', {
confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '标签名称',
})
if (!value.trim()) return
const res = await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } as any) as any
await reloadTags()
targetForm()
ElMessage.success('已创建并选中')
} 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>
<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>
<el-popover
v-model:visible="tagPopoverVisible"
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, 'is-active': tagPopoverVisible }"
>
<template v-if="selectedTagIds.length === 0">
<span class="placeholder">标签筛选</span>
</template>
<template v-else>
<el-tag
v-for="id in displayedSelectedTagIds"
:key="id"
size="small"
closable
type="info"
@close.stop="removeSelectedTag(id)"
>
{{ getTagName(id) }}
</el-tag>
<span v-if="hiddenSelectedCount > 0" class="more-tag">+{{ hiddenSelectedCount }}</span>
</template>
<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"
default-expand-all
:props="{ label: 'label', children: 'children' }"
@node-click="onTreeNodeClick"
>
<template #default="{ data }">
<div
class="tree-row"
:class="{
'is-group': data.type === 'group',
'is-tag': data.type === 'tag',
'is-disabled': data.disabled,
'is-checked': data.type === 'tag' && selectedTagIds.includes(data.rawId),
}"
@mouseenter="hoveredNodeId = data.id"
@mouseleave="hoveredNodeId = null"
>
<template v-if="data.type === 'group'">
<i class="fa-solid fa-folder node-icon" />
<span class="node-label">
{{ data.label }}
<span v-if="data.children && data.children.length" class="node-count">{{ data.children.length }}</span>
</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑分组" @click.stop="openGroupEdit(data)" />
</span>
</template>
<template v-else>
<el-checkbox
:model-value="selectedTagIds.includes(data.rawId)"
@change="toggleTagInSelection(data.rawId)"
@click.stop
/>
<span class="node-label">{{ data.label }}</span>
<span v-show="hoveredNodeId === data.id" class="node-actions">
<el-button text size="small" :icon="Edit" title="编辑标签" @click.stop="openTagEditFromFilterById(data.rawId)" />
</span>
</template>
</div>
</template>
</el-tree>
</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 (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"
: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" @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">{{ 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">原产品</div>
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
</div>
</div>
</div>
</template>
<span class="good-name">{{ data.goodName }}</span>
</el-tooltip>
</div>
<span class="good-actions" @click.stop>
<el-button 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.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">{{ 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
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"
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>
</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">
<div>
<div class="config-og-name">{{ configOG.goodName }}</div>
<div class="config-og-meta">SDS ID: {{ configOG.sdsGoodId }}<template v-if="configOG.goodPrice"> · 价格: ¥{{ configOG.goodPrice }}</template></div>
</div>
</div>
<el-form label-width="80px" style="margin-top: 16px">
<el-form-item label="国家">
<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>
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) configForm.value.countryId = latest.id })" />
</div>
<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' || !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="图片">
<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-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>
</el-form-item>
<el-form-item label="位置">
<el-select v-model="configForm.positionId" clearable placeholder="可选">
<el-option v-for="p in configPositions" :key="p.id" :label="`#${p.indexVal}`" :value="p.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
<el-button type="primary" :loading="configLoading" @click="handleConfigSubmit">确认配置</el-button>
</template>
</el-dialog>
<!-- Edit Good Modal (direct edit no separate detail step) -->
<el-dialog v-model="editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy-on-close>
<!-- Origin product reference -->
<div v-if="editGood?.originGood" class="edit-og-ref">
<el-image v-if="editGood.originGood.goodImage" :src="editGood.originGood.goodImage" fit="cover" class="edit-og-img" />
<div class="edit-og-meta">
<div class="edit-og-label">关联原产品</div>
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
<div class="edit-og-sub">SDS ID: {{ editGood.originGood.sdsGoodId }}<template v-if="editGood.originGood.goodPrice"> · ¥{{ editGood.originGood.goodPrice }}</template></div>
<div class="edit-og-status">
<el-tag :type="editGood.originGood.hasDetail ? 'success' : 'warning'" size="small">
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
</el-tag>
<span v-if="editGood.originGood.detailSyncedAt">
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
</span>
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
</div>
</div>
<el-button
type="primary"
plain
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="国家">
<div class="select-inline">
<el-select v-model="editForm.countryId" filterable>
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
<el-button text :icon="Plus" @click="quickCreateCountry(() => { const latest = allCountries.value[allCountries.value.length - 1]; if (latest) editForm.value.countryId = latest.id })" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader" :props="{ checkStrictly: true }" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<div class="select-inline">
<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>
<el-tabs v-if="editOriginDetail" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品编码">{{ editOriginDetail.productCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="英文名称">{{ editOriginDetail.englishName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生产周期">{{ editOriginDetail.productionCycleHours ?? '-' }} 小时</el-descriptions-item>
<el-descriptions-item label="净重">{{ editOriginDetail.minWeightG ?? '-' }} g</el-descriptions-item>
<el-descriptions-item label="生产工艺">{{ editOriginDetail.productionProcess || '-' }}</el-descriptions-item>
<el-descriptions-item label="材质">{{ editOriginDetail.materialDescription || '-' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane :label="`尺码表 (${editSizeRows.length})`">
<el-table :data="editSizeRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="100" fixed />
<el-table-column
v-for="column in editSizeColumns"
:key="column.key"
:prop="column.key"
:label="`${column.name} (cm)`"
min-width="120"
/>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`包装规格 (${editPackageRows.length})`">
<el-table :data="editPackageRows" border max-height="320">
<el-table-column prop="sizeName" label="尺码" width="90" fixed />
<el-table-column label="包装尺寸 (cm)" min-width="160">
<template #default="{ row }">
{{ row.dimensionsCm ? `${row.dimensionsCm.length}×${row.dimensionsCm.width}×${row.dimensionsCm.height}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="volumeCm3" label="体积 (cm³)" width="120" />
<el-table-column prop="grossWeightG" label="含包装重量 (g)" width="150" />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`SKU (${editVariants.length})`">
<el-table :data="editVariants" border max-height="320">
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
<template #footer>
<el-button type="danger" @click="editGood && handleDeleteGood(editGood)">删除</el-button>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="handleEditSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 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>
<!-- Tag Edit Modal -->
<el-dialog v-model="tagEditVisible" title="编辑标签" width="420px" destroy-on-close>
<el-form label-width="80px">
<el-form-item label="名称"><el-input v-model="tagEditForm.tagName" placeholder="标签名称" /></el-form-item>
<el-form-item label="背景色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagColor" />
<el-input v-model="tagEditForm.tagColor" placeholder="#ff6800" style="width: 120px" />
</div>
</el-form-item>
<el-form-item label="字体色">
<div style="display: flex; align-items: center; gap: 8px;">
<el-color-picker v-model="tagEditForm.tagFontColor" />
<el-input v-model="tagEditForm.tagFontColor" placeholder="#ffffff" style="width: 120px" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="tagEditLoading" @click="handleTagEditDelete">删除</el-button>
<el-button @click="tagEditVisible = false">取消</el-button>
<el-button type="primary" :loading="tagEditLoading" @click="handleTagEditSubmit">保存</el-button>
</template>
</el-dialog>
<!-- Group Edit Modal -->
<el-dialog
v-model="groupEditVisible"
:title="`编辑分组「${groupEditForm.groupName}」`"
width="420px"
destroy-on-close
>
<el-form label-width="80px">
<el-form-item label="分组名称">
<el-input v-model="groupEditForm.groupName" placeholder="请输入分组名称" />
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" :loading="groupEditLoading" @click="handleGroupDelete">删除</el-button>
<el-button @click="groupEditVisible = false">取消</el-button>
<el-button type="primary" :loading="groupEditLoading" @click="handleGroupSave">保存</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-col { flex: 1; min-width: 0; overflow: hidden; }
.good-name {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; line-height: 1.4; cursor: pointer;
}
.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--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; }
.edit-og-ref {
display: flex; align-items: center; gap: 12px;
padding: 12px; background: #f5f7fa; border-radius: 8px;
}
.edit-og-img { width: 48px; height: 48px; border-radius: 6px; flex-shrink: 0; }
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
.edit-og-meta { flex: 1; min-width: 0; }
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; }
/* Config modal */
.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; }
.config-og-name { font-weight: 600; font-size: 14px; }
.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; }
/* 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);
}
/* ─── Tag filter popover ─── */
/* Custom trigger that looks like el-select */
.tag-select-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
width: 180px;
min-height: 24px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 12px;
color: #606266;
cursor: pointer;
transition: border-color 0.2s;
box-sizing: border-box;
}
.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 .tag-arrow {
margin-left: auto;
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 .more-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
background: #f4f4f5;
border-radius: 3px;
font-size: 11px;
color: #606266;
}
/* Popover content */
.tag-filter-popper {
padding: 0 !important;
}
.tag-filter-popper .tag-tree-panel {
max-height: 380px;
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;
}
/* Tree row layout */
.tree-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 6px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
position: relative;
}
.tree-row.is-group {
font-weight: 600;
color: #111;
background: #fafafa;
}
.tree-row.is-group:hover {
background: #f0f0f0;
}
.tree-row.is-tag:hover {
background: #f5f7fa;
}
.tree-row.is-tag.is-checked {
background: #fff2e8;
color: #ff6a00;
}
.node-icon {
color: #f59e0b;
font-size: 12px;
width: 14px;
text-align: center;
flex-shrink: 0;
}
.node-label {
flex: 1;
user-select: none;
}
.node-count {
display: inline-block;
margin-left: 4px;
padding: 0 5px;
font-size: 10px;
color: #909399;
background: #e9e9eb;
border-radius: 8px;
font-weight: 400;
}
.node-edit-input {
flex: 1;
font-size: 13px;
border: 1px solid #ff6a00;
border-radius: 3px;
padding: 2px 6px;
outline: none;
font-weight: 600;
}
.node-actions {
display: inline-flex;
gap: 0;
margin-left: auto;
flex-shrink: 0;
}
.node-actions :deep(.el-button) {
padding: 2px 4px;
}
</style>