diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 0f607de..847aca4 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -214,41 +214,32 @@ const filteredGoods = computed(() => { return sorted }) -/** 左树同族折叠:同款(同 familyId)多条官网商品折叠为一个"族组"节点,children 为各行明细 */ -function foldByFamily(nodes: any[]): any[] { - const out: any[] = [] - const byFamily = new Map() - for (const n of nodes) { +/** 左树一族只显示一行:同款(同 familyId)多条配置保留代表行(排序最前), + * 其余配置计数挂在 configCount,悬浮徽标可查看并打开 */ +function distinctByFamily(nodes: any[]): any[] { + const seen = new Set() + return nodes.map(n => { const fid = n.raw?.originGood?.family?.familyId ? String(n.raw.originGood.family.familyId) : null - if (!fid) { out.push(n); continue } - if (!byFamily.has(fid)) { - const bucket: any[] = [] - byFamily.set(fid, bucket) - out.push(bucket) - } - ;(byFamily.get(fid) as any[]).push(n) - } - return out.map(n => - Array.isArray(n) - ? { - id: 'fam-' + String(n[0].raw.originGood.family.familyId), - label: n[0].goodName, - isFamilyGroup: true, - goodCount: n.length, - children: n, - } - : n, - ) + 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, ...foldByFamily(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, @@ -276,7 +267,7 @@ const leftTreeData = computed(() => { isCat: true, raw: c, goodCount: cGoods.length, - children: foldByFamily(cGoods.map(goodToNode)), + children: distinctByFamily(cGoods.map(goodToNode)), } }) } @@ -449,11 +440,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) : [] @@ -738,26 +724,11 @@ function onTreeGoodDragLeave(_event: DragEvent, data: any) { if (treeDragOver.value?.id === data.goodId) treeDragOver.value = null } -/** 国家分组内:找商品所在容器数组(国家根 or 族组 children) */ +/** 国家分组内:找商品所在容器数组(即国家根 children) */ function findContainingArray(group: any, goodId: string): any[] | null { - for (const c of group.children ?? []) { - if (c.isFamilyGroup) { - if ((c.children ?? []).some((g: any) => g.goodId === goodId)) return c.children - } else if (c.isGood && c.goodId === goodId) { - return group.children - } - } - return null -} - -/** 国家分组内商品的折叠后展示顺序(族组按位置展开计入) */ -function flattenCountryGoods(group: any): any[] { - const out: any[] = [] - for (const c of group.children ?? []) { - if (c.isFamilyGroup) out.push(...(c.children ?? [])) - else if (c.isGood) out.push(c) - } - return out + return (group.children ?? []).some((c: any) => c.isGood && c.goodId === goodId) + ? group.children + : null } async function onTreeGoodDrop(event: DragEvent, data: any) { @@ -770,30 +741,23 @@ 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) => findContainingArray(c, target.id)) if (!group) return - const fromArr = findContainingArray(group, draggedId) - const toArr = findContainingArray(group, target.id) - if (!fromArr || fromArr !== toArr) { - ElMessage.info('暂只支持同一分组(或同一族)内拖拽排序') - return - } - const nodes = [...fromArr] + const nodes = [...group.children] const fromIdx = nodes.findIndex(n => n.goodId === draggedId) + if (fromIdx === -1) return const [moved] = nodes.splice(fromIdx, 1) let insertIdx = nodes.findIndex(n => n.goodId === target.id) if (target.pos === 'after') insertIdx++ nodes.splice(insertIdx, 0, moved) - fromArr.splice(0, fromArr.length, ...nodes) - // 保序重排:该国家现有优先级集合不变,按折叠后展示顺序重新分配(尽量少扰动全局/品类排序) - const flat = flattenCountryGoods(group) - const priorities = flat.map(n => Number(n.priority || 0)).sort((a, b) => b - a) + // 保序重排:该国家现有优先级集合不变,按新顺序重新分配(尽量少扰动全局/品类排序) + const priorities = nodes.map(n => Number(n.priority || 0)).sort((a, b) => b - a) moveTopLoading.value = 'drag' try { await goodsApi.batchUpdatePriority({ - items: flat.map((n, i) => ({ id: Number(n.goodId), priority: priorities[i] ?? 0 })), + items: nodes.map((n, i) => ({ id: Number(n.goodId), priority: priorities[i] ?? 0 })), }) ElMessage.success('排序已保存') await refreshLeftTree() @@ -1107,11 +1071,6 @@ onMounted(() => loadAll()) - -
- {{ cleanLinkName(data.label) }} - {{ data.goodCount }} 个配置 -
loadAll()) {{ cleanLinkName(data.goodName) }} ×{{ data.mergedCount }} + + +
+
+ {{ cleanLinkName(c.goodName) }} + {{ c.country }}{{ c.raw?.category?.categoryName ? ' · ' + c.raw.category.categoryName : '' }} + 打开 +
+
+
@@ -1266,8 +1243,9 @@ onMounted(() => loadAll()) @click.stop="locateInLeftTree(data.rawId)" /> 配置 @@ -1556,23 +1534,23 @@ onMounted(() => loadAll()) .good-node.is-dragging { opacity: 0.5; } .good-node.is-drop-before { box-shadow: inset 0 2px 0 var(--el-color-primary); } .good-node.is-drop-after { box-shadow: inset 0 -2px 0 var(--el-color-primary); } -.fam-node { - display: flex; align-items: center; gap: 8px; - flex: 1; min-width: 0; - padding: 4px 8px; margin: 2px 0; - border-radius: 6px; cursor: default; - background: #fafafa; border: 1px dashed #dcdfe6; -} -.fam-label { - font-weight: 600; font-size: 13px; color: #303133; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.fam-badge { +.cfg-badge { font-size: 11px; line-height: 1; padding: 3px 7px; border-radius: 8px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); - flex-shrink: 0; white-space: nowrap; + flex-shrink: 0; white-space: nowrap; cursor: pointer; } +.cfg-pop-list { display: flex; flex-direction: column; gap: 6px; max-height: 280px; overflow: auto; } +.cfg-pop-row { + display: flex; align-items: center; gap: 8px; + padding: 4px 6px; border-radius: 6px; background: #fafafa; +} +.cfg-pop-row:hover { background: #f0f2f5; } +.cfg-pop-name { + flex-shrink: 0; max-width: 200px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #303133; +} +.cfg-pop-sub { flex: 1; font-size: 11px; color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .good-thumb { width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; object-fit: cover; diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 2964884..69bfac8 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -159,9 +159,11 @@ pnpm --filter @inkreach/api backfill:product-families - 右树徽标语义(**族感知**,公开契约一族一款):**已配置 N**(绿,本链接已配置为官网商品, N=国数)/ **已配置**(绿,本链接未单独建商品但所属族已配置)/ **成族**(蓝,已归族且全族 未配置)/ **未配置**(橙);「定位到官网商品」在自身或同族已配置时出现(按 主链接 → 同族 → - 旧副源顺序定位并高亮),「配置」仅在全族未配置时出现;把已配置族的链接拖拽/点击配置会被 - 拦截提示(避免产出官网不可见的重复商品); + 旧副源顺序定位并高亮),「配置」在本链接未单独建商品时可用(含族已配置的情形,供调整归族; + 注意重复配置会产生官网不可见的冗余商品); - 族分组头部计数与「仅未配置」筛选同样按族语义(族已配置 → 计入已配置)。 +- **左树一族一行**(国家/品类模式):同款多条配置只显示排序最前的一条,名称旁 + 「N 个配置」徽标悬浮列出其余配置(可打开编辑),分组头部计数仍按全部商品统计。 - 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用; - 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、 GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。