feat(admin): expandable family members with per-link tag config + SKU pricing dims columns

This commit is contained in:
yeuimu
2026-08-28 16:00:46 +08:00
parent 08201f18a4
commit 6f22a6fd1f
7 changed files with 312 additions and 122 deletions
+15 -1
View File
@@ -1,5 +1,5 @@
import request from './request' import request from './request'
import type { OriginGood, OriginGoodsTreeResponse, PaginatedResult } from '@/types' import type { OriginGood, OriginGoodsTreeResponse, OriginGoodTagsResult, PaginatedResult } from '@/types'
export const originGoodsApi = { export const originGoodsApi = {
getTree: () => { getTree: () => {
@@ -9,4 +9,18 @@ export const originGoodsApi = {
getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => { getOriginGoodsList: (params: { page?: number; pageSize?: number; keyword?: string }) => {
return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params }) return request.get<any, PaginatedResult<OriginGood>>('/origin-goods', { params })
}, },
getTags: (id: string) => {
return request.get<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
// 人工接管链接标签(全量替换,自动同步不再覆盖)
updateTags: (id: string, tagIds: string[]) => {
return request.put<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`, { tagIds: tagIds.map(Number) })
},
// 恢复自动派生(清掉人工标签)
resetTags: (id: string) => {
return request.delete<any, OriginGoodTagsResult>(`/origin-goods/${id}/tags`)
},
} }
+18
View File
@@ -402,17 +402,35 @@ export interface OriginGoodsTreeResponse {
} }
// Product Family (SPU layer) types // Product Family (SPU layer) types
export interface OriginGoodTagInfo {
id: string
tagName: string
tagColor: string | null
tagFontColor: string | null
manual: boolean
}
/** 链接标签配置结果(origin-goods/:id/tags */
export interface OriginGoodTagsResult {
tagsManual: boolean
tags: OriginGoodTagInfo[]
}
export interface ProductFamilyMember { export interface ProductFamilyMember {
id: string id: string
sdsGoodId: string sdsGoodId: string
goodName: string goodName: string
goodImage: string | null goodImage: string | null
goodPrice: string | null
source: 'SDS' | 'CUSTOM' source: 'SDS' | 'CUSTOM'
delisted: boolean delisted: boolean
skuCode: string | null skuCode: string | null
logisticsLabel: string | null logisticsLabel: string | null
craftLabel: string | null craftLabel: string | null
warehouseLabel: string | null warehouseLabel: string | null
tagsManual: boolean
originGoodTags: Array<{ id: string; manual: boolean; tag: OriginGoodTagInfo }>
variantCount: number
} }
export interface FamilyPriceOverrideRow { export interface FamilyPriceOverrideRow {
+21
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
cleanLinkName, cleanLinkName,
deriveLinkTagNames, deriveLinkTagNames,
linkDims,
parseLinkName, parseLinkName,
sameOriginGroup, sameOriginGroup,
truncateToProcess, truncateToProcess,
@@ -106,3 +107,23 @@ describe('deriveLinkTagNames', () => {
expect(deriveLinkTagNames(null)).toEqual([]); expect(deriveLinkTagNames(null)).toEqual([]);
}); });
}); });
describe('linkDims', () => {
it('解析物流备注/工艺/印花数量', () => {
expect(linkDims('美国(包邮)180g纯棉T恤成人款-DG001-单面印花')).toEqual({
logistics: '包邮',
crafts: ['烫画'],
printCount: '单面印花',
});
expect(linkDims('美国(不包邮光板)180GT恤成人款-JSA002-不打印·美西洛杉矶二仓')).toEqual({
logistics: '不包邮光板',
crafts: ['不打印', '光板'],
printCount: null,
});
});
it('无括号结构时物流为 null,手工名称工艺默认烫画', () => {
expect(linkDims('纯棉T恤')).toEqual({ logistics: null, crafts: ['烫画'], printCount: null });
expect(linkDims(null)).toEqual({ logistics: null, crafts: [], printCount: null });
});
});
+29
View File
@@ -52,6 +52,35 @@ export function cleanLinkName(name: string | null | undefined): string {
const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const; const CRAFT_KEYWORDS = ['直喷', '不打印', '光板'] as const;
/** 链接的定价维度(SKU 表展示用):物流备注 / 工艺 / 印花数量 */
export interface LinkDims {
logistics: string | null;
crafts: string[];
printCount: string | null;
}
/** 从链接名称解析定价维度;工艺无命中时默认烫画(与派生标签规则一致) */
export function linkDims(name: string | null | undefined): LinkDims {
if (!name) return { logistics: null, crafts: [], printCount: null };
const head = name.split('-')[0] ?? '';
const openCandidates = [head.indexOf(''), head.indexOf('(')].filter((i) => i >= 0);
const openIdx = openCandidates.length ? Math.min(...openCandidates) : -1;
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 printCount = name.includes('双面印花')
? '双面印花'
: name.includes('单面印花')
? '单面印花'
: null;
return {
logistics,
crafts: craftHits.length > 0 ? craftHits : ['烫画'],
printCount,
};
}
/** /**
* 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示): * 由链接名称派生标签名(与 api 端 auto-tag-rules.ts 规则一致,仅用于成员行只读展示):
* 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画; * 印花数量:双面印花 优先于 单面印花;工艺:直喷/不打印/光板,皆无则默认烫画;
+210 -106
View File
@@ -18,7 +18,7 @@ import { tagGroupsApi } from '@/api/tag-groups'
import { originGoodsApi } from '@/api/origin-goods' import { originGoodsApi } from '@/api/origin-goods'
import { syncApi } from '@/api/sync' import { syncApi } from '@/api/sync'
import { sameFamily } from '@/utils/family-match' import { sameFamily } from '@/utils/family-match'
import { cleanLinkName, deriveLinkTagNames, truncateToProcess } from '@/utils/origin-name' import { cleanLinkName, deriveLinkTagNames, linkDims, truncateToProcess } from '@/utils/origin-name'
import { productFamiliesApi } from '@/api/product-families' import { productFamiliesApi } from '@/api/product-families'
const mode = ref<'category' | 'country' | 'global'>('category') const mode = ref<'category' | 'country' | 'global'>('category')
@@ -566,7 +566,7 @@ const editGood = ref<Good | GoodDetail | null>(null)
const originalOriginGoodId = ref<string>('') const originalOriginGoodId = ref<string>('')
const editForm = ref({ const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[], id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '', categoryId: '', positionId: '',
}) })
const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null) const editOriginDetail = computed(() => (editGood.value as GoodDetail | null)?.originDetail ?? null)
@@ -654,14 +654,57 @@ function addCustomVariant() {
}) })
} }
// ─── Edit family(编辑弹窗的族成员管理,替代旧主源/副源 ─── // ─── 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<string>('') const editFamilyId = ref<string>('')
const editFamilyCode = ref<string | null>(null) const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<Array<{ id: string; goodName: string; goodImage: string | null }>>([]) const editFamilyMembers = ref<FamilyMemberRow[]>([])
const familyMemberCount = ref<number | null>(null) const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false) const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('') const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([]) const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
/** 展开的成员行 keyog id */
const expandedMemberKeys = ref<Set<string>>(new Set())
/** 成员标签编辑态(key = og id */
const memberTagEdits = ref<Record<string, string[]>>({})
const memberTagSaving = ref<string>('')
/** 商品自身的链接定价维度(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) { function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? '' editFamilyId.value = family?.familyId ?? ''
@@ -671,24 +714,72 @@ function initEditFamily(family: { familyId?: string; familyCode?: string | null
editFamilyLoaded.value = false editFamilyLoaded.value = false
editFamilyAddKw.value = '' editFamilyAddKw.value = ''
editFamilyCandidates.value = [] editFamilyCandidates.value = []
expandedMemberKeys.value = new Set()
memberTagEdits.value = {}
if (editFamilyId.value) loadEditFamily() if (editFamilyId.value) loadEditFamily()
} }
async function loadEditFamily() { async function loadEditFamily() {
if (!editFamilyId.value) return if (!editFamilyId.value) return
try { try {
const f = await productFamiliesApi.detail(editFamilyId.value) const f = await productFamiliesApi.detail(editFamilyId.value) as any
editFamilyCode.value = f.familyCode editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods familyMemberCount.value = f._count.originGoods
const primaryId = f.primaryOriginGoodId ?? editGood.value?.originGoodId editFamilyMembers.value = (f.originGoods ?? []).map((m: any) => ({
editFamilyMembers.value = f.originGoods id: String(m.id),
.filter((m) => String(m.id) !== String(primaryId)) goodName: m.goodName ?? '',
.map((m) => ({ id: String(m.id), goodName: m.goodName, goodImage: m.goodImage })) 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<string, string[]> = {}
for (const m of editFamilyMembers.value) edits[m.id] = m.tags.map((t) => t.id)
memberTagEdits.value = edits
} finally { } finally {
editFamilyLoaded.value = true 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() { async function refreshAfterFamilyChange() {
if (!editGood.value) return if (!editGood.value) return
const detail = await goodsApi.getGoodById(editGood.value.id) const detail = await goodsApi.getGoodById(editGood.value.id)
@@ -750,11 +841,6 @@ async function openEdit(g: Good) {
countryId: g.countryId, countryId: g.countryId,
cascaderCategory: findCategoryPath(allCategories.value, g.categoryId), cascaderCategory: findCategoryPath(allCategories.value, g.categoryId),
categoryId: g.categoryId, categoryId: g.categoryId,
// 有族时:自动组(物流/工艺/位置)标签为派生数据,不进入可编辑选择
tagIds: (g.tags || []).filter((t: any) => {
const og = (g as any).originGood?.family
return !og || !t.tagGroupId || !autoTagGroupIds.value.has(t.tagGroupId)
}).map((t: any) => t.id),
positionId: g.positionId || '', positionId: g.positionId || '',
} }
initEditFamily((g as any).originGood?.family ?? null) initEditFamily((g as any).originGood?.family ?? null)
@@ -884,7 +970,6 @@ async function handleEditSubmit() {
: undefined, : undefined,
countryId: Number(editForm.value.countryId), countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId), categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null, positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any) } as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload) if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
@@ -1326,15 +1411,7 @@ const groupedTagOptions = computed(() => {
return groups return groups
}) })
// ─── 派生标签:物流/工艺/印花数量组由系统按链接名称自动生成,表单中只读 ─── // ─── 派生标签已下沉到链接级(origin_good_tags):在「关联原产品」成员行内配置 ───
const isAutoTagGroup = (name: string) => /物流|工艺|位置|印花数量/.test(name)
const autoTagGroupIds = computed(() =>
new Set(allTagGroups.value.filter((g) => isAutoTagGroup(g.groupName)).map((g) => g.id)),
)
/** 当前商品的自动组标签(只读展示,来自其链接名称的解析结果) */
const editAutoTags = computed(() =>
((editGood.value as any)?.tags ?? []).filter((t: any) => autoTagGroupIds.value.has(t.tagGroupId)),
)
function toggleTagInSelection(tagId: string): void { function toggleTagInSelection(tagId: string): void {
const idx = selectedTagIds.value.indexOf(tagId) const idx = selectedTagIds.value.indexOf(tagId)
@@ -1959,9 +2036,6 @@ onMounted(() => loadAll())
<el-form-item label="图片"> <el-form-item label="图片">
<ImageUpload v-model="configForm.goodImage" label="上传图片" /> <ImageUpload v-model="configForm.goodImage" label="上传图片" />
</el-form-item> </el-form-item>
<el-form-item label="标签">
<div class="derived-tags-note">标签无需手动选择保存后由系统按链接名称自动解析印花数量 / 工艺 / 物流</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="configVisible = false">取消</el-button> <el-button @click="configVisible = false">取消</el-button>
@@ -2006,62 +2080,75 @@ onMounted(() => loadAll())
<!-- Edit Good Modal (direct edit no separate detail step) --> <!-- Edit Good Modal (direct edit no separate detail step) -->
<el-dialog v-model="editVisible" :title="cleanLinkName(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" :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">
{{ editGood.originGood.hasDetail ? '详情已同步' : '详情未同步' }}
</el-tag>
<span v-if="editGood.originGood.detailSyncedAt">
{{ new Date(editGood.originGood.detailSyncedAt).toLocaleString() }}
</span>
<span>SKU {{ editGood.originGood.variantCount || 0 }}</span>
<span>尺码 {{ editGood.originGood.sizeRowCount || 0 }}</span>
<span>包装 {{ editGood.originGood.packageRowCount || 0 }}</span>
</div>
</div>
<el-button
v-if="!editIsCustom"
type="primary"
plain
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<div v-if="!editIsCustom" class="edit-merged-box"> <div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title"> <div class="edit-merged-title">
关联原产品族成员 关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。"> <el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。展开成员可查看详情并修正标签。">
<el-icon><QuestionFilled /></el-icon> <el-icon><QuestionFilled /></el-icon>
</el-tooltip> </el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length + 1 }} 条链接</span> <span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length }} 条链接</span>
</div> </div>
<div class="edit-merged-list"> <div class="edit-merged-list">
<div class="edit-merged-item primary"> <template v-for="row in familyRows" :key="row.key">
<span class="edit-merged-tag"></span> <div class="edit-merged-item" :class="{ primary: row.role === 'primary' }">
<span class="edit-merged-name" :title="editGood?.originGood?.goodName ?? undefined">{{ cleanLinkName(editGood?.originGood?.goodName) }}</span> <el-icon
<span v-if="deriveLinkTagNames(editGood?.originGood?.goodName).length" class="member-chips"> class="member-expand-toggle"
<span v-for="c in deriveLinkTagNames(editGood?.originGood?.goodName)" :key="c" class="member-chip">{{ c }}</span> :class="{ 'is-expanded': expandedMemberKeys.has(row.key) }"
</span> @click="toggleMemberExpand(row.key)"
</div> ><ArrowDown /></el-icon>
<div v-for="m in editFamilyMembers" :key="m.id" class="edit-merged-item"> <span class="edit-merged-tag" :class="{ sub: row.role !== 'primary' }">{{ row.role === 'primary' ? '主' : '族' }}</span>
<span class="edit-merged-tag sub"></span> <span class="edit-merged-name" :title="row.goodName">{{ cleanLinkName(row.goodName) }}</span>
<span class="edit-merged-name" :title="m.goodName">{{ cleanLinkName(m.goodName) }}</span> <span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<span v-if="deriveLinkTagNames(m.goodName).length" class="member-chips"> <template v-if="row.tags.length">
<span v-for="c in deriveLinkTagNames(m.goodName)" :key="c" class="member-chip">{{ c }}</span> <span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</span> </template>
<div class="edit-merged-actions"> <template v-else>
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(m)">设为主链接</el-button> <span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview" :title="'按名称解析预览,展开可配置'">{{ c }}</span>
<el-button size="small" link type="danger" @click="removeFamilyMember(m)">移除出族</el-button> </template>
</span>
<span v-if="row.tagsManual" class="member-manual-flag" title="人工接管:自动同步不再覆盖该链接的标签">人工</span>
<div v-if="row.role !== 'primary'" class="edit-merged-actions">
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(row)">设为主链接</el-button>
<el-button size="small" link type="danger" @click="removeFamilyMember(row)">移除出族</el-button>
</div>
</div> </div>
</div> <div v-if="expandedMemberKeys.has(row.key)" class="member-detail-panel">
<div class="member-detail-grid">
<span class="md-label">SDS ID</span><span class="md-val">{{ row.sdsGoodId || '-' }}</span>
<span class="md-label">链接价格</span><span class="md-val">{{ row.goodPrice ? `¥${row.goodPrice}` : '-' }}</span>
<span class="md-label">SKU </span><span class="md-val">{{ row.variantCount }}</span>
<span class="md-label">物流备注</span><span class="md-val">{{ row.logisticsLabel || '-' }}</span>
<span class="md-label">工艺位置</span><span class="md-val">{{ row.craftLabel || '-' }}</span>
<span class="md-label">仓库</span><span class="md-val">{{ row.warehouseLabel || '-' }}</span>
<span class="md-label">原始名称</span><span class="md-val" :title="row.goodName">{{ row.goodName }}</span>
</div>
<div class="member-tags-edit">
<span class="md-label">标签</span>
<el-select
v-model="memberTagEdits[row.key]"
multiple filterable size="small"
placeholder="选择标签"
style="flex:1"
>
<el-option-group v-for="g in groupedTagOptions" :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 size="small" type="primary" :loading="memberTagSaving === row.key" @click="saveMemberTags(row)">保存标签</el-button>
<el-button v-if="row.tagsManual" size="small" :loading="memberTagSaving === row.key" @click="resetMemberTags(row)">恢复自动</el-button>
<el-button
v-if="row.role === 'primary'"
size="small"
:icon="Refresh"
:loading="detailSyncing"
@click="handleSyncOneDetail"
>同步详情</el-button>
</div>
<div class="derived-tags-note">
标签按链接名称自动解析印花数量 / 工艺 / 物流解析不对可直接修改保存后自动同步不再覆盖该链接恢复自动回到按名称派生
</div>
</div>
</template>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div> <div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div> <div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div> </div>
@@ -2087,15 +2174,6 @@ onMounted(() => loadAll())
<el-form-item label="分类"> <el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" /> <el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item> </el-form-item>
<el-form-item label="标签">
<div class="derived-tags-row derived-tags-readonly">
<template v-if="editAutoTags.length">
<el-tag v-for="t in editAutoTags" :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="基础价格"> <el-form-item v-if="editIsCustom" label="基础价格">
<el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" /> <el-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</el-form-item> </el-form-item>
@@ -2181,17 +2259,29 @@ onMounted(() => loadAll())
<el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column> <el-table-column label="操作" width="70"><template #default="{ $index }"><el-button link type="danger" @click="customContentForm.variants.splice($index, 1)">删除</el-button></template></el-table-column>
</el-table> </el-table>
</template> </template>
<el-table v-else :data="editVariants" border max-height="320"> <template v-else>
<el-table-column prop="sku" label="SKU" min-width="170" fixed /> <div class="sku-dims-note">价格由 物流 × 工艺 × 印花数量 × 尺码 × 颜色 决定族内同组合取最低价人工改价走族价格覆盖</div>
<el-table-column prop="sizeName" label="尺码" width="90" /> <el-table :data="editVariants" border max-height="320">
<el-table-column prop="colorName" label="颜色" width="100" /> <el-table-column prop="sku" label="SKU" min-width="170" fixed />
<el-table-column prop="price" label="价格" width="100" /> <el-table-column label="物流" width="110">
<el-table-column label="状态" width="90"> <template #default>{{ editLinkDims.logistics || '-' }}</template>
<template #default="{ row }"> </el-table-column>
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag> <el-table-column label="工艺" width="100">
</template> <template #default>{{ editLinkDims.crafts.join(' / ') || '-' }}</template>
</el-table-column> </el-table-column>
</el-table> <el-table-column label="印花数量" width="100">
<template #default>{{ editLinkDims.printCount || '-' }}</template>
</el-table-column>
<el-table-column prop="sizeName" label="尺码" width="90" />
<el-table-column prop="colorName" label="颜色" width="100" />
<el-table-column prop="price" label="价格" width="100" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '可用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" /> <el-empty v-else-if="!editDetailLoading" description="尚未同步商品详情" :image-size="60" />
@@ -2514,16 +2604,6 @@ onMounted(() => loadAll())
.origin-ref { display: flex; align-items: center; gap: 8px; } .origin-ref { display: flex; align-items: center; gap: 8px; }
.edit-og-ref {
display: flex; align-items: center; gap: 12px;
padding: 12px; background: #f5f7fa; border-radius: 8px;
}
.edit-og-img { width: 48px; height: 48px; border-radius: 6px; flex-shrink: 0; }
.edit-og-label { font-size: 11px; color: #909399; text-transform: uppercase; letter-spacing: 0.5px; }
.edit-og-name { font-weight: 600; font-size: 14px; margin-top: 2px; }
.edit-og-sub { color: #909399; font-size: 12px; margin-top: 2px; }
.edit-og-meta { flex: 1; min-width: 0; }
.edit-og-status { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; margin-top: 6px; color: #909399; font-size: 12px; }
.edit-merged-box { margin-top: 12px; } .edit-merged-box { margin-top: 12px; }
.edit-merged-title { .edit-merged-title {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: 4px;
@@ -2562,6 +2642,30 @@ onMounted(() => loadAll())
font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px; font-size: 11px; line-height: 16px; padding: 1px 7px; border-radius: 8px;
background: #ecf5ff; color: var(--el-color-primary); white-space: nowrap; 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 { .edit-merged-tag {
padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px; padding: 0 5px; border-radius: 4px; font-size: 11px; line-height: 18px;
background: var(--el-color-primary); color: #fff; flex-shrink: 0; background: var(--el-color-primary); color: #fff; flex-shrink: 0;
+17 -14
View File
@@ -130,28 +130,31 @@ pnpm --filter @inkreach/api backfill:product-families
- 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」 - 商品配置页布局不变(左树=官网商品、右树=原产品库分类平铺);配置弹窗保持原「合并同名」
勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族); 勾选流程,提交时**静默**把勾选链接与主链接归入同一族(无族自动成族);
- 编辑弹窗的「关联原产品(主源 + 副源)」区块改为「**关联原产品(族成员)**」: - 编辑弹窗的「**关联原产品**」区块:显示族编码与链接数,主/族成员行可**逐个展开**——
显示族编码与链接数、成员列表(`设为主链接` / `移除出族`)、搜索添加成员—— 查看成员详情(SDS ID / 链接价格 / SKU 数 / 物流备注 / 工艺位置 / 仓库 / 原始名称)、
操作直接作用于族(并集与价格矩阵随重算更新); **配置该链接的标签**(保存后人工接管;「恢复自动」回到按名称派生);成员行仍支持
`设为主链接` / `移除出族`,底部搜索添加成员——操作直接作用于族(并集与价格矩阵随重算更新);
- 编辑弹窗 SKU 表新增 **物流 / 工艺 / 印花数量** 三列:价格由
`物流 × 工艺 × 印花数量 × 尺码 × 颜色` 决定(族内同组合取最低价,人工改价走族价格覆盖);
- 人工改价/自动成族等族管理 API`/product-families/*`)保留,供脚本或后续界面使用。 - 人工改价/自动成族等族管理 API`/product-families/*`)保留,供脚本或后续界面使用。
**族派生标签(按链接名称自动解析2026-08 规则改版)** **链接级标签(自动派生 + 人工修正2026-08 规则改版)**
- 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),因此不再按 - 标签与**产品链接一一对应**(每条链接因印花数量/工艺/物流不同而价格不同),
族并集派生,而是按每条链接自身名称解析后写入该链接对应的商品; 落库在链接级 `origin_good_tags` 表(`manual` 标记人工/派生);商品的自动组标签是其
**主链接标签的镜像**
- 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端 - 解析规则(`apps/api/src/product-families/auto-tag-rules.ts`,与 admin 端
`utils/origin-name.ts#deriveLinkTagNames` 同构): `utils/origin-name.ts#deriveLinkTagNames` 同构):
- **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`组「印花数量」); - **印花数量**:名称含「双面印花」→ `双面印花`;否则含「单面印花」→ `单面印花`(组「印花数量」);
- **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」); - **工艺**:名称含「直喷」「不打印」「光板」→ 对应标签(可多个,组「印刷工艺」);
都不含 → 默认 `烫画` 都不含 → 默认 `烫画`
- **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中); - **物流**:含「不包邮」→ `不包邮`;否则含「包邮」→ `包邮`(组「物流渠道」,先判不包邮防子串误命中);
- 组名匹配「物流/工艺/位置/印花数量」的组视为**自动组**:每次族重算/成员变更/商品创建更新时 - 每次族重算/成员变更/商品创建更新时:未接管的 SDS 链接按名称刷新派生行;
同步;缺失的组与标签自动补建;旧的自动组标签(如「印刷位置」的单面印/双面印)会被剔除 **人工接管的链接(`tagsManual=true`)永不被覆盖**;商品镜像其链接的有效标签
- 后台商品表单**不再提供标签手输框**:编辑弹窗展示只读的自动标签胶囊,配置弹窗提示 缺失的组与标签自动补建;
「保存后由系统按链接名称自动解析」;后端同样剔除手动传入的自动组标签; - **人工修正入口**:编辑弹窗成员行展开 → 修改标签 → 保存标签(全量替换为人工行,
- **人工调节接口保留**`设为主链接` / `移除出族` / 搜索添加成员(`updateMembers` `PUT /origin-goods/:id/tags`);「恢复自动」清掉人工行回到派生(`DELETE /origin-goods/:id/tags`
价格改价(`/product-families/*/overrides`)——自动组织不对时可手动调整 - 其他分组(如风格类)不受影响,保持人工管理;自定义链接(无名称可解析)同样支持人工配置
- 其他分组(如风格类)不受影响,保持人工管理;
- 实测:`美国(包邮)…-DG001-单面印花``包邮 / 烫画 / 单面印花` - 实测:`美国(包邮)…-DG001-单面印花``包邮 / 烫画 / 单面印花`
`美国(不包邮光板)…-JSA002-不打印``不包邮 / 不打印 / 光板` `美国(不包邮光板)…-JSA002-不打印``不包邮 / 不打印 / 光板`
`美国(不包邮)…-DG501-双面印花``不包邮 / 烫画 / 双面印花` `美国(不包邮)…-DG501-双面印花``不包邮 / 烫画 / 双面印花`
+2 -1
View File
@@ -58,7 +58,7 @@ apps/api/
│ ├── tags/ # 标签 CRUD(受 JWT 保护) │ ├── tags/ # 标签 CRUD(受 JWT 保护)
│ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序) │ ├── tag-groups/ # 标签分组 CRUD(受 JWT 保护,含批量排序)
│ ├── positions/ # 坑位 CRUD(受 JWT 保护) │ ├── positions/ # 坑位 CRUD(受 JWT 保护)
│ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树,树叶子含族信息 │ ├── origin-goods/ # SDS 原始商品快照(只读分页 + 配置状态树 + 链接级标签人工接管
│ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules │ ├── product-families/ # 产品族(SPU 层):CRUD / auto-group / 成员管理 / 自定义成员 / 价格覆盖 / 重算 / 按链接名称派生标签(auto-tag-rules
│ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建 │ ├── goods/ # 商品 CRUD + 批量优先级 + 批量创建
│ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志 │ ├── sync/ # SDS 同步:分类 / 商品 / 同步日志
@@ -120,6 +120,7 @@ apps/api/
| `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT | | `/tag-groups/sort` `PATCH` | 批量更新分组排序 | JWT |
| `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT | | `/origin-goods` `GET` | SDS 原始商品快照分页 | JWT |
| `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT | | `/origin-goods/tree` `GET` | 配置状态树(叶子含 `familyId/familyName/familyCode/familyStale` | JWT |
| `/origin-goods/:id/tags` `GET/PUT/DELETE` | 链接级标签:查(含 manual 标记)/ 人工接管全量替换 / 恢复按名称自动派生;写入后镜像到名下商品 | JWT |
| `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT | | `/product-families` `GET/POST` | 产品族分页列表(`keyword` 匹配名称/编码)/ 建族(可直挂成员) | JWT |
| `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT | | `/product-families/auto-group` `POST` | 自动成族:按 SDS 分类(产品模型)聚合无族链接;`{apply:false}` 仅预览,`{apply:true}` 落库并逐族重算(幂等) | JWT |
| `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT | | `/product-families/:id` `GET/PATCH` | 族详情(成员+覆盖)/ 编辑 canonical 字段、`autoManaged`、主链接 | JWT |