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