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

2656 lines
110 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, QuestionFilled,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
MergedOriginGoodSummary,
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 { sameOriginGroup } from '@/utils/origin-name'
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: 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) {
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,
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,
}))
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: 1000 } 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 configSiblings = ref<any[]>([])
const configChecked = ref<string[]>([])
const configPrimaryId = ref<string>('')
const checkedSiblingNodes = computed(() =>
configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))),
)
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) && !n.configuredCount) result.push(n)
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return result
}
function openConfigModal(og: any, dropTarget: any) {
configOG.value = og
configDropTarget.value = dropTarget
const ogNode = findOgNodeById(og.rawId)
const siblings = ogNode ? collectSiblings(ogNode) : []
configSiblings.value = siblings
configPrimaryId.value = String(og.rawId)
configChecked.value = siblings
.filter((s) => sameOriginGroup(ogNode?.goodName ?? og.goodName, s.goodName))
.map((s) => String(s.rawId))
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
}
}
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 onConfigCascaderChange(val: any) {
configForm.value.categoryId = val.length ? val[val.length - 1] : ''
}
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(configPrimaryId.value || configOG.value.rawId),
mergedOriginGoodIds: configChecked.value
.filter((id) => id !== configPrimaryId.value)
.map(Number),
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 }
}
// ─── Custom Good ───
const customVisible = ref(false)
const customLoading = ref(false)
const customForm = ref({
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[],
goodPriority: 0,
})
function openCustomCreate() {
customForm.value = {
goodName: '', goodImage: '', goodPrice: '', countryId: '',
cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0,
}
customVisible.value = true
}
function onCustomCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
customForm.value.categoryId = path.length ? String(path[path.length - 1]) : ''
}
async function handleCustomCreate() {
if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return }
if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return }
if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return }
customLoading.value = true
try {
const created = await goodsApi.createCustomGood({
goodName: customForm.value.goodName.trim(),
goodImage: customForm.value.goodImage || undefined,
goodPrice: customForm.value.goodPrice || null,
countryId: Number(customForm.value.countryId),
categoryId: Number(customForm.value.categoryId),
tagIds: customForm.value.tagIds.map(Number),
goodPriority: customForm.value.goodPriority,
detail: {},
})
ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU')
customVisible.value = false
await refreshLeftTree()
await openEdit(created)
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || '自定义商品创建失败')
} finally { customLoading.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 editMerged = ref<MergedOriginGoodSummary[]>([])
const editMergeSearch = ref('')
const originalOriginGoodId = ref<string>('')
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 ?? [])
const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true)
// 候选 = 右栏树全部 og,按搜索词过滤
const editMergeCandidates = computed(() => {
if (!editMergeSearch.value) return []
const kw = editMergeSearch.value
const result: any[] = []
function traverse(nodes: any[]) {
for (const n of nodes) {
if (n.isOG && (n.goodName ?? '').includes(kw)) result.push(n)
if (n.children?.length) traverse(n.children)
}
}
traverse(rightTreeData.value)
return result.filter(
(n) => String(n.rawId) !== String(editGood.value?.originGoodId)
&& !editMerged.value.some((m) => m.id === String(n.rawId)),
)
})
function addEditMerge(node: any) {
editMerged.value.push({
id: String(node.rawId), sdsGoodId: node.sdsGoodId,
goodName: node.goodName, goodImage: node.goodImage,
goodPrice: node.goodPrice, hasDetail: Boolean(node.hasDetail),
variantCount: node.variantCount ?? 0,
})
editMergeSearch.value = ''
}
function promoteMerged(m: MergedOriginGoodSummary) {
if (!editGood.value) return
const oldPrimaryId = editGood.value.originGoodId
const oldPrimary = editGood.value.originGood
editGood.value = {
...editGood.value,
originGoodId: m.id,
originGood: oldPrimary
? { ...oldPrimary, id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName }
: { id: m.id, sdsGoodId: m.sdsGoodId, goodName: m.goodName } as any,
}
editMerged.value = [
{ id: oldPrimaryId, sdsGoodId: oldPrimary?.sdsGoodId ?? '', goodName: oldPrimary?.goodName ?? null,
goodImage: oldPrimary?.goodImage ?? null, goodPrice: oldPrimary?.goodPrice ?? null,
hasDetail: oldPrimary?.hasDetail ?? false, variantCount: (oldPrimary as any)?.variantCount ?? 0 },
...editMerged.value.filter((x) => x.id !== m.id),
]
}
const customContentForm = ref({
goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined,
minWeightG: '', productionProcess: '', materialDescription: '',
blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '',
productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '',
designExplanation: '', designArea: '', pictureRequest: '',
sizeChartJson: '{\n "columns": [],\n "rows": []\n}',
packageSpecsJson: '{\n "rows": []\n}',
optionsJson: '{}',
mediaJson: '{}',
variants: [] as Array<{
sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string;
price: string; originalPrice: string; weightG: string; boxLengthCm: string;
boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean
}>,
})
function fillCustomContent(g: GoodDetail) {
const detail = g.originDetail ?? {}
customContentForm.value = {
goodPrice: g.originGood?.goodPrice ?? '',
productCode: String(detail.productCode ?? ''),
englishName: String(detail.englishName ?? ''),
productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours),
minWeightG: String(detail.minWeightG ?? ''),
productionProcess: String(detail.productionProcess ?? ''),
materialDescription: String(detail.materialDescription ?? ''),
blankDesignUrl: String(detail.blankDesignUrl ?? ''),
detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''),
textureName: String(detail.textureName ?? ''),
reminder: String(detail.reminder ?? ''),
productPerformance: String(detail.productPerformance ?? ''),
applicableScenarios: String(detail.applicableScenarios ?? ''),
washingInstructions: String(detail.washingInstructions ?? ''),
specialDescription: String(detail.specialDescription ?? ''),
designExplanation: String(detail.designExplanation ?? ''),
designArea: String(detail.designArea ?? ''),
pictureRequest: String(detail.pictureRequest ?? ''),
sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2),
packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2),
optionsJson: JSON.stringify(detail.options ?? {}, null, 2),
mediaJson: JSON.stringify(detail.media ?? {}, null, 2),
variants: g.variants.map((variant) => ({
sku: variant.sku,
sizeId: String(variant.sizeId ?? ''),
sizeName: String(variant.sizeName ?? ''),
colorId: String(variant.colorId ?? ''),
colorName: String(variant.colorName ?? ''),
colorHex: String(variant.colorHex ?? ''),
imageUrl: String(variant.imageUrl ?? ''),
price: String(variant.price ?? ''),
originalPrice: String(variant.originalPrice ?? ''),
weightG: String(variant.weightG ?? ''),
boxLengthCm: String(variant.boxLengthCm ?? ''),
boxWidthCm: String(variant.boxWidthCm ?? ''),
boxHeightCm: String(variant.boxHeightCm ?? ''),
designDataJson: JSON.stringify(variant.designData ?? {}, null, 2),
enabled: variant.enabled,
})),
}
}
function addCustomVariant() {
customContentForm.value.variants.push({
sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '',
originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true,
})
}
async function openEdit(g: Good) {
editGood.value = g
editMerged.value = (g.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
originalOriginGoodId.value = g.originGoodId
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 {
const detail = await goodsApi.getGoodById(g.id)
editGood.value = detail
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
if (detail.originGood?.isCustom) fillCustomContent(detail)
} 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() {
if (editIsCustom.value) return
const goodId = editGood.value?.originGood?.sdsGoodId
if (!goodId) return
detailSyncing.value = true
try {
const result = await syncApi.syncOneProductDetail(goodId)
const detail = await goodsApi.getGoodById(editGood.value!.id)
editGood.value = detail
editMerged.value = (detail.mergedOriginGoods as MergedOriginGoodSummary[]) ?? []
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() {
let customPayload: any = null
if (editIsCustom.value) {
let sizeChart: Record<string, unknown>
let packageSpecs: Record<string, unknown>
let options: Record<string, unknown>
let media: Record<string, unknown>
try {
sizeChart = JSON.parse(customContentForm.value.sizeChartJson)
packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson)
options = JSON.parse(customContentForm.value.optionsJson)
media = JSON.parse(customContentForm.value.mediaJson)
for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson)
} catch {
ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON')
return
}
if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) {
ElMessage.error('SKU 不能为空')
return
}
customPayload = {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
goodPrice: customContentForm.value.goodPrice || null,
detail: {
productCode: customContentForm.value.productCode || null,
englishName: customContentForm.value.englishName || null,
productionCycleHours: customContentForm.value.productionCycleHours ?? null,
minWeightG: customContentForm.value.minWeightG || null,
productionProcess: customContentForm.value.productionProcess || null,
materialDescription: customContentForm.value.materialDescription || null,
blankDesignUrl: customContentForm.value.blankDesignUrl || null,
detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null,
textureName: customContentForm.value.textureName || null,
reminder: customContentForm.value.reminder || null,
productPerformance: customContentForm.value.productPerformance || null,
applicableScenarios: customContentForm.value.applicableScenarios || null,
washingInstructions: customContentForm.value.washingInstructions || null,
specialDescription: customContentForm.value.specialDescription || null,
designExplanation: customContentForm.value.designExplanation || null,
designArea: customContentForm.value.designArea || null,
pictureRequest: customContentForm.value.pictureRequest || null,
sizeChart,
packageSpecs,
options,
media,
},
variants: customContentForm.value.variants.map((variant: any, index: number) => ({
sku: variant.sku.trim(),
sizeId: variant.sizeId || null,
sizeName: variant.sizeName || null,
colorId: variant.colorId || null,
colorName: variant.colorName || null,
colorHex: variant.colorHex || null,
imageUrl: variant.imageUrl || null,
price: variant.price || null,
originalPrice: variant.originalPrice || null,
weightG: variant.weightG || null,
boxLengthCm: variant.boxLengthCm || null,
boxWidthCm: variant.boxWidthCm || null,
boxHeightCm: variant.boxHeightCm || null,
designData: JSON.parse(variant.designDataJson),
enabled: variant.enabled,
sortOrder: index,
})),
}
}
editLoading.value = true
try {
await goodsApi.updateGood(editForm.value.id, {
goodName: editForm.value.goodName,
goodImage: editForm.value.goodImage || null,
originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value
? Number(editGood.value!.originGoodId)
: undefined,
mergedOriginGoodIds: editIsCustom.value ? undefined : editMerged.value.map((m) => Number(m.id)),
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)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
editVisible.value = false
refreshLeftTree()
refreshRightTree()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '更新失败')
} finally { editLoading.value = false }
}
function onEditCascaderChange(val: any) {
const path = Array.isArray(val) ? val : []
editForm.value.categoryId = path.length ? String(path[path.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: TreeNode[] = 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' as const,
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
await countriesApi.createCountry({ countryName: value.trim() } 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
await tagsApi.createTag({ tagName: value.trim(), tagColor: '#ff6800' } 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>
<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"
: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">{{ data.isCustom ? '来源' : '原产品' }}</div>
<div class="gt-val gt-val-ellipsis">{{ data.originGoodName }}</div>
</div>
</div>
</div>
</template>
<span class="good-name">{{ 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">{{ 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>
<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[allCountries.length - 1]; if (latest) configForm.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 as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item v-if="configSiblings.length" label="合并同名">
<div class="config-merge-box">
<div class="config-merge-tip">勾选同分类下同名不同工厂/仓库原产品合并为一个商品主源决定价格与详情</div>
<div class="config-merge-primary">
<el-radio-group v-model="configPrimaryId">
<el-radio :value="String(configOG.rawId)">主源{{ configOG.goodName }}</el-radio>
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
</el-radio-group>
</div>
<el-checkbox-group v-model="configChecked">
<el-checkbox v-for="s in configSiblings" :key="s.rawId" :value="String(s.rawId)">
{{ s.goodName }}<template v-if="s.goodPrice"> · ¥{{ s.goodPrice }}</template>
</el-checkbox>
</el-checkbox-group>
</div>
</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[allTags.length - 1]; if (latest) configForm.tagIds.push(latest.id) })" />
</div>
</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>
<!-- Custom Good Modal -->
<el-dialog v-model="customVisible" title="新增自定义商品" width="620px" destroy-on-close>
<el-alert
title="自定义商品不关联 SDS 原产品,名称、图片、价格、详情、尺码、包装和 SKU 均可维护。"
type="info"
:closable="false"
style="margin-bottom:16px"
/>
<el-form label-width="90px">
<el-form-item label="商品名称" required><el-input v-model="customForm.goodName" /></el-form-item>
<el-form-item label="商品图片"><ImageUpload v-model="customForm.goodImage" label="上传图片" /></el-form-item>
<el-form-item label="基础价格"><el-input v-model="customForm.goodPrice" placeholder="例如 28.00" /></el-form-item>
<el-form-item label="国家" required>
<el-select v-model="customForm.countryId" filterable placeholder="请选择国家" style="width:100%">
<el-option v-for="c in allCountries" :key="c.id" :label="c.countryName" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="分类" required>
<el-cascader v-model="customForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" style="width:100%" @change="onCustomCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="customForm.tagIds" multiple filterable placeholder="请选择标签" style="width:100%">
<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-form-item>
<el-form-item label="优先级"><el-input-number v-model="customForm.goodPriority" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="customVisible = false">取消</el-button>
<el-button type="primary" :loading="customLoading" @click="handleCustomCreate">创建并完善详情</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">{{ editIsCustom ? '自定义商品' : '关联原产品' }}</div>
<div class="edit-og-name">{{ editGood.originGood.goodName }}</div>
<div class="edit-og-sub">{{ editIsCustom ? '自定义 ID' : '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
v-if="!editIsCustom"
type="primary"
plain
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">
关联原产品主源 + 副源
<el-tooltip content="主源决定价格、详情与上下架;副源变体合并展示。切换主源后旧主源自动转为副源。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="edit-merged-list">
<div class="edit-merged-item primary">
<span class="edit-merged-tag"></span>
<span>{{ editGood?.originGood?.goodName }}</span>
</div>
<div v-for="m in editMerged" :key="m.id" class="edit-merged-item">
<span class="edit-merged-tag sub"></span>
<span>{{ m.goodName }}</span>
<el-button size="small" link type="primary" @click="promoteMerged(m)">设为主源</el-button>
<el-button size="small" link type="danger" @click="editMerged = editMerged.filter(x => x.id !== m.id)">移除</el-button>
</div>
</div>
<el-select v-model="editMergeSearch" filterable remote :remote-method="(q: string) => editMergeSearch = q"
placeholder="搜索原产品名称以添加副源(用于合并同名商品)" clearable style="width:100%">
<el-option v-for="c in editMergeCandidates" :key="c.rawId" :label="c.goodName" :value="String(c.rawId)"
@click="addEditMerge(c)" />
</el-select>
</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 placeholder="请选择国家">
<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[allCountries.length - 1]; if (latest) editForm.countryId = latest.id })" />
</div>
</el-form-item>
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @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[allTags.length - 1]; if (latest) editForm.tagIds.push(latest.id) })" />
</div>
</el-form-item>
<el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item>
</el-form>
<el-tabs v-if="editOriginDetail" class="detail-tabs">
<el-tab-pane label="商品详情">
<el-form v-if="editIsCustom" label-width="100px" class="custom-detail-form">
<el-form-item label="商品编码"><el-input v-model="customContentForm.productCode" /></el-form-item>
<el-form-item label="英文名称"><el-input v-model="customContentForm.englishName" /></el-form-item>
<el-form-item label="生产周期"><el-input-number v-model="customContentForm.productionCycleHours" :min="0" /><span style="margin-left:8px">小时</span></el-form-item>
<el-form-item label="净重"><el-input v-model="customContentForm.minWeightG"><template #append>g</template></el-input></el-form-item>
<el-form-item label="生产工艺"><el-input v-model="customContentForm.productionProcess" type="textarea" /></el-form-item>
<el-form-item label="材质"><el-input v-model="customContentForm.materialDescription" type="textarea" /></el-form-item>
<el-form-item label="空白设计图"><el-input v-model="customContentForm.blankDesignUrl" /></el-form-item>
<el-form-item label="详情视频"><el-input v-model="customContentForm.detailsPageVideoUrl" /></el-form-item>
<el-form-item label="面料名称"><el-input v-model="customContentForm.textureName" /></el-form-item>
<el-form-item label="温馨提示"><el-input v-model="customContentForm.reminder" type="textarea" /></el-form-item>
<el-form-item label="产品性能"><el-input v-model="customContentForm.productPerformance" type="textarea" /></el-form-item>
<el-form-item label="适用场景"><el-input v-model="customContentForm.applicableScenarios" type="textarea" /></el-form-item>
<el-form-item label="洗涤说明"><el-input v-model="customContentForm.washingInstructions" type="textarea" /></el-form-item>
<el-form-item label="特殊说明"><el-input v-model="customContentForm.specialDescription" type="textarea" /></el-form-item>
<el-form-item label="设计说明"><el-input v-model="customContentForm.designExplanation" type="textarea" /></el-form-item>
<el-form-item label="设计区域"><el-input v-model="customContentForm.designArea" /></el-form-item>
<el-form-item label="图片要求"><el-input v-model="customContentForm.pictureRequest" type="textarea" /></el-form-item>
<el-form-item label="商品选项 JSON"><el-input v-model="customContentForm.optionsJson" type="textarea" :rows="6" /></el-form-item>
<el-form-item label="媒体数据 JSON"><el-input v-model="customContentForm.mediaJson" type="textarea" :rows="6" /></el-form-item>
</el-form>
<el-descriptions v-else :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-input v-if="editIsCustom" v-model="customContentForm.sizeChartJson" type="textarea" :rows="14" placeholder="尺码表 JSON" />
<el-table v-else :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-input v-if="editIsCustom" v-model="customContentForm.packageSpecsJson" type="textarea" :rows="14" placeholder="包装规格 JSON" />
<el-table v-else :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})`">
<template v-if="editIsCustom">
<div style="display:flex;justify-content:flex-end;margin-bottom:8px"><el-button size="small" :icon="Plus" @click="addCustomVariant">添加 SKU</el-button></div>
<el-table :data="customContentForm.variants" border max-height="320">
<el-table-column label="SKU" min-width="160"><template #default="{ row }"><el-input v-model="row.sku" /></template></el-table-column>
<el-table-column label="尺码 ID" width="120"><template #default="{ row }"><el-input v-model="row.sizeId" /></template></el-table-column>
<el-table-column label="尺码" width="120"><template #default="{ row }"><el-input v-model="row.sizeName" /></template></el-table-column>
<el-table-column label="颜色 ID" width="120"><template #default="{ row }"><el-input v-model="row.colorId" /></template></el-table-column>
<el-table-column label="颜色" width="120"><template #default="{ row }"><el-input v-model="row.colorName" /></template></el-table-column>
<el-table-column label="色值" width="120"><template #default="{ row }"><el-input v-model="row.colorHex" placeholder="#FFFFFF" /></template></el-table-column>
<el-table-column label="图片" min-width="180"><template #default="{ row }"><el-input v-model="row.imageUrl" /></template></el-table-column>
<el-table-column label="价格" width="120"><template #default="{ row }"><el-input v-model="row.price" /></template></el-table-column>
<el-table-column label="原价" width="120"><template #default="{ row }"><el-input v-model="row.originalPrice" /></template></el-table-column>
<el-table-column label="重量(g)" width="120"><template #default="{ row }"><el-input v-model="row.weightG" /></template></el-table-column>
<el-table-column label="包装长(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxLengthCm" /></template></el-table-column>
<el-table-column label="包装宽(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxWidthCm" /></template></el-table-column>
<el-table-column label="包装高(cm)" width="130"><template #default="{ row }"><el-input v-model="row.boxHeightCm" /></template></el-table-column>
<el-table-column label="设计数据 JSON" min-width="220"><template #default="{ row }"><el-input v-model="row.designDataJson" type="textarea" :rows="2" /></template></el-table-column>
<el-table-column label="启用" width="80"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table>
</template>
<el-table v-else :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 {
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--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; }
.edit-merged-box { margin-top: 12px; }
.edit-merged-title {
display: flex; align-items: center; gap: 4px;
font-size: 12px; color: #909399; margin-bottom: 8px;
}
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px;
}
.edit-merged-item .el-button { margin-left: auto; }
.edit-merged-tag {
padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px;
background: var(--el-color-primary); color: #fff; flex-shrink: 0;
}
.edit-merged-tag.sub { background: var(--el-color-info); }
.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; }
.config-merge-box { width: 100%; }
.config-merge-tip { color: #909399; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
.config-merge-primary {
padding: 8px 10px; background: #f5f7fa; border-radius: 6px; margin-bottom: 8px;
}
.config-merge-primary .el-radio-group { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 160px; overflow-y: auto; }
/* 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>