@@ -214,12 +214,32 @@ const filteredGoods = computed(() => {
return sorted
} )
/** 左树一族只显示一行:同款(同 familyId)多条配置保留代表行(排序最前),
* 其余配置计数挂在 configCount,悬浮徽标可查看并打开 */
function distinctByFamily ( nodes : any [ ] ) : any [ ] {
const seen = new Set < string > ( )
return nodes . map ( n => {
const fid = n . raw ? . originGood ? . family ? . familyId
? String ( n . raw . originGood . family . familyId )
: null
if ( ! fid ) return n
if ( seen . has ( fid ) ) return null
seen . add ( fid )
const siblings = nodes . filter ( m =>
String ( m . raw ? . originGood ? . family ? . familyId ? ? '' ) === fid )
n . configCount = siblings . length
n . familyConfigs = siblings
return n
} ) . filter ( Boolean )
}
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 )
const children = [ ... childNodes , ... distinctByFamily ( myGoods ) ]
const goodCount = children . reduce ( ( sum : number , c : any ) =>
sum + ( c . isGood ? 1 + ( c . configCount ? c . configCount - 1 : 0 ) : ( c . goodCount ? ? 0 ) ) , 0 )
return {
id : n . id ,
label : n . categoryName ,
@@ -247,7 +267,7 @@ const leftTreeData = computed(() => {
isCat : true ,
raw : c ,
goodCount : cGoods . length ,
children : cGoods . map ( goodToNode ) ,
children : distinctByFamily ( cGoods . map ( goodToNode ) ) ,
}
} )
}
@@ -398,18 +418,26 @@ function findOgNodeById(rawId: string | number): any {
return found
}
/** 链接名的款号段(第 2 段,如 BRTF004);同款号 = 同款 */
function skuCodeOf ( name : string | null | undefined ) : string {
return ( name ? ? '' ) . split ( '-' ) . map ( s => s . trim ( ) ) [ 1 ] ? ? ''
}
function collectSiblings ( ogNode : any ) : any [ ] {
const result : any [ ] = [ ]
const myFamily = ogNode . familyId ? String ( ogNode . familyId ) : null
const mySku = skuCodeOf ( ogNode . goodName ? ? ogNode . label )
function traverse ( nodes : any [ ] ) {
for ( const n of nodes ) {
// 全局同款链接(跨分类):合并同名按名称组键匹配,
// 不局限于当前分类; 含已配置的,勾选归族操作幂等
if (
n . isOG &&
String ( n . rawId ) !== String ( ogNode . rawId ) &&
sameOriginGroup ( ogNode . goodName ? ? ogNode . label , n . goodName ? ? n . label )
) {
result . push ( n )
// 全局同款链接(跨分类/跨工艺/跨物流):族归属精确匹配,或款号一致;
// 含已配置的,勾选归族操作幂等
if ( n . isOG && String ( n . rawId ) !== String ( ogNode . rawId ) ) {
const nFamily = n . familyId ? String ( n . familyId ) : null
const nSku = skuCodeOf ( n . goodName ? ? n . label )
const same = ( myFamily && nFamily && myFamily === nFamily )
|| ( Boolean ( mySku ) && Boolean ( nSku ) && mySku === nSku )
|| ( ! mySku && sameOriginGroup ( ogNode . goodName ? ? ogNode . label , n . goodName ? ? n . label ) )
if ( same ) result . push ( n )
}
if ( n . children ? . length ) traverse ( n . children )
}
@@ -420,11 +448,6 @@ function collectSiblings(ogNode: any): any[] {
function openConfigModal ( og : any , dropTarget : any ) {
const ogNode = findOgNodeById ( og . rawId )
// 公开契约一族一款:族已配置后再配置成员只会产出官网不可见的重复商品
if ( ( ogNode ? . familyConfiguredCount ? ? 0 ) > 0 || ( ogNode ? . configuredCount ? ? 0 ) > 0 ) {
ElMessage . info ( '该链接所属族已配置为官网商品,无需重复配置' )
return
}
configOG . value = { ... og , familyId : og . familyId ? ? ogNode ? . familyId ? ? null }
configDropTarget . value = dropTarget
configSiblings . value = ogNode ? collectSiblings ( ogNode ) : [ ]
@@ -575,6 +598,12 @@ function locateInLeftTree(originGoodId: string) {
const node = tree . getNode ( keyId )
if ( node ) node . expanded = true
}
// 族折叠后商品嵌套在族组节点下:沿父链全部展开(两种模式通用)
let anc = tree . getNode ( targetKey ) ? . parent as any
while ( anc ? . data ) {
anc . expanded = true
anc = anc . parent
}
setTimeout ( ( ) => {
tree . setCurrentKey ( targetKey )
// 闪烁高亮定位行(2.4s 后自动消失)
@@ -703,6 +732,13 @@ function onTreeGoodDragLeave(_event: DragEvent, data: any) {
if ( treeDragOver . value ? . id === data . goodId ) treeDragOver . value = null
}
/** 国家分组内:找商品所在容器数组(即国家根 children) */
function findContainingArray ( group : any , goodId : string ) : any [ ] | null {
return ( group . children ? ? [ ] ) . some ( ( c : any ) => c . isGood && c . goodId === goodId )
? group . children
: null
}
async function onTreeGoodDrop ( event : DragEvent , data : any ) {
event . preventDefault ( )
event . stopPropagation ( )
@@ -713,14 +749,12 @@ async function onTreeGoodDrop(event: DragEvent, data: any) {
if ( mode . value !== 'country' || ! draggedId || ! target ) return
if ( target . id !== data . goodId || draggedId === data . goodId ) return
// 目标所在国家分组的商品(新顺序 )
const group = leftTreeData . value . find ( ( c : any ) =>
( c . children ? ? [ ] ) . some ( ( g : any ) => g . goodId === target . id ) ,
)
// 目标所在国家分组(跨国拖动不支持:商品归属固定国家 )
const group = leftTreeData . value . find ( ( c : any ) => findContainingArray ( c , target . id ) )
if ( ! group ) return
const nodes = [ ... group . children ]
const fromIdx = nodes . findIndex ( n => n . goodId === draggedId )
if ( fromIdx === - 1 ) return // 跨国拖动不支持(商品归属固定国家)
if ( fromIdx === - 1 ) return
const [ moved ] = nodes . splice ( fromIdx , 1 )
let insertIdx = nodes . findIndex ( n => n . goodId === target . id )
if ( target . pos === 'after' ) insertIdx ++
@@ -972,6 +1006,11 @@ onMounted(() => loadAll())
< div class = "gv-root" v-loading = "loading" >
< ! - - Filter Bar - - >
< div class = "gv-filter" >
< 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 >
< 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 >
@@ -996,14 +1035,8 @@ onMounted(() => loadAll())
< 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 >
< el-button size = "small" type = "success" :icon = "Plus" @click ="openCustomCreate" > 新增自定义商品 < / el -button >
< / div >
<!-- Dual Tree ( hidden in global mode ) -- >
@@ -1103,6 +1136,24 @@ onMounted(() => loadAll())
< span class = "good-name" :title = "data.goodName" > { { cleanLinkName ( data . goodName ) } } < / span >
< span v-if = "data.mergedCount > 1" class="gv-merged-badge" > × {{ data.mergedCount }} < / span >
< / el -tooltip >
< el-popover
v-if = "data.configCount > 1"
placement = "bottom-start"
:width = "380"
trigger = "click"
popper -class = " cfg -pop -popper "
>
< template # reference >
< span class = "cfg-badge" : title = "`同款共 ${data.configCount} 个配置`" @click.stop > {{ data.configCount }} 个配置 < / span >
< / template >
< div class = "cfg-pop-list" >
< div v-for = "c in (data.familyConfigs || [])" :key="c.goodId" class="cfg-pop-row" >
< span class = "cfg-pop-name" :title = "c.goodName" > { { cleanLinkName ( c . goodName ) } } < / span >
< span class = "cfg-pop-sub" > { { c . country } } { { c . raw ? . category ? . categoryName ? ' · ' + c . raw . category . categoryName : '' } } < / span >
< el-button size = "small" link type = "primary" @click.stop ="openEdit(c.raw)" > 打开 < / el -button >
< / div >
< / div >
< / el-popover >
< / div >
< span class = "good-actions" @click.stop >
< el -button v-if = "!data.isCustom" size="small" link :icon="Aim" title="定位原产品" @click="locateInRightTree(data.originGoodId)" / >
@@ -1155,7 +1206,7 @@ onMounted(() => loadAll())
:expand-on-click-node = "true"
>
< template # default = "{ data }" >
< div v-if = "data.isOG" class="og-node" draggable="true" @dragstart="onOGDragStart($event, data)" >
< div v-if = "data.isOG" class="og-node" draggable="true" title="拖到左侧分类/国家可配置为官网商品" @dragstart="onOGDragStart($event, data)" >
< el -image v-if = "data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" / >
< div v-else class = "og-thumb og-thumb-placeholder" / >
< span class = "og-name" :title = "data.goodName" > { { data . label } } < / span >
@@ -1200,8 +1251,9 @@ onMounted(() => loadAll())
@click.stop ="locateInLeftTree(data.rawId)"
/ >
< el-button
v-if = "!data.configuredCount && !data.familyConfiguredCount "
v-if = "!data.configuredCount"
size = "small" type = "primary" link class = "og-config-btn"
title = "配置为官网商品(可勾选同款链接归入同一族)"
@click.stop ="openConfigFromRightTree(data)"
> 配置 < / el-button >
< / div >
@@ -1490,6 +1542,23 @@ onMounted(() => loadAll())
. good - node . is - dragging { opacity : 0.5 ; }
. good - node . is - drop - before { box - shadow : inset 0 2 px 0 var ( -- el - color - primary ) ; }
. good - node . is - drop - after { box - shadow : inset 0 - 2 px 0 var ( -- el - color - primary ) ; }
. cfg - badge {
font - size : 11 px ; line - height : 1 ; padding : 3 px 7 px ; border - radius : 8 px ;
color : var ( -- el - color - primary ) ;
background : var ( -- el - color - primary - light - 9 ) ;
flex - shrink : 0 ; white - space : nowrap ; cursor : pointer ;
}
. cfg - pop - list { display : flex ; flex - direction : column ; gap : 6 px ; max - height : 280 px ; overflow : auto ; }
. cfg - pop - row {
display : flex ; align - items : center ; gap : 8 px ;
padding : 4 px 6 px ; border - radius : 6 px ; background : # fafafa ;
}
. cfg - pop - row : hover { background : # f0f2f5 ; }
. cfg - pop - name {
flex - shrink : 0 ; max - width : 200 px ; overflow : hidden ;
text - overflow : ellipsis ; white - space : nowrap ; font - size : 12 px ; color : # 303133 ;
}
. cfg - pop - sub { flex : 1 ; font - size : 11 px ; color : # 909399 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. good - thumb {
width : 36 px ; height : 36 px ; border - radius : 6 px ; flex - shrink : 0 ;
object - fit : cover ;
@@ -1505,7 +1574,14 @@ onMounted(() => loadAll())
font - size : 13 px ; line - height : 1.4 ; cursor : pointer ;
max - width : calc ( 100 % - 40 px ) ;
}
. good - name - col { flex : 1 ; min - width : 0 ; overflow : hidden ; }
. good - name - col {
flex : 1 ; min - width : 0 ; overflow : hidden ;
display : flex ; align - items : center ; gap : 6 px ;
}
. good - name - col > . el - tooltip _ _trigger {
min - width : 0 ; overflow : hidden ;
display : inline - flex ; align - items : center ; gap : 4 px ;
}
. gv - merged - badge {
margin - left : 4 px ;
padding : 0 5 px ;
@@ -1526,7 +1602,7 @@ onMounted(() => loadAll())
overflow : hidden ;
}
. good - country {
font - size : 11 px ; color : # 909399 ; line - height : 1 ;
font - size : 11 px ; color : # 606266 ; line - height : 1 ;
background : # f5f7fa ; padding : 2 px 6 px ; border - radius : 8 px ;
flex - shrink : 0 ; white - space : nowrap ;
}
@@ -1536,9 +1612,9 @@ onMounted(() => loadAll())
flex - shrink : 0 ; white - space : nowrap ;
}
. good - tag {
font - size : 11 px ; color : # 606266 ; line - height : 1 ;
font - size : 11 px ; color : # 454545 ; line - height : 1 ;
padding : 2 px 6 px 2 px 12 px ; border - radius : 8 px ;
background : # fafafa ; position : relative ; flex - shrink : 0 ;
background : # f5f7fa ; position : relative ; flex - shrink : 0 ;
white - space : nowrap ; overflow : hidden ; text - overflow : ellipsis ; max - width : 80 px ;
}
. good - tag : : before {