From 1128167aef711b19125d226fdf9e010826a351a0 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 15:33:11 +0800 Subject: [PATCH] feat(admin): family-aware goods view - auto tags readonly, clean link names, config badges, locate highlight --- apps/admin/src/types/index.ts | 1 + apps/admin/src/utils/origin-name.spec.ts | 64 +++++++- apps/admin/src/utils/origin-name.ts | 51 ++++++ apps/admin/src/views/goods/GoodsView.vue | 196 +++++++++++++---------- docs/references/product-center.md | 31 +++- docs/references/structs.md | 2 +- 6 files changed, 249 insertions(+), 96 deletions(-) diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts index 842b073..3585d63 100644 --- a/apps/admin/src/types/index.ts +++ b/apps/admin/src/types/index.ts @@ -360,6 +360,7 @@ export interface OriginGood { sizeRowCount?: number packageRowCount?: number productCode?: string | null + family?: { familyId: string; familyCode: string | null; familyName: string } | null } // Origin Goods Tree types diff --git a/apps/admin/src/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts index 362ed98..71af732 100644 --- a/apps/admin/src/utils/origin-name.spec.ts +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { sameOriginGroup, truncateToProcess } from './origin-name'; +import { + cleanLinkName, + deriveLinkTagNames, + parseLinkName, + sameOriginGroup, + truncateToProcess, +} from './origin-name'; describe('truncateToProcess', () => { it('keeps first 3 dash segments (drops warehouse)', () => { @@ -44,3 +50,59 @@ describe('sameOriginGroup', () => { expect(sameOriginGroup('', '')).toBe(false); }); }); + +describe('parseLinkName / cleanLinkName', () => { + it('parses 国家(物流)品名-SKU-工艺 → 品名 + 型号', () => { + expect(parseLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({ + productName: '180g纯棉T恤成人款', + skuCode: 'DG001', + }); + expect(cleanLinkName('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toBe( + '180g纯棉T恤成人款 DG001', + ); + }); + + it('handles half-width parens and warehouse suffix', () => { + expect( + cleanLinkName('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), + ).toBe('180GT恤成人款 JSA002'); + }); + + it('returns null / raw fallback for manual names without the country(remark) structure', () => { + expect(parseLinkName('180g纯棉T恤成人款')).toBeNull(); + expect(cleanLinkName('180g纯棉T恤成人款')).toBe('180g纯棉T恤成人款'); + expect(cleanLinkName(null)).toBe(''); + }); + + it('returns null when product name after parens is empty', () => { + expect(parseLinkName('美国(包邮)-DG001-单面印花')).toBeNull(); + }); +}); + +describe('deriveLinkTagNames', () => { + it('单面印花 + 包邮 + 无工艺关键字 → 单面印花/烫画/包邮', () => { + expect(deriveLinkTagNames('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual([ + '单面印花', + '烫画', + '包邮', + ]); + }); + + it('不打印 + 光板 + 不包邮 → 两工艺标签 + 不包邮', () => { + expect( + deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), + ).toEqual(['不打印', '光板', '不包邮']); + }); + + it('双面印花 + 不包邮,且不误命中包邮', () => { + expect(deriveLinkTagNames('美国(不包邮)T恤-DG001-双面印花·美东新泽西仓')).toEqual([ + '双面印花', + '烫画', + '不包邮', + ]); + }); + + it('空名称返回空数组', () => { + expect(deriveLinkTagNames(null)).toEqual([]); + }); +}); diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 7da4a09..39cad2a 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -18,3 +18,54 @@ export function sameOriginGroup( const ka = truncateToProcess(a); return ka !== '' && ka === truncateToProcess(b); } + +/** 链接名结构化解析结果:品名 + 型号(SKU 代码) */ +export interface ParsedLinkName { + productName: string; + skuCode: string | null; +} + +/** + * 解析链接名称:`国家(物流备注)品名-SKU-工艺位置[-·仓库名]` → 品名 + 型号。 + * 与 api 端 origin-name.parser.ts 同构;解析失败(无「国家(备注)」前缀结构)返回 null, + * 调用方应回退显示原始名称 —— 手动维护的品名不走该解析。 + */ +export function parseLinkName(name: string | null | undefined): ParsedLinkName | null { + if (!name) return null; + const segs = name.split('-').map((s) => s.trim()); + const head = segs[0] ?? ''; + const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')')); + if (closeIdx <= 0 || closeIdx === head.length - 1) return null; + const productName = head.slice(closeIdx + 1).trim(); + if (!productName) return null; + const skuCode = segs[1] || null; + return { productName, skuCode }; +} + +/** 展示名:`品名 型号`(如「180g纯棉T恤成人款 DG001」);解析失败回退原名 */ +export function cleanLinkName(name: string | null | undefined): string { + if (!name) return ''; + const parsed = parseLinkName(name); + if (!parsed) return name; + return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName; +} + +const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; + +/** + * 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示): + * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画; + * 物流:不包邮 优先于 包邮。 + */ +export function deriveLinkTagNames(name: string | null | undefined): string[] { + if (!name) return []; + const names: string[] = []; + if (name.includes('双面印花')) names.push('双面印花'); + else if (name.includes('单面印花')) names.push('单面印花'); + const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k)); + if (craftHits.length > 0) names.push(...craftHits); + else names.push('烫画'); + if (name.includes('不包邮')) names.push('不包邮'); + else if (name.includes('包邮')) names.push('包邮'); + return names; +} diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 02d462d..50cbdd1 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -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('') + 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()) -
+
@@ -1697,7 +1710,7 @@ onMounted(() => loadAll())
-
{{ data.goodName }}
+
{{ cleanLinkName(data.goodName) }}
{{ data.country }}
@@ -1715,12 +1728,12 @@ onMounted(() => loadAll())
{{ data.isCustom ? '来源' : '原产品' }}
-
{{ data.originGoodName }}
+
{{ cleanLinkName(data.originGoodName) }}
- {{ data.goodName }} + {{ cleanLinkName(data.goodName) }} ×{{ data.mergedCount }}
@@ -1778,11 +1791,11 @@ onMounted(() => loadAll())
- {{ data.label }} + {{ cleanLinkName(data.label) }} 已配置{{ data.configuredCount > 1 ? ' ' + data.configuredCount : '' }} loadAll()) @click.stop="handleSyncOriginDetail(data)" /> 配置 @@ -1818,8 +1831,8 @@ onMounted(() => loadAll())
{{ data.label }} -