@@ -18,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups'
import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync'
import { sameFamily } from '@/utils/family-match'
import { truncateToProcess } from '@/utils/origin-name'
import { cleanLinkName , deriveLinkTagNames , truncateToProcess } from '@/utils/origin-name'
import { productFamiliesApi } from '@/api/product-families'
const mode = ref < 'category' | 'country' | 'global' > ( 'category' )
@@ -182,7 +182,9 @@ function goodToNode(g: Good): any {
const filteredGoods = computed ( ( ) => {
let result = allGoods . value
if ( searchKeyword . value ) {
result = result . filter ( g => g . goodName ? . includes ( searchKeyword . value ) )
const kw = searchKeyword . value
// 同时匹配原始链接名与解析后的「品名 型号」展示名
result = result . filter ( g => g . goodName ? . includes ( kw ) || cleanLinkName ( g . goodName ) . includes ( kw ) )
}
if ( selectedCountryIds . value . length ) {
result = result . filter ( g => selectedCountryIds . value . includes ( g . countryId ) )
@@ -289,13 +291,32 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
children : [ ... children , ... goods ] ,
}
}
rightTreeData . value = tree . tree . map ( mapCat )
const roots = tree . tree . map ( mapCat )
// 已配置数按族计入:链接成族(或已配置商品)即视为已配置,后序汇总到各级分类
function fillConfiguredEff ( nodes : any [ ] ) : number {
let count = 0
for ( const n of nodes ) {
if ( n . isOG ) {
n . configuredEff = n . configuredCount > 0 || n . familyId ? 1 : 0
} else {
n . configuredEff = fillConfiguredEff ( n . children ? ? [ ] )
}
count += n . configuredEff
}
return count
}
fillConfiguredEff ( roots )
rightTreeData . value = roots
}
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
// 「仅未配置」同样把族内链接视为已配置
if ( showUnconfiguredOnly . value && ( data . configuredCount > 0 || data . familyId ) ) return false
if ( searchKeyword . value ) {
const kw = searchKeyword . value
if ( ! data . label . includes ( kw ) && ! cleanLinkName ( data . label ) . includes ( kw ) ) return false
}
return true
}
return true
@@ -930,8 +951,17 @@ function locateInRightTree(originGoodId: string) {
} , 250 )
}
/** 右侧定位到左侧后短暂闪烁的商品 id(用于行级高亮动画) */
const locateFlashGoodId = ref < string > ( '' )
function locateInLeftTree ( originGoodId : string ) {
const good = allGoods . value . find ( g => g . originGoodId === originGoodId )
// 链接 → 商品:优先精确匹配该链接配置的商品,其次同族商品(族内任一链接都可定位)
const ogNode = findOgNodeById ( originGoodId )
const familyId = ogNode ? . familyId ? ? null
const good = allGoods . value . find ( g => String ( g . originGoodId ) === String ( originGoodId ) )
? ? ( familyId
? allGoods . value . find ( g => g . originGood ? . family ? . familyId === familyId )
: null )
if ( ! good ) {
ElMessage . warning ( '该原产品尚未配置到官网' )
return
@@ -958,6 +988,9 @@ function locateInLeftTree(originGoodId: string) {
}
setTimeout ( ( ) => {
tree . setCurrentKey ( targetKey )
// 闪烁高亮定位行(2.4s 后自动消失)
locateFlashGoodId . value = good . id
setTimeout ( ( ) => { if ( locateFlashGoodId . value === good . id ) locateFlashGoodId . value = '' } , 2400 )
nextTick ( ( ) => {
const el = document . querySelector ( '.gv-left .el-tree-node.is-current' ) as HTMLElement
el ? . scrollIntoView ( { behavior : 'smooth' , block : 'center' } )
@@ -1293,27 +1326,14 @@ const groupedTagOptions = computed(() => {
return groups
} )
// ─── 族 派生标签:物流/工艺/位置组由族自动同步 ,表单中只读 ───
const isAutoTagGroup = ( name : string ) => / 物流 | 工艺 | 位置 / . test ( name )
// ─── 派生标签:物流/工艺/印花数量组由系统按链接名称自动生成 ,表单中只读 ───
const isAutoTagGroup = ( name : string ) => / 物流 | 工艺 | 位置 | 印花数量 / . test ( name )
const autoTagGroupIds = computed ( ( ) =>
new Set ( allTagGroups . value . filter ( ( g ) => isAutoTagGroup ( g . groupName ) ) . map ( ( g ) => g . id ) ) ,
)
const configHasFamily = computed ( ( ) => Boolean ( ( configOG . value as any ) ? . familyId ) )
const configTagOptions = computed ( ( ) =>
configHasFamily . value
? groupedTagOptions . value . filter ( ( g ) => g . id === 'ungrouped' || ! autoTagGroupIds . value . has ( g . id as any ) )
: groupedTagOptions . value ,
)
const editTagOptions = computed ( ( ) =>
editFamilyId . value
? groupedTagOptions . value . filter ( ( g ) => g . id === 'ungrouped' || ! autoTagGroupIds . value . has ( g . id as any ) )
: groupedTagOptions . value ,
)
/** 当前商品的自动组标签(只读展示) */
const editDerivedTags = computed ( ( ) =>
editFamilyId . value
? ( ( editGood . value as any ) ? . tags ? ? [ ] ) . filter ( ( t : any ) => autoTagGroupIds . value . has ( t . tagGroupId ) )
: [ ] ,
/** 当前商品的自动组标签(只读展示,来自其链接名称的解析结果) */
const editAutoTags = computed ( ( ) =>
( ( editGood . value as any ) ? . tags ? ? [ ] ) . filter ( ( t : any ) => autoTagGroupIds . value . has ( t . tagGroupId ) ) ,
)
function toggleTagInSelection ( tagId : string ) : void {
@@ -1370,19 +1390,6 @@ async function quickCreateCountry(targetForm: () => void) {
} 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 ( '请输入分组名称' , '新建分组' , {
@@ -1662,6 +1669,7 @@ onMounted(() => loadAll())
:data = "leftTreeData"
:props = "treeProps"
node -key = " id "
highlight -current
: draggable = "mode === 'category'"
:expand-on-click-node = "true"
@ node -drag -end = " onLeftTreeDragEnd "
@@ -1684,7 +1692,12 @@ onMounted(() => loadAll())
< / span >
< / div >
<!-- Good node : two - line with meta -- >
< div v-else-if = "data.isGood" class="good-node" @click="openEdit(data.raw)" >
< div
v-else-if = "data.isGood"
class = "good-node"
: class = "{ 'good-node--flash': data.goodId === locateFlashGoodId }"
@click ="openEdit(data.raw)"
>
< el-image v-if = "data.goodImage" :src="data.goodImage" fit="cover" class="good-thumb" / >
< div v-else class = "good-thumb-placeholder" / >
< div class = "good-body" >
@@ -1697,7 +1710,7 @@ onMounted(() => loadAll())
< 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-title" > { { cleanLinkName ( data . goodName ) } } < / div >
< div class = "gt-country" > { { data . country } } < / div >
< / div >
< / div >
@@ -1715,12 +1728,12 @@ onMounted(() => loadAll())
< / 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 class = "gt-val gt-val-ellipsis" > { { cleanLinkName ( data . originGoodName ) } } < / div >
< / div >
< / div >
< / div >
< / template >
< span class = "good-name" > { { data . goodName } } < / span >
< span class = "good-name" :title = "data.goodName" > { { cleanLinkName ( data . goodName ) } } < / span >
< span v-if = "data.mergedCount > 1" class="gv-merged-badge" > × {{ data.mergedCount }} < / span >
< / el -tooltip >
< / div >
@@ -1778,11 +1791,11 @@ onMounted(() => loadAll())
< 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 class = "og-name" :title = "data.label" > { { cleanLinkName ( data . label ) } } < / span >
< span
v-if = "data.configuredCount > 0"
v-if = "data.familyId || data.configuredCount > 0"
class = "og-badge og-badge--ok"
title = "已配置 {{ data.configuredCount } } 国"
: title = "data.familyId ? `族 ${data.familyCode || ''} · ${data.familyName || ''}` : ` 已配置 $ {data.configuredCount} 国` "
> 已配置 { { data . configuredCount > 1 ? ' ' + data . configuredCount : '' } } < / span >
< span
v-else
@@ -1805,12 +1818,12 @@ onMounted(() => loadAll())
@click.stop ="handleSyncOriginDetail(data)"
/ >
< el-button
v-if = "data.configuredCount > 0"
v-if = "data.configuredCount > 0 || data.familyId "
size = "small" link :icon = "Aim" title = "定位到官网商品"
@click.stop ="locateInLeftTree(data.rawId)"
/ >
< el-button
v-if = "!data.configuredCount"
v-if = "!data.configuredCount && !data.familyId "
size = "small" type = "primary" link class = "og-config-btn"
@click.stop ="openConfigFromRightTree(data)"
> 配置 < / el-button >
@@ -1818,8 +1831,8 @@ onMounted(() => loadAll())
< 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 v-if = "data.configuredEff < data.totalCount" >
{{ data.configuredEff }} / {{ data.totalCount }}
< / template >
< template v-else > {{ data.totalCount }} < / template >
< / span >
@@ -1909,7 +1922,7 @@ onMounted(() => loadAll())
< 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-name" :title = "configOG.goodName" > { { cleanLinkName ( configOG . goodName ) } } < / div >
< div class = "config-og-meta" > SDS ID : { { configOG . sdsGoodId } } < template v-if = "configOG.goodPrice" > · 价格 : ¥ {{ configOG.goodPrice }} < / template > < / div >
< / div >
< / div >
@@ -1929,16 +1942,16 @@ onMounted(() => loadAll())
< / 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-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 :value = "String(configOG.rawId)" > 主链接 : { { cleanLinkName ( configOG . goodName ) } } < / el-radio >
< el-radio v-for = "s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)" > {{ cleanLinkName ( 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 >
{{ cleanLinkName ( s.goodName ) }} < template v-if = "s.goodPrice" > · ¥ {{ s.goodPrice }} < / template >
< / el -checkbox >
< / el-checkbox-group >
< / div >
@@ -1947,17 +1960,7 @@ onMounted(() => loadAll())
< 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 configTagOptions" :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 >
< div v-if = "configHasFamily" class="derived-tags-note" >
物流 / 工艺 / 印刷位置标签由族成员自动生成 , 无需手动选择
< / div >
< div class = "derived-tags-note" > 标签无需手动选择 : 保存后由系统按链接名称自动解析 ( 印花数量 / 工艺 / 物流 ) < / div >
< / el-form-item >
< / el-form >
< template # footer >
@@ -2002,13 +2005,13 @@ onMounted(() => loadAll())
< / el-dialog >
<!-- Edit Good Modal ( direct edit — no separate detail step ) -- >
< el-dialog v-model = "editVisible" :title="editGood?.goodName || '编辑商品'" width="900px" destroy -on -close >
< el-dialog v-model = "editVisible" :title="cleanLinkName( 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-name" : title = "editGood.originGood.goodName ?? undefined" > { { cleanLinkName ( 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" >
@@ -2043,20 +2046,28 @@ onMounted(() => loadAll())
< div class = "edit-merged-list" >
< div class = "edit-merged-item primary" >
< span class = "edit-merged-tag" > 主 < / span >
< span > { { editGood ? . originGood ? . goodName } } < / span >
< span class = "edit-merged-name" : title = "editGood?.originGood?.goodName ?? undefined" > { { cleanLinkName ( editGood ? . originGood ? . goodName ) } } < / span >
< span v-if = "deriveLinkTagNames(editGood?.originGood?.goodName).length" class="member-chips" >
< span v-for = "c in deriveLinkTagNames(editGood?.originGood?.goodName)" :key="c" class="member-chip" > {{ c }} < / span >
< / span >
< / div >
< div v-for = "m in editFamilyMembers" :key="m.id" class="edit-merged-item" >
< span class = "edit-merged-tag sub" > 族 < / span >
< span > { { m . goodName } } < / span >
< span class = "edit-merged-name" :title = "m.goodName" > { { cleanLinkName ( m . goodName ) } } < / span >
< span v-if = "deriveLinkTagNames(m.goodName).length" class="member-chips" >
< span v-for = "c in deriveLinkTagNames(m.goodName)" :key="c" class="member-chip" > {{ c }} < / span >
< / span >
< div class = "edit-merged-actions" >
< el-button size = "small" link type = "primary" @click ="promoteFamilyPrimary(m)" > 设为主链接 < / el -button >
< el-button size = "small" link type = "danger" @click ="removeFamilyMember(m)" > 移除出族 < / el -button >
< / div >
< / div >
< div v-if = "!editFamilyLoaded && editFamilyId" class="edit-family-loading" > 族成员加载中 … < / div >
< div v-else-if = "!editFamilyId" class="edit-family-loading" > 暂未成族 : 配置合并时自动成族 < / div >
< / div >
< el -select v-model = "editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder = "搜索原产品名称添加到族" clearable style = "width:100%" >
< el-option v-for = "c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
< el-option v-for = "c in editFamilyCandidates" :key="c.id" :label="cleanLinkName(c.goodName) || c.goodName" :value="String(c.id)"
@click ="addFamilyMember(c)" / >
< / el-select >
< / div >
@@ -2077,20 +2088,12 @@ onMounted(() => loadAll())
< 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 editTagOption s" :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 >
< div v-if = "editFamilyId && editDerivedTags.length" class="derived-tags-row" >
< span class = "derived-tags-label" > 族自动 : < / span >
< el-tag v-for = "t in editDerivedTags" :key="t.id" size="small" type="info" class="derived-tag" > {{ t.tagName }} < / el -tag >
< / div >
< div v-else-if = "editFamilyId" class="derived-tags-note" >
物流 / 工艺 / 印刷位置标签由族成员自动生成 , 无需手动选择
< div class = "derived-tags-row derived-tags-readonly " >
< template v-if = "editAutoTags.length " >
< el -tag v-for = "t in editAutoTag s" :key="t .id" size="small" class="derived-tag" > {{ t.tagName }} < / el -tag >
< / template >
< span v-else class = "derived-tags-note" > 暂无自动标签 < / span >
< span class = "derived-tags-note" > 由链接名称自动解析 ( 印花数量 / 工艺 / 物流 ) , 不可手动编辑 < / span >
< / div >
< / el-form-item >
< el-form-item v-if = "editIsCustom" label="基础价格" >
@@ -2530,14 +2533,35 @@ onMounted(() => loadAll())
. edit - family - loading { color : var ( -- el - text - color - placeholder ) ; font - size : 12 px ; padding : 4 px 0 ; }
. derived - tags - note { font - size : 12 px ; color : var ( -- el - text - color - secondary ) ; margin - top : 4 px ; }
. derived - tags - row { display : flex ; align - items : center ; flex - wrap : wrap ; gap : 4 px ; margin - top : 4 px ; }
. derived - tags - readonly { margin - top : 0 ; row - gap : 2 px ; }
. derived - tags - label { font - size : 12 px ; color : var ( -- el - text - color - secondary ) ; }
. derived - tag { pointer - events : none ; }
/* 左树定位高亮:el-tree 当前节点底色 + 行级闪烁动画 */
. gv - left : deep ( . el - tree - node . is - current > . el - tree - node _ _content ) {
background : var ( -- el - color - primary - light - 8 ) ;
}
. good - node -- flash { animation : good - node - flash 1.2 s ease - in - out 2 ; }
@ keyframes good - node - flash {
0 % , 100 % { background : transparent ; }
50 % { background : var ( -- el - color - primary - light - 7 ) ; box - shadow : inset 0 0 0 1 px var ( -- el - color - primary - light - 5 ) ; }
}
. edit - merged - list { display : flex ; flex - direction : column ; gap : 6 px ; margin - bottom : 8 px ; }
. edit - merged - item {
display : flex ; align - items : center ; gap : 8 px ;
padding : 6 px 10 px ; background : # f5f7fa ; border - radius : 6 px ; font - size : 13 px ;
}
. edit - merged - item . el - button { margin - left : auto ; }
. edit - merged - name {
flex : 1 ; min - width : 0 ;
overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ;
}
/* 操作按钮固定右缘:只有 actions 容器吃 margin-left:auto,两个按钮间距固定 */
. edit - merged - actions { margin - left : auto ; flex - shrink : 0 ; display : flex ; align - items : center ; }
. edit - merged - actions . el - button + . el - button { margin - left : 4 px ; }
. member - chips { display : flex ; align - items : center ; gap : 4 px ; flex - shrink : 0 ; flex - wrap : nowrap ; overflow : hidden ; }
. member - chip {
font - size : 11 px ; line - height : 16 px ; padding : 1 px 7 px ; border - radius : 8 px ;
background : # ecf5ff ; color : var ( -- el - color - primary ) ; white - space : nowrap ;
}
. edit - merged - tag {
padding : 0 5 px ; border - radius : 4 px ; font - size : 11 px ; line - height : 18 px ;
background : var ( -- el - color - primary ) ; color : # fff ; flex - shrink : 0 ;