From 10197c47a8b1a1c98cb5a0231a80f1dbc01b7684 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Fri, 28 Aug 2026 16:40:48 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20split=20GoodsView=20into=20d?= =?UTF-8?q?ialog=20components;=20fix(GoodsView):=20raw=20member=20names,?= =?UTF-8?q?=20no=20primary=20badges,=20member=20price=20grid;=20feat(tag):?= =?UTF-8?q?=20=E5=85=89=E6=9D=BF=20maps=20to=20=E4=B8=8D=E6=89=93=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components.d.ts | 1 - apps/admin/src/utils/category-tree.ts | 28 + apps/admin/src/utils/origin-name.spec.ts | 6 +- apps/admin/src/utils/origin-name.ts | 12 +- apps/admin/src/views/goods/GoodsView.vue | 1488 +---------------- .../goods/components/CustomGoodDialog.vue | 123 ++ .../goods/components/GoodsConfigDialog.vue | 155 ++ .../goods/components/GoodsEditDialog.vue | 806 +++++++++ .../goods/components/TagFilterPopover.vue | 478 ++++++ .../product-families/auto-tag-rules.spec.ts | 8 +- .../src/product-families/auto-tag-rules.ts | 18 +- .../product-families/family-tag-sync.spec.ts | 9 +- docs/references/product-center.md | 24 +- docs/references/structs.md | 4 +- 14 files changed, 1719 insertions(+), 1441 deletions(-) create mode 100644 apps/admin/src/utils/category-tree.ts create mode 100644 apps/admin/src/views/goods/components/CustomGoodDialog.vue create mode 100644 apps/admin/src/views/goods/components/GoodsConfigDialog.vue create mode 100644 apps/admin/src/views/goods/components/GoodsEditDialog.vue create mode 100644 apps/admin/src/views/goods/components/TagFilterPopover.vue diff --git a/apps/admin/src/components.d.ts b/apps/admin/src/components.d.ts index e39958f..70b2817 100644 --- a/apps/admin/src/components.d.ts +++ b/apps/admin/src/components.d.ts @@ -42,7 +42,6 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup'] ElPopover: typeof import('element-plus/es')['ElPopover'] - ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] diff --git a/apps/admin/src/utils/category-tree.ts b/apps/admin/src/utils/category-tree.ts new file mode 100644 index 0000000..9aa4565 --- /dev/null +++ b/apps/admin/src/utils/category-tree.ts @@ -0,0 +1,28 @@ +import type { CategoryTree } from '@/types' + +/** 在分类树中找目标分类的祖先路径(含自身) */ +export function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] { + for (const n of nodes) { + if (n.id === targetId) return [n.id] + if (n.children?.length) { + const sub = findCategoryPath(n.children, targetId) + if (sub.length) return [n.id, ...sub] + } + } + return [] +} + +export interface CascadeNode { + value: string + label: string + children?: CascadeNode[] +} + +/** 分类树 → el-cascader 选项 */ +export function buildCascader(tree: CategoryTree[]): CascadeNode[] { + return tree.map((n) => ({ + value: n.id, + label: n.categoryName, + children: n.children?.length ? buildCascader(n.children) : undefined, + })) +} diff --git a/apps/admin/src/utils/origin-name.spec.ts b/apps/admin/src/utils/origin-name.spec.ts index ffaab18..6ae3737 100644 --- a/apps/admin/src/utils/origin-name.spec.ts +++ b/apps/admin/src/utils/origin-name.spec.ts @@ -89,10 +89,10 @@ describe('deriveLinkTagNames', () => { ]); }); - it('不打印 + 光板 + 不包邮 → 两工艺标签 + 不包邮', () => { + it('不打印 + 光板 + 不包邮 → 归并为单个不打印(光板即不打印)', () => { expect( deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), - ).toEqual(['不打印', '光板', '不包邮']); + ).toEqual(['不打印', '不包邮']); }); it('双面印花 + 不包邮,且不误命中包邮', () => { @@ -117,7 +117,7 @@ describe('linkDims', () => { }); expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({ logistics: '不包邮光板', - crafts: ['不打印', '光板'], + crafts: ['不打印'], printCount: null, }); }); diff --git a/apps/admin/src/utils/origin-name.ts b/apps/admin/src/utils/origin-name.ts index 56ca0d6..6a169b5 100644 --- a/apps/admin/src/utils/origin-name.ts +++ b/apps/admin/src/utils/origin-name.ts @@ -50,7 +50,11 @@ export function cleanLinkName(name: string | null | undefined): string { return parsed.skuCode ? `${parsed.productName} ${parsed.skuCode}` : parsed.productName; } -const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; +const CRAFT_KEYWORD_MAP: Record = { + 直喷: '直喷', + 不打印: '不打印', + 光板: '不打印', // 光板即为不打印 +}; /** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */ export interface LinkDims { @@ -68,7 +72,7 @@ export function linkDims(name: string | null | undefined): LinkDims { const closeIdx = Math.max(head.lastIndexOf(')'), head.lastIndexOf(')')); const logistics = openIdx >= 0 && closeIdx > openIdx ? head.slice(openIdx + 1, closeIdx).trim() || null : null; - const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k)); + const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))]; const printCount = name.includes('双面印花') ? '双面印花' : name.includes('单面印花') @@ -83,7 +87,7 @@ export function linkDims(name: string | null | undefined): LinkDims { /** * 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示): - * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画; + * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板→不打印,皆无则默认烫画; * 物流:不包邮 优先于 包邮。 */ export function deriveLinkTagNames(name: string | null | undefined): string[] { @@ -91,7 +95,7 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] { const names: string[] = []; if (name.includes('双面印花')) names.push('双面印花'); else if (name.includes('单面印花')) names.push('单面印花'); - const craftHits = CRAFT_KEYWORDS.filter((k) => name.includes(k)); + const craftHits = [...new Set(Object.entries(CRAFT_KEYWORD_MAP).filter(([k]) => name.includes(k)).map(([, tag]) => tag))]; if (craftHits.length > 0) names.push(...craftHits); else names.push('烫画'); if (name.includes('不包邮')) names.push('不包邮'); diff --git a/apps/admin/src/views/goods/GoodsView.vue b/apps/admin/src/views/goods/GoodsView.vue index 8269b4a..c5d387b 100644 --- a/apps/admin/src/views/goods/GoodsView.vue +++ b/apps/admin/src/views/goods/GoodsView.vue @@ -4,7 +4,7 @@ import { useVirtualList } from '@vueuse/core' import { ElMessage, ElMessageBox } from 'element-plus' import { Plus, Edit, Delete, Search, Top, Refresh, - FolderAdd, Aim, ArrowDown, QuestionFilled, + FolderAdd, Aim, } from '@element-plus/icons-vue' import type { CategoryTree, Country, Tag, TagGroup, Good, GoodDetail, @@ -18,8 +18,11 @@ import { tagGroupsApi } from '@/api/tag-groups' import { originGoodsApi } from '@/api/origin-goods' import { syncApi } from '@/api/sync' import { sameFamily } from '@/utils/family-match' -import { cleanLinkName, deriveLinkTagNames, linkDims, truncateToProcess } from '@/utils/origin-name' -import { productFamiliesApi } from '@/api/product-families' +import { cleanLinkName } from '@/utils/origin-name' +import GoodsConfigDialog from './components/GoodsConfigDialog.vue' +import CustomGoodDialog from './components/CustomGoodDialog.vue' +import GoodsEditDialog from './components/GoodsEditDialog.vue' +import TagFilterPopover from './components/TagFilterPopover.vue' const mode = ref<'category' | 'country' | 'global'>('category') const loading = ref(false) @@ -100,15 +103,6 @@ function setExpand(treeRef: any, data: any[], expand: boolean) { }) } -interface CascadeNode { value: string; label: string; children?: CascadeNode[] } -function buildCascader(tree: CategoryTree[]): CascadeNode[] { - return tree.map(n => ({ - value: n.id, label: n.categoryName, - children: n.children?.length ? buildCascader(n.children) : undefined, - })) -} -const categoryCascader = computed(() => buildCascader(allCategories.value)) - async function loadAll() { loading.value = true try { @@ -345,17 +339,6 @@ function refreshRightTree() { originGoodsApi.getTree().then((tree: any) => buildRightTree(tree)) } -function findCategoryPath(nodes: CategoryTree[], targetId: string): string[] { - for (const n of nodes) { - if (n.id === targetId) return [n.id] - if (n.children?.length) { - const sub = findCategoryPath(n.children, targetId) - if (sub.length) return [n.id, ...sub] - } - } - return [] -} - // ─── Right → Left Drag (origin product → category/country) ─── function onOGDragStart(event: DragEvent, data: any) { if (!event.dataTransfer || !data.isOG) return @@ -385,22 +368,12 @@ function onCatDrop(event: DragEvent, data: any) { } catch {} } -// ─── Config Modal ─── +// ─── Config Modal(GoodsConfigDialog 组件) ─── const configVisible = ref(false) -const configLoading = ref(false) const configOG = ref(null) const configDropTarget = ref(null) -const configForm = ref({ - countryId: '', cascaderCategory: [] as string[], categoryId: '', - tagIds: [] as string[], positionId: '', goodImage: '', -}) const configSiblings = ref([]) -const configChecked = ref([]) -const configPrimaryId = ref('') - -const checkedSiblingNodes = computed(() => - configSiblings.value.filter((s) => configChecked.value.includes(String(s.rawId))), -) +const configDefaultChecked = ref([]) function findOgNodeById(rawId: string | number): any { let found: any = null @@ -427,24 +400,13 @@ function collectSiblings(ogNode: any): any[] { } function openConfigModal(og: any, dropTarget: any) { - configOG.value = { ...og, familyId: og.familyId ?? findOgNodeById(og.rawId)?.familyId ?? null } - configDropTarget.value = dropTarget const ogNode = findOgNodeById(og.rawId) - const siblings = ogNode ? collectSiblings(ogNode) : [] - configSiblings.value = siblings - configPrimaryId.value = String(og.rawId) - configChecked.value = siblings + configOG.value = { ...og, familyId: og.familyId ?? ogNode?.familyId ?? null } + configDropTarget.value = dropTarget + configSiblings.value = ogNode ? collectSiblings(ogNode) : [] + configDefaultChecked.value = configSiblings.value .filter((s) => sameFamily(ogNode ?? { goodName: og.goodName }, s)) .map((s) => String(s.rawId)) - 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 - } - } configVisible.value = true } @@ -458,404 +420,47 @@ function openConfigFromRightTree(data: any) { }, null) } -function onConfigCascaderChange(val: any) { - configForm.value.categoryId = val.length ? val[val.length - 1] : '' -} - -/** 多对一合并的族支撑:勾选的兄弟链接与主链接静默归入同一族(无族则自动建族), - * 前端交互保持原「合并同名」流程不变;失败不阻断原配置流程 */ -async function ensureFamilyMembership() { - const primaryId = String(configPrimaryId.value || configOG.value.rawId) - const checked = configChecked.value.filter((id) => id !== primaryId) - if (!checked.length) return - const primaryNode = findOgNodeById(primaryId) - try { - if (primaryNode?.familyId) { - await productFamiliesApi.updateMembers(primaryNode.familyId, { - addOriginGoodIds: [...new Set([primaryId, ...checked])], - }) - } else { - await productFamiliesApi.create({ - familyName: truncateToProcess(primaryNode?.goodName ?? configOG.value.goodName) || (configOG.value.goodName ?? ''), - originGoodIds: [primaryId, ...checked], - primaryOriginGoodId: primaryId, - }) - } - } catch (e: any) { - ElMessage.warning(e?.response?.data?.message || '挂族失败,商品仍按原方式配置') - } -} - -async function handleConfigSubmit() { - if (!configForm.value.countryId) { ElMessage.warning('请选择国家'); return } - if (!configForm.value.categoryId) { ElMessage.warning('请选择分类'); return } - configLoading.value = true - try { - await ensureFamilyMembership() - await goodsApi.createGood({ - goodName: configOG.value.goodName, - goodImage: configForm.value.goodImage || undefined, - originGoodId: Number(configPrimaryId.value || configOG.value.rawId), - countryId: Number(configForm.value.countryId), - categoryId: Number(configForm.value.categoryId), - tagIds: configForm.value.tagIds.map(Number), - positionId: configForm.value.positionId ? Number(configForm.value.positionId) : undefined, - } as any) - ElMessage.success('配置成功') - configVisible.value = false - refreshLeftTree() - refreshRightTree() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '配置失败') - } finally { configLoading.value = false } -} - -// ─── Custom Good ─── -const customVisible = ref(false) -const customLoading = ref(false) -const customForm = ref({ - goodName: '', goodImage: '', goodPrice: '', countryId: '', - cascaderCategory: [] as string[], categoryId: '', tagIds: [] as string[], - goodPriority: 0, -}) - -function openCustomCreate() { - customForm.value = { - goodName: '', goodImage: '', goodPrice: '', countryId: '', - cascaderCategory: [], categoryId: '', tagIds: [], goodPriority: 0, - } - customVisible.value = true -} - -function onCustomCascaderChange(val: any) { - const path = Array.isArray(val) ? val : [] - customForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' -} - -async function handleCustomCreate() { - if (!customForm.value.goodName.trim()) { ElMessage.warning('请输入商品名称'); return } - if (!customForm.value.countryId) { ElMessage.warning('请选择国家'); return } - if (!customForm.value.categoryId) { ElMessage.warning('请选择分类'); return } - customLoading.value = true - try { - const created = await goodsApi.createCustomGood({ - goodName: customForm.value.goodName.trim(), - goodImage: customForm.value.goodImage || undefined, - goodPrice: customForm.value.goodPrice || null, - countryId: Number(customForm.value.countryId), - categoryId: Number(customForm.value.categoryId), - tagIds: customForm.value.tagIds.map(Number), - goodPriority: customForm.value.goodPriority, - detail: {}, - }) - ElMessage.success('自定义商品已创建,可继续完善详情、尺码、包装和 SKU') - customVisible.value = false - await refreshLeftTree() - await openEdit(created) - } catch (error: any) { - ElMessage.error(error?.response?.data?.message || '自定义商品创建失败') - } finally { customLoading.value = false } -} - -// ─── Edit Good (replaces detail — click opens edit directly) ─── -const editVisible = ref(false) -const editLoading = ref(false) -const editDetailLoading = ref(false) -const detailSyncing = ref(false) -const editGood = ref(null) -const originalOriginGoodId = ref('') -const editForm = ref({ - id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[], - categoryId: '', positionId: '', -}) - -const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null) -const editVariants = computed(() => (editGood.value as GoodDetail | null)?.variants ?? []) -const editSizeColumns = computed(() => editOriginDetail.value?.sizeChart?.columns ?? []) -const editSizeRows = computed(() => { - const rows = editOriginDetail.value?.sizeChart?.rows ?? [] - return rows.map((row: any) => ({ - ...row, - ...(row.measurements ?? []).reduce((out: Record, item: any) => { - out[item.key] = item.cm ?? '-' - return out - }, {}), - })) -}) -const editPackageRows = computed(() => editOriginDetail.value?.packageSpecs?.rows ?? []) -const editIsCustom = computed(() => editGood.value?.originGood?.isCustom === true) - -const customContentForm = ref({ - goodPrice: '', productCode: '', englishName: '', productionCycleHours: undefined as number | undefined, - minWeightG: '', productionProcess: '', materialDescription: '', - blankDesignUrl: '', detailsPageVideoUrl: '', textureName: '', reminder: '', - productPerformance: '', applicableScenarios: '', washingInstructions: '', specialDescription: '', - designExplanation: '', designArea: '', pictureRequest: '', - sizeChartJson: '{\n "columns": [],\n "rows": []\n}', - packageSpecsJson: '{\n "rows": []\n}', - optionsJson: '{}', - mediaJson: '{}', - variants: [] as Array<{ - sku: string; sizeId: string; sizeName: string; colorId: string; colorName: string; colorHex: string; imageUrl: string; - price: string; originalPrice: string; weightG: string; boxLengthCm: string; - boxWidthCm: string; boxHeightCm: string; designDataJson: string; enabled: boolean - }>, -}) - -function fillCustomContent(g: GoodDetail) { - const detail = g.originDetail ?? {} - customContentForm.value = { - goodPrice: g.originGood?.goodPrice ?? '', - productCode: String(detail.productCode ?? ''), - englishName: String(detail.englishName ?? ''), - productionCycleHours: detail.productionCycleHours == null ? undefined : Number(detail.productionCycleHours), - minWeightG: String(detail.minWeightG ?? ''), - productionProcess: String(detail.productionProcess ?? ''), - materialDescription: String(detail.materialDescription ?? ''), - blankDesignUrl: String(detail.blankDesignUrl ?? ''), - detailsPageVideoUrl: String(detail.detailsPageVideoUrl ?? ''), - textureName: String(detail.textureName ?? ''), - reminder: String(detail.reminder ?? ''), - productPerformance: String(detail.productPerformance ?? ''), - applicableScenarios: String(detail.applicableScenarios ?? ''), - washingInstructions: String(detail.washingInstructions ?? ''), - specialDescription: String(detail.specialDescription ?? ''), - designExplanation: String(detail.designExplanation ?? ''), - designArea: String(detail.designArea ?? ''), - pictureRequest: String(detail.pictureRequest ?? ''), - sizeChartJson: JSON.stringify(detail.sizeChart ?? { columns: [], rows: [] }, null, 2), - packageSpecsJson: JSON.stringify(detail.packageSpecs ?? { rows: [] }, null, 2), - optionsJson: JSON.stringify(detail.options ?? {}, null, 2), - mediaJson: JSON.stringify(detail.media ?? {}, null, 2), - variants: g.variants.map((variant) => ({ - sku: variant.sku, - sizeId: String(variant.sizeId ?? ''), - sizeName: String(variant.sizeName ?? ''), - colorId: String(variant.colorId ?? ''), - colorName: String(variant.colorName ?? ''), - colorHex: String(variant.colorHex ?? ''), - imageUrl: String(variant.imageUrl ?? ''), - price: String(variant.price ?? ''), - originalPrice: String(variant.originalPrice ?? ''), - weightG: String(variant.weightG ?? ''), - boxLengthCm: String(variant.boxLengthCm ?? ''), - boxWidthCm: String(variant.boxWidthCm ?? ''), - boxHeightCm: String(variant.boxHeightCm ?? ''), - designDataJson: JSON.stringify(variant.designData ?? {}, null, 2), - enabled: variant.enabled, - })), - } -} - -function addCustomVariant() { - customContentForm.value.variants.push({ - sku: '', sizeId: '', sizeName: '', colorId: '', colorName: '', colorHex: '', imageUrl: '', price: '', - originalPrice: '', weightG: '', boxLengthCm: '', boxWidthCm: '', boxHeightCm: '', designDataJson: '{}', enabled: true, - }) -} - -// ─── Edit family(编辑弹窗的「关联原产品」:成员可展开查看详情并配置标签) ─── -interface FamilyMemberRow { - id: string - goodName: string - goodImage: string | null - source: string - delisted: boolean - sdsGoodId: string - goodPrice: string | null - variantCount: number - logisticsLabel: string | null - craftLabel: string | null - warehouseLabel: string | null - tagsManual: boolean - tags: Array<{ id: string; tagName: string; tagColor: string | null; manual: boolean }> -} -const editFamilyId = ref('') -const editFamilyCode = ref(null) -const editFamilyMembers = ref([]) -const familyMemberCount = ref(null) -const editFamilyLoaded = ref(false) -const editFamilyAddKw = ref('') -const editFamilyCandidates = ref>([]) -/** 展开的成员行 key(og id) */ -const expandedMemberKeys = ref>(new Set()) -/** 成员标签编辑态(key = og id) */ -const memberTagEdits = ref>({}) -const memberTagSaving = ref('') -/** 商品自身的链接定价维度(SKU 表列) */ -const editLinkDims = computed(() => linkDims(editGood.value?.originGood?.goodName ?? null)) - -function toggleMemberExpand(key: string) { - const next = new Set(expandedMemberKeys.value) - if (next.has(key)) next.delete(key) - else next.add(key) - expandedMemberKeys.value = next -} - -/** 成员行视图模型:主链接行补充商品详情接口里的实时数据 */ -const familyRows = computed(() => { - const primaryOgId = String(editGood.value?.originGoodId ?? '') - return editFamilyMembers.value.map((m) => { - const isPrimary = m.id === primaryOgId - return { - key: m.id, - role: (isPrimary ? 'primary' : 'member') as 'primary' | 'member', - ...m, - variantCount: isPrimary ? (editGood.value?.originGood?.variantCount ?? m.variantCount) : m.variantCount, - } - }) -}) - -function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) { - editFamilyId.value = family?.familyId ?? '' - editFamilyCode.value = family?.familyCode ?? null - editFamilyMembers.value = [] - familyMemberCount.value = null - editFamilyLoaded.value = false - editFamilyAddKw.value = '' - editFamilyCandidates.value = [] - expandedMemberKeys.value = new Set() - memberTagEdits.value = {} - if (editFamilyId.value) loadEditFamily() -} - -async function loadEditFamily() { - if (!editFamilyId.value) return - try { - const f = await productFamiliesApi.detail(editFamilyId.value) as any - editFamilyCode.value = f.familyCode - familyMemberCount.value = f._count.originGoods - editFamilyMembers.value = (f.originGoods ?? []).map((m: any) => ({ - id: String(m.id), - goodName: m.goodName ?? '', - goodImage: m.goodImage ?? null, - source: m.source, - delisted: Boolean(m.delisted), - sdsGoodId: m.sdsGoodId ?? '', - goodPrice: m.goodPrice ?? null, - variantCount: m._count?.variants ?? 0, - logisticsLabel: m.logisticsLabel ?? null, - craftLabel: m.craftLabel ?? null, - warehouseLabel: m.warehouseLabel ?? null, - tagsManual: Boolean(m.tagsManual), - tags: (m.originGoodTags ?? []).map((t: any) => ({ - id: String(t.tag.id), - tagName: t.tag.tagName, - tagColor: t.tag.tagColor ?? null, - manual: Boolean(t.manual), - })), - })) - // 标签编辑态 = 各成员已保存标签 - const edits: Record = {} - for (const m of editFamilyMembers.value) edits[m.id] = m.tags.map((t) => t.id) - memberTagEdits.value = edits - } finally { - editFamilyLoaded.value = true - } -} - -async function saveMemberTags(row: { id: string }) { - memberTagSaving.value = row.id - try { - await originGoodsApi.updateTags(row.id, memberTagEdits.value[row.id] ?? []) - ElMessage.success('标签已保存,自动同步不再覆盖该链接') - await refreshAfterFamilyChange() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '保存失败') - } finally { - memberTagSaving.value = '' - } -} - -async function resetMemberTags(row: { id: string }) { - memberTagSaving.value = row.id - try { - await originGoodsApi.resetTags(row.id) - ElMessage.success('已恢复按链接名称自动解析') - await refreshAfterFamilyChange() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '恢复失败') - } finally { - memberTagSaving.value = '' - } -} - -async function refreshAfterFamilyChange() { - if (!editGood.value) return - const detail = await goodsApi.getGoodById(editGood.value.id) - editGood.value = detail - initEditFamily((detail.originGood as any)?.family ?? null) +function onConfigured() { refreshLeftTree() refreshRightTree() } -async function searchFamilyCandidates(q: string) { - if (!q) { editFamilyCandidates.value = []; return } - try { - const res = await originGoodsApi.getOriginGoodsList({ keyword: q, page: 1, pageSize: 20 }) - editFamilyCandidates.value = res.items.map((o: any) => ({ id: String(o.id), goodName: o.goodName ?? o.sdsGoodId })) - } catch { editFamilyCandidates.value = [] } +// ─── Custom Good(CustomGoodDialog 组件) ─── +const customVisible = ref(false) + +function openCustomCreate() { + customVisible.value = true } -async function addFamilyMember(c: { id: string; goodName: string }) { - if (!editFamilyId.value) { ElMessage.warning('该商品尚未成族'); return } - try { - await productFamiliesApi.updateMembers(editFamilyId.value, { addOriginGoodIds: [c.id] }) - ElMessage.success('已加入族') - editFamilyAddKw.value = '' - await refreshAfterFamilyChange() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '加入失败') - } +function onCustomCreated(created: any) { + refreshLeftTree() + openEdit(created) } -async function removeFamilyMember(m: { id: string }) { - if (!editFamilyId.value) return - try { - await productFamiliesApi.updateMembers(editFamilyId.value, { removeOriginGoodIds: [m.id] }) - ElMessage.success('已移除出族') - await refreshAfterFamilyChange() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '移除失败') - } +// ─── Edit Good(GoodsEditDialog 组件) ─── +const editDialogVisible = ref(false) +const editGoodId = ref('') + +function openEdit(g: Good | GoodDetail) { + editGoodId.value = g.id + editDialogVisible.value = true } -async function promoteFamilyPrimary(m: { id: string }) { - if (!editFamilyId.value || !editGood.value) return - try { - await productFamiliesApi.patch(editFamilyId.value, { primaryOriginGoodId: m.id }) - await goodsApi.updateGood(editGood.value.id, { originGoodId: Number(m.id) } as any) - ElMessage.success('已切换主链接') - await refreshAfterFamilyChange() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '切换失败') - } +function onEditChanged() { + refreshLeftTree() + refreshRightTree() } -async function openEdit(g: Good) { - editGood.value = g - originalOriginGoodId.value = g.originGoodId - editForm.value = { - id: g.id, goodName: g.goodName, - goodImage: g.goodImage || g.originGood?.goodImage || '', - countryId: g.countryId, - cascaderCategory: findCategoryPath(allCategories.value, g.categoryId), - categoryId: g.categoryId, - positionId: g.positionId || '', - } - initEditFamily((g as any).originGood?.family ?? null) - editVisible.value = true - editDetailLoading.value = true +async function handleDeleteGood(g: Good) { try { - const detail = await goodsApi.getGoodById(g.id) - editGood.value = detail - initEditFamily((detail.originGood as any)?.family ?? null) - if (detail.originGood?.isCustom) fillCustomContent(detail) - } catch { - ElMessage.warning('商品详情加载失败,当前显示列表数据') - } finally { - editDetailLoading.value = false - } + await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', { + type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', + }) + } catch { return } + await goodsApi.deleteGood(g.id) + ElMessage.success('删除成功') + refreshLeftTree() + refreshRightTree() } async function handleSyncOriginDetail(data: any) { @@ -874,132 +479,6 @@ async function handleSyncOriginDetail(data: any) { } } -async function handleSyncOneDetail() { - if (editIsCustom.value) return - const goodId = editGood.value?.originGood?.sdsGoodId - if (!goodId) return - detailSyncing.value = true - try { - const result = await syncApi.syncOneProductDetail(goodId) - const detail = await goodsApi.getGoodById(editGood.value!.id) - editGood.value = detail - ElMessage.success(`详情同步完成,共 ${result.variants} 个 SKU`) - await Promise.all([refreshLeftTree(), refreshRightTree()]) - } catch (error: any) { - ElMessage.error(error?.response?.data?.message || '商品详情同步失败') - } finally { - detailSyncing.value = false - } -} - -async function handleEditSubmit() { - let customPayload: any = null - if (editIsCustom.value) { - let sizeChart: Record - let packageSpecs: Record - let options: Record - let media: Record - try { - sizeChart = JSON.parse(customContentForm.value.sizeChartJson) - packageSpecs = JSON.parse(customContentForm.value.packageSpecsJson) - options = JSON.parse(customContentForm.value.optionsJson) - media = JSON.parse(customContentForm.value.mediaJson) - for (const variant of customContentForm.value.variants) JSON.parse(variant.designDataJson) - } catch { - ElMessage.error('尺码表、包装规格、选项、媒体或 SKU 设计数据不是有效 JSON') - return - } - if (customContentForm.value.variants.some((variant) => !variant.sku.trim())) { - ElMessage.error('SKU 不能为空') - return - } - customPayload = { - goodName: editForm.value.goodName, - goodImage: editForm.value.goodImage || null, - goodPrice: customContentForm.value.goodPrice || null, - detail: { - productCode: customContentForm.value.productCode || null, - englishName: customContentForm.value.englishName || null, - productionCycleHours: customContentForm.value.productionCycleHours ?? null, - minWeightG: customContentForm.value.minWeightG || null, - productionProcess: customContentForm.value.productionProcess || null, - materialDescription: customContentForm.value.materialDescription || null, - blankDesignUrl: customContentForm.value.blankDesignUrl || null, - detailsPageVideoUrl: customContentForm.value.detailsPageVideoUrl || null, - textureName: customContentForm.value.textureName || null, - reminder: customContentForm.value.reminder || null, - productPerformance: customContentForm.value.productPerformance || null, - applicableScenarios: customContentForm.value.applicableScenarios || null, - washingInstructions: customContentForm.value.washingInstructions || null, - specialDescription: customContentForm.value.specialDescription || null, - designExplanation: customContentForm.value.designExplanation || null, - designArea: customContentForm.value.designArea || null, - pictureRequest: customContentForm.value.pictureRequest || null, - sizeChart, - packageSpecs, - options, - media, - }, - variants: customContentForm.value.variants.map((variant: any, index: number) => ({ - sku: variant.sku.trim(), - sizeId: variant.sizeId || null, - sizeName: variant.sizeName || null, - colorId: variant.colorId || null, - colorName: variant.colorName || null, - colorHex: variant.colorHex || null, - imageUrl: variant.imageUrl || null, - price: variant.price || null, - originalPrice: variant.originalPrice || null, - weightG: variant.weightG || null, - boxLengthCm: variant.boxLengthCm || null, - boxWidthCm: variant.boxWidthCm || null, - boxHeightCm: variant.boxHeightCm || null, - designData: JSON.parse(variant.designDataJson), - enabled: variant.enabled, - sortOrder: index, - })), - } - } - editLoading.value = true - try { - await goodsApi.updateGood(editForm.value.id, { - goodName: editForm.value.goodName, - goodImage: editForm.value.goodImage || null, - originGoodId: editGood.value?.originGoodId !== originalOriginGoodId.value && !editIsCustom.value - ? Number(editGood.value!.originGoodId) - : undefined, - countryId: Number(editForm.value.countryId), - categoryId: Number(editForm.value.categoryId), - positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, - } as any) - if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload) - ElMessage.success('更新成功') - editVisible.value = false - refreshLeftTree() - refreshRightTree() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '更新失败') - } finally { editLoading.value = false } -} - -function onEditCascaderChange(val: any) { - const path = Array.isArray(val) ? val : [] - editForm.value.categoryId = path.length ? String(path[path.length - 1]) : '' -} - -async function handleDeleteGood(g: Good) { - try { - await ElMessageBox.confirm(`确定删除「${g.goodName}」吗?`, '确认', { - type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', - }) - } catch { return } - await goodsApi.deleteGood(g.id) - ElMessage.success('删除成功') - editVisible.value = false - refreshLeftTree() - refreshRightTree() -} - // ─── Right tree locate ─── function findRightTreePath(nodes: any[], targetId: string): string[] | null { for (const node of nodes) { @@ -1225,172 +704,12 @@ async function reloadCountries() { allCountries.value = Array.isArray(res) ? res : (res.items ?? []) } -// ─── Tag edit modal (from filter dropdown) ─── -const tagEditVisible = ref(false) -const tagEditForm = ref({ id: '', tagName: '', tagColor: '#ff6800', tagFontColor: '#ffffff' }) -const tagEditLoading = ref(false) - -function openTagEditFromFilter(t: Tag) { - tagEditForm.value = { id: t.id, tagName: t.tagName, tagColor: t.tagColor || '#ff6800', tagFontColor: t.tagFontColor || '#ffffff' } - tagEditVisible.value = true -} - -async function handleTagEditSubmit() { - if (!tagEditForm.value.tagName.trim()) { ElMessage.warning('请输入标签名称'); return } - tagEditLoading.value = true - try { - await tagsApi.updateTag(tagEditForm.value.id, { tagName: tagEditForm.value.tagName.trim(), tagColor: tagEditForm.value.tagColor, tagFontColor: tagEditForm.value.tagFontColor } as any) - ElMessage.success('保存成功') - tagEditVisible.value = false - await reloadTags() - } catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') } - finally { tagEditLoading.value = false } -} - -async function handleTagEditDelete() { - try { - await ElMessageBox.confirm(`确定删除标签「${tagEditForm.value.tagName}」吗?`, '确认', { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }) - } catch { return } - try { - await tagsApi.deleteTag(tagEditForm.value.id) - ElMessage.success('删除成功') - tagEditVisible.value = false - await reloadTags() - } catch { ElMessage.error('删除失败') } -} - async function reloadTags() { const res = await tagsApi.getTagsList({ page: 1, pageSize: 200 } as any) as any allTags.value = Array.isArray(res) ? res : (res.items ?? []) allTagGroups.value = await tagGroupsApi.getTagGroupsList() } -// ─── Group edit modal ─── -const groupEditVisible = ref(false) -const groupEditLoading = ref(false) -const groupEditForm = ref<{ id: string; groupName: string }>({ id: '', groupName: '' }) - -function openGroupEdit(node: TreeNode): void { - // Don't allow editing the virtual "未分组" node - if (node.id === 'g-ungrouped') return - groupEditForm.value = { id: node.rawId!, groupName: node.label } - groupEditVisible.value = true -} - -async function handleGroupSave(): Promise { - const name = groupEditForm.value.groupName.trim() - if (!name) { ElMessage.warning('请输入分组名称'); return } - groupEditLoading.value = true - try { - await tagGroupsApi.updateTagGroup(groupEditForm.value.id, { groupName: name }) - ElMessage.success('已保存') - groupEditVisible.value = false - await reloadTags() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '保存失败') - } finally { - groupEditLoading.value = false - } -} - -async function handleGroupDelete(): Promise { - try { - await ElMessageBox.confirm( - `删除分组「${groupEditForm.value.groupName}」后,组内标签将归为「未分组」。确认删除?`, - '确认删除', - { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }, - ) - } catch { return } - groupEditLoading.value = true - try { - await tagGroupsApi.deleteTagGroup(groupEditForm.value.id) - ElMessage.success('已删除') - groupEditVisible.value = false - await reloadTags() - } catch (e: any) { - ElMessage.error(e?.response?.data?.message || '删除失败') - } finally { - groupEditLoading.value = false - } -} - -// ─── Tag tree (filter dropdown) ─── -interface TreeNode { - id: string - rawId: string | null - type: 'group' | 'tag' - label: string - sortOrder?: number - disabled?: boolean - children?: TreeNode[] -} - -const tagPopoverVisible = ref(false) -const hoveredNodeId = ref(null) - -const MAX_VISIBLE_TAGS = 1 - -const displayedSelectedTagIds = computed(() => - selectedTagIds.value.slice(0, MAX_VISIBLE_TAGS), -) -const hiddenSelectedCount = computed(() => - Math.max(0, selectedTagIds.value.length - MAX_VISIBLE_TAGS), -) - -function getTagName(id: string): string { - return allTags.value.find((t) => t.id === id)?.tagName ?? id -} - -function removeSelectedTag(id: string): void { - const idx = selectedTagIds.value.indexOf(id) - if (idx >= 0) selectedTagIds.value.splice(idx, 1) -} - -const tagTreeData = computed(() => { - const groupNodes: TreeNode[] = allTagGroups.value - .slice() - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((g) => ({ - id: `g-${g.id}`, - rawId: g.id, - type: 'group', - label: g.groupName, - sortOrder: g.sortOrder, - disabled: true, - children: allTags.value - .filter((t) => t.tagGroupId === g.id) - .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) - .map((t) => ({ - id: `t-${t.id}`, - rawId: t.id, - type: 'tag', - label: t.tagName, - })), - })) - - const ungrouped: TreeNode[] = allTags.value - .filter((t) => !t.tagGroupId) - .sort((a, b) => a.tagName.localeCompare(b.tagName)) - .map((t) => ({ - id: `t-${t.id}`, - rawId: t.id, - type: 'tag' as const, - label: t.tagName, - })) - - if (ungrouped.length > 0) { - groupNodes.push({ - id: 'g-ungrouped', - rawId: null, - type: 'group', - label: '未分组', - disabled: true, - children: ungrouped, - }) - } - return groupNodes -}) - const groupedTagOptions = computed(() => { const groups = allTagGroups.value .slice() @@ -1411,41 +730,6 @@ const groupedTagOptions = computed(() => { return groups }) -// ─── 派生标签已下沉到链接级(origin_good_tags):在「关联原产品」成员行内配置 ─── - -function toggleTagInSelection(tagId: string): void { - const idx = selectedTagIds.value.indexOf(tagId) - if (idx >= 0) selectedTagIds.value.splice(idx, 1) - else selectedTagIds.value.push(tagId) -} - -function onTreeNodeClick(data: TreeNode): void { - if (data.type === 'group' && !data.disabled) { - // Click group = toggle all its tags - const tagIds = (data.children ?? []).map((c) => c.rawId!).filter(Boolean) - if (tagIds.length === 0) return - const allSelected = tagIds.every((id) => selectedTagIds.value.includes(id)) - if (allSelected) { - // Deselect all - tagIds.forEach((id) => { - const idx = selectedTagIds.value.indexOf(id) - if (idx >= 0) selectedTagIds.value.splice(idx, 1) - }) - } else { - // Select all (add missing ones) - tagIds.forEach((id) => { - if (!selectedTagIds.value.includes(id)) selectedTagIds.value.push(id) - }) - } - } - // Tag click is handled by its checkbox -} - -function openTagEditFromFilterById(id: string): void { - const t = allTags.value.find((tag) => tag.id === id) - if (t) openTagEditFromFilter(t) -} - // ─── Country edit (from filter dropdown) ─── function openCountryEditFromFilter(c: Country) { countryEditMode.value = 'edit' @@ -1453,33 +737,6 @@ function openCountryEditFromFilter(c: Country) { countryEditVisible.value = true } -// ─── Quick create (inline in edit/config modal) ─── -async function quickCreateCountry(targetForm: () => void) { - try { - const { value } = await ElMessageBox.prompt('请输入国家名称', '新增国家', { - confirmButtonText: '新增', cancelButtonText: '取消', inputPlaceholder: '国家名称', - }) - if (!value.trim()) return - await countriesApi.createCountry({ countryName: value.trim() } as any) - await reloadCountries() - targetForm() - ElMessage.success('已创建并选中') - } 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 ─── @@ -1631,88 +888,12 @@ onMounted(() => loadAll()) - - - -
-
- 新建分组 -
- - - -
-
+ @@ -1995,302 +1176,36 @@ onMounted(() => loadAll()) - - -
-
-
{{ cleanLinkName(configOG.goodName) }}
-
SDS ID: {{ configOG.sdsGoodId }}
-
-
- - -
- - - - -
- {{ allCountries.find(c => c.id === configForm.countryId)?.countryName }} -
- - - {{ configDropTarget?.label }} - - -
-
勾选同分类下同名(不同工厂/仓库)原产品,合并为一个商品;主链接决定价格与详情。
-
- - 主链接:{{ cleanLinkName(configOG.goodName) }} - {{ cleanLinkName(s.goodName) }} - -
- - - {{ cleanLinkName(s.goodName) }} - - -
-
- - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- 关联原产品 - - - - 族 {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接 -
-
- -
族成员加载中…
-
暂未成族:配置合并时自动成族
-
- - - -
- - - - - - -
- - - - -
-
- - - - - - -
- - - - - - - 小时 - - - - - - - - - - - - - - - - - - - {{ editOriginDetail.productCode || '-' }} - {{ editOriginDetail.englishName || '-' }} - {{ editOriginDetail.productionCycleHours ?? '-' }} 小时 - {{ editOriginDetail.minWeightG ?? '-' }} g - {{ editOriginDetail.productionProcess || '-' }} - {{ editOriginDetail.materialDescription || '-' }} - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ + + + @@ -2315,49 +1230,6 @@ onMounted(() => loadAll()) 保存 - - - - - - -
- - -
-
- -
- - -
-
-
- -
- - - - - - - - - - @@ -2604,18 +1476,6 @@ onMounted(() => loadAll()) .origin-ref { display: flex; align-items: center; gap: 8px; } -.edit-merged-box { margin-top: 12px; } -.edit-merged-title { - display: flex; align-items: center; gap: 4px; - font-size: 12px; color: #909399; margin-bottom: 8px; -} -.edit-family-code { margin-left: auto; color: var(--el-color-primary); } -.edit-family-loading { color: var(--el-text-color-placeholder); font-size: 12px; padding: 4px 0; } -.derived-tags-note { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 4px; } -.derived-tags-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-top: 4px; } -.derived-tags-readonly { margin-top: 0; row-gap: 2px; } -.derived-tags-label { font-size: 12px; 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); @@ -2625,66 +1485,6 @@ onMounted(() => loadAll()) 0%, 100% { background: transparent; } 50% { background: var(--el-color-primary-light-7); box-shadow: inset 0 0 0 1px var(--el-color-primary-light-5); } } -.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } -.edit-merged-item { - display: flex; align-items: center; gap: 8px; - padding: 6px 10px; background: #f5f7fa; border-radius: 6px; font-size: 13px; -} -.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: 4px; } -.member-chips { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: nowrap; overflow: hidden; } -.member-chip { - font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px; - background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap; -} -.member-chip.is-manual { background: #f0f9eb; color: var(--el-color-success); } -.member-chip.is-preview { background: #f4f4f5; color: #909399; border: 1px dashed #dcdfe6; } -.member-manual-flag { - font-size: 11px; line-height: 16px; padding: 0 6px; border-radius: 4px; - background: #f0f9eb; color: var(--el-color-success); flex-shrink: 0; -} -.member-expand-toggle { - cursor: pointer; flex-shrink: 0; color: #909399; - transition: transform 0.18s; -} -.member-expand-toggle.is-expanded { transform: rotate(180deg); } -.member-detail-panel { - background: #fbfcfe; border: 1px solid #ebeef5; border-radius: 6px; - padding: 10px 12px; margin: -2px 0 2px 30px; -} -.member-detail-grid { - display: grid; grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr); - gap: 4px 10px; font-size: 12px; margin-bottom: 8px; -} -.md-label { color: #909399; } -.md-val { color: #606266; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.member-tags-edit { display: flex; align-items: center; gap: 8px; } -.member-tags-edit .md-label { flex-shrink: 0; } -.sku-dims-note { font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; } -.edit-merged-tag { - padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px; - background: var(--el-color-primary); color: #fff; flex-shrink: 0; -} -.edit-merged-tag.sub { background: var(--el-color-info); } -.detail-tabs { margin-top: 12px; padding-top: 4px; border-top: 1px solid #ebeef5; } - -/* Config modal */ -.config-og-info { padding: 12px; background: #f5f7fa; border-radius: 8px; } -.config-og-name { font-weight: 600; font-size: 14px; } -.config-og-meta { color: #909399; font-size: 12px; margin-top: 2px; } -.config-merge-box { width: 100%; } -.config-merge-tip { color: #909399; font-size: 12px; margin-bottom: 8px; line-height: 1.5; } -.config-merge-primary { - padding: 8px 10px; background: #f5f7fa; border-radius: 6px; margin-bottom: 8px; -} -.config-merge-primary .el-radio-group { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; } -.config-merge-box .el-checkbox-group { display: flex; flex-direction: column; align-items: flex-start; max-height: 160px; overflow-y: auto; } - /* Tag mgmt */ .tag-mgmt { max-height: 400px; overflow-y: auto; } .tag-mgmt-form { display: flex; gap: 6px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; } @@ -2754,140 +1554,4 @@ onMounted(() => loadAll()) width: 6px; height: 6px; border-radius: 50%; background: var(--c, #ccc); } -/* ─── Tag filter popover ─── */ - -/* Custom trigger that looks like el-select */ -.tag-select-trigger { - display: inline-flex; - align-items: center; - gap: 4px; - width: 180px; - min-height: 24px; - padding: 0 8px; - border: 1px solid #dcdfe6; - border-radius: 4px; - background: #fff; - font-size: 12px; - color: #606266; - cursor: pointer; - transition: border-color 0.2s; - box-sizing: border-box; -} -.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 .tag-arrow { - margin-left: auto; - 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 .more-tag { - display: inline-flex; - align-items: center; - height: 20px; - padding: 0 6px; - background: #f4f4f5; - border-radius: 3px; - font-size: 11px; - color: #606266; -} - -/* Popover content */ -.tag-filter-popper { - padding: 0 !important; -} -.tag-filter-popper .tag-tree-panel { - max-height: 380px; - 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; -} - -/* Tree row layout */ -.tree-row { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 4px 6px; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - position: relative; -} -.tree-row.is-group { - font-weight: 600; - color: #111; - background: #fafafa; -} -.tree-row.is-group:hover { - background: #f0f0f0; -} -.tree-row.is-tag:hover { - background: #f5f7fa; -} -.tree-row.is-tag.is-checked { - background: #fff2e8; - color: #ff6a00; -} -.node-icon { - color: #f59e0b; - font-size: 12px; - width: 14px; - text-align: center; - flex-shrink: 0; -} -.node-label { - flex: 1; - user-select: none; -} -.node-count { - display: inline-block; - margin-left: 4px; - padding: 0 5px; - font-size: 10px; - color: #909399; - background: #e9e9eb; - border-radius: 8px; - font-weight: 400; -} -.node-edit-input { - flex: 1; - font-size: 13px; - border: 1px solid #ff6a00; - border-radius: 3px; - padding: 2px 6px; - outline: none; - font-weight: 600; -} -.node-actions { - display: inline-flex; - gap: 0; - margin-left: auto; - flex-shrink: 0; -} -.node-actions :deep(.el-button) { - padding: 2px 4px; -} diff --git a/apps/admin/src/views/goods/components/CustomGoodDialog.vue b/apps/admin/src/views/goods/components/CustomGoodDialog.vue new file mode 100644 index 0000000..e1587be --- /dev/null +++ b/apps/admin/src/views/goods/components/CustomGoodDialog.vue @@ -0,0 +1,123 @@ + + + diff --git a/apps/admin/src/views/goods/components/GoodsConfigDialog.vue b/apps/admin/src/views/goods/components/GoodsConfigDialog.vue new file mode 100644 index 0000000..ece9c43 --- /dev/null +++ b/apps/admin/src/views/goods/components/GoodsConfigDialog.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/apps/admin/src/views/goods/components/GoodsEditDialog.vue b/apps/admin/src/views/goods/components/GoodsEditDialog.vue new file mode 100644 index 0000000..1d31b16 --- /dev/null +++ b/apps/admin/src/views/goods/components/GoodsEditDialog.vue @@ -0,0 +1,806 @@ + + + + + diff --git a/apps/admin/src/views/goods/components/TagFilterPopover.vue b/apps/admin/src/views/goods/components/TagFilterPopover.vue new file mode 100644 index 0000000..a080f09 --- /dev/null +++ b/apps/admin/src/views/goods/components/TagFilterPopover.vue @@ -0,0 +1,478 @@ + + + + + + + diff --git a/apps/api/src/product-families/auto-tag-rules.spec.ts b/apps/api/src/product-families/auto-tag-rules.spec.ts index 01eaeac..16ad9d2 100644 --- a/apps/api/src/product-families/auto-tag-rules.spec.ts +++ b/apps/api/src/product-families/auto-tag-rules.spec.ts @@ -13,10 +13,14 @@ describe('auto-tag-rules / deriveLinkTagNames', () => { ).toEqual(['双面印花', '烫画', '不包邮']); }); - it('不打印 + 光板(物流备注)+ 不包邮 → 两个工艺标签 + 不包邮,无印花数量标签', () => { + it('不打印 + 光板(物流备注)+ 不包邮 → 归并为单个 不打印 工艺标签(光板即不打印)', () => { expect( deriveLinkTagNames('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓'), - ).toEqual(['不打印', '光板', '不包邮']); + ).toEqual(['不打印', '不包邮']); + }); + + it('仅光板(无不打印字样)同样归为 不打印', () => { + expect(deriveLinkTagNames('美国(包邮光板)T恤-DG001')).toEqual(['不打印', '包邮']); }); it('直喷命中时不给默认烫画', () => { diff --git a/apps/api/src/product-families/auto-tag-rules.ts b/apps/api/src/product-families/auto-tag-rules.ts index b9defba..fd2ae32 100644 --- a/apps/api/src/product-families/auto-tag-rules.ts +++ b/apps/api/src/product-families/auto-tag-rules.ts @@ -24,14 +24,19 @@ export interface DerivedTagGroupSpec { tags: string[]; } -/** 派生标签所属组及其全部合法取值 */ +/** 派生标签所属组及其全部合法取值(光板 = 不打印,不单独设标签) */ export const DERIVED_TAG_GROUP_SPECS: DerivedTagGroupSpec[] = [ { group: '物流渠道', tags: ['包邮', '不包邮'] }, { group: '印花数量', tags: ['单面印花', '双面印花'] }, - { group: '印刷工艺', tags: ['烫画', '直喷', '不打印', '光板'] }, + { group: '印刷工艺', tags: ['烫画', '直喷', '不打印'] }, ]; -const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; +/** 工艺关键字 → 标签名(光板即为不打印) */ +const CRAFT_KEYWORD_MAP: Record = { + 直喷: '直喷', + 不打印: '不打印', + 光板: '不打印', +}; const CRAFT_DEFAULT = '烫画'; /** @@ -45,8 +50,11 @@ export function deriveLinkTagNames(name: string | null | undefined): string[] { if (name.includes('双面印花')) names.push('双面印花'); else if (name.includes('单面印花')) names.push('单面印花'); - const craftHits = CRAFT_KEYWORDS.filter((keyword) => name.includes(keyword)); - if (craftHits.length > 0) names.push(...craftHits); + const craftTags = new Set(); + for (const [keyword, tagName] of Object.entries(CRAFT_KEYWORD_MAP)) { + if (name.includes(keyword)) craftTags.add(tagName); + } + if (craftTags.size > 0) names.push(...craftTags); else names.push(CRAFT_DEFAULT); if (name.includes('不包邮')) names.push('不包邮'); diff --git a/apps/api/src/product-families/family-tag-sync.spec.ts b/apps/api/src/product-families/family-tag-sync.spec.ts index a453e31..1f49636 100644 --- a/apps/api/src/product-families/family-tag-sync.spec.ts +++ b/apps/api/src/product-families/family-tag-sync.spec.ts @@ -123,15 +123,18 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { expect(r1.linksUpdated).toBe(2); expect(r1.goodsUpdated).toBe(2); - // 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印/光板/不包邮 + // 链接级:og1 → 单面印花/烫画/包邮;og2 → 不打印(光板归并为不打印)/不包邮 expect(await linkTagNames(og1.id)).toEqual(['包邮', '烫画', '单面印花']); - expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印', '光板']); + expect(await linkTagNames(og2.id)).toEqual(['不包邮', '不打印']); // 商品级:镜像各自链接(+人工分组保留,旧自动组剔除) const names1 = await goodTagNames(good1.id); expect(names1).toEqual(expect.arrayContaining(['包邮', '烫画', '单面印花', `潮流${stamp}`])); expect(names1).not.toContain('单面印'); - expect(await goodTagNames(good2.id)).toEqual(['不包邮', '不打印', '光板']); + const names2 = await goodTagNames(good2.id); + expect(names2).toEqual(['不包邮', '不打印']); + expect(names2).not.toContain('光板'); + expect(names2).not.toContain('烫画'); // 幂等 const r2 = await recompute.syncFamilyTags(family.id); diff --git a/docs/references/product-center.md b/docs/references/product-center.md index 02015a3..b9210a2 100644 --- a/docs/references/product-center.md +++ b/docs/references/product-center.md @@ -129,14 +129,18 @@ pnpm --filter @inkreach/api backfill:product-families **后台操作入口(族替代旧主源/副源,界面保持原有布局)**: - 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」 - 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族); -- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,主/族成员行可**逐个展开**—— - 查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库 / 原始名称)、 - **配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生);成员行仍支持 - `设为主链接` / `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新); -- 编辑弹窗 SKU 表新增 **物流 / 工艺 / 印花数量** 三列:价格由 - `物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定(族内同组合取最低价,人工改价走族价格覆盖); -- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用。 + 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);族内**不区分主次** + (成员列表无设为主链接按钮,主链接仅作为详情数据源的内部实现); +- 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,成员行(原始链接名,便于核对) + 可**逐个展开**——查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库)、 + **配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生)、 + **改价格**(尺码 × 颜色 → 价格表格,写入族价格覆盖,人工格子可「还原」回推导价); + 成员行支持 `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新); +- 编辑弹窗 SKU 表含 **物流 / 工艺 / 印花数量** 维度列(价格由 + `物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定); +- 人工改价/自动成族等族管理 API(`/product-families/*`)保留,供脚本或后续界面使用; +- 页面组件化:`GoodsView.vue` 拆出 `components/` 下的 GoodsEditDialog(编辑弹窗)、 + GoodsConfigDialog(配置弹窗)、CustomGoodDialog(自定义商品)、TagFilterPopover(标签筛选弹层)。 **链接级标签(自动派生 + 人工修正,2026-08 规则改版)**: @@ -146,8 +150,8 @@ pnpm --filter @inkreach/api backfill:product-families - 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端 `utils/origin-name.ts#deriveLinkTagNames` 同构): - **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」); - - **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」); - 都不含 → 默认 `烫画`; + - **工艺**:名称含「直喷」→ `直喷`;含「不打印」或「光板」→ `不打印`(**光板即为不打印**, + 组「印刷工艺」);都不含 → 默认 `烫画`; - **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中); - 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行; **人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签; diff --git a/docs/references/structs.md b/docs/references/structs.md index 308eb8c..870ecb7 100644 --- a/docs/references/structs.md +++ b/docs/references/structs.md @@ -163,7 +163,9 @@ apps/admin/ │ ├── types/index.ts # 共享类型 │ ├── views/ │ │ ├── login/LoginView.vue # 登录 -│ │ ├── goods/GoodsView.vue # 商品配置(编辑弹窗内管理族成员:设为主链接/移除出族/搜索添加) +│ │ ├── goods/GoodsView.vue # 商品配置主页面(左右树 + 筛选 + 全局列表) +│ │ ├── goods/components/ # 弹窗/弹层组件:GoodsEditDialog(编辑+族成员标签/改价)、 +│ │ │ # GoodsConfigDialog(配置合并)、CustomGoodDialog、TagFilterPopover │ │ ├── categories/CategoriesView.vue │ │ ├── countries/CountriesView.vue │ │ ├── tags/TagsView.vue