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
+210 -106
View File
@@ -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 { cleanLinkName, deriveLinkTagNames, truncateToProcess } from '@/utils/origin-name'
import { cleanLinkName, deriveLinkTagNames, linkDims, truncateToProcess } from '@/utils/origin-name'
import { productFamiliesApi } from '@/api/product-families'
const mode = ref<'category' | 'country' | 'global'>('category')
@@ -566,7 +566,7 @@ const editGood = ref<Good | GoodDetail | null>(null)
const originalOriginGoodId = ref<string>('')
const editForm = ref({
id: '', goodName: '', goodImage: '', countryId: '', cascaderCategory: [] as string[],
categoryId: '', tagIds: [] as string[], positionId: '',
categoryId: '', positionId: '',
})
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 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 editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
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) {
editFamilyId.value = family?.familyId ?? ''
@@ -671,24 +714,72 @@ function initEditFamily(family: { familyId?: string; familyCode?: string | 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)
const f = await productFamiliesApi.detail(editFamilyId.value) as any
editFamilyCode.value = f.familyCode
familyMemberCount.value = f._count.originGoods
const primaryId = f.primaryOriginGoodId ?? editGood.value?.originGoodId
editFamilyMembers.value = f.originGoods
.filter((m) => String(m.id) !== String(primaryId))
.map((m) => ({ id: String(m.id), goodName: m.goodName, goodImage: m.goodImage }))
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<string, string[]> = {}
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)
@@ -750,11 +841,6 @@ async function openEdit(g: Good) {
countryId: g.countryId,
cascaderCategory: findCategoryPath(allCategories.value, 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 || '',
}
initEditFamily((g as any).originGood?.family ?? null)
@@ -884,7 +970,6 @@ async function handleEditSubmit() {
: undefined,
countryId: Number(editForm.value.countryId),
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
@@ -1326,15 +1411,7 @@ const groupedTagOptions = computed(() => {
return groups
})
// ─── 派生标签:物流/工艺/印花数量组由系统按链接名称自动生成,表单中只读 ───
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)),
)
// ─── 派生标签已下沉到链接级(origin_good_tags):在「关联原产品」成员行内配置 ───
function toggleTagInSelection(tagId: string): void {
const idx = selectedTagIds.value.indexOf(tagId)
@@ -1959,9 +2036,6 @@ onMounted(() => loadAll())
<el-form-item label="图片">
<ImageUpload v-model="configForm.goodImage" label="上传图片" />
</el-form-item>
<el-form-item label="标签">
<div class="derived-tags-note">标签无需手动选择保存后由系统按链接名称自动解析印花数量 / 工艺 / 物流</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configVisible = false">取消</el-button>
@@ -2006,62 +2080,75 @@ onMounted(() => loadAll())
<!-- Edit Good Modal (direct edit no separate detail step) -->
<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 class="edit-merged-title">
关联原产品族成员
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。">
关联原产品
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。展开成员可查看详情并修正标签。">
<el-icon><QuestionFilled /></el-icon>
</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 class="edit-merged-list">
<div class="edit-merged-item primary">
<span class="edit-merged-tag"></span>
<span class="edit-merged-name" :title="editGood?.originGood?.goodName ?? undefined">{{ cleanLinkName(editGood?.originGood?.goodName) }}</span>
<span v-if="deriveLinkTagNames(editGood?.originGood?.goodName).length" class="member-chips">
<span v-for="c in deriveLinkTagNames(editGood?.originGood?.goodName)" :key="c" class="member-chip">{{ c }}</span>
</span>
</div>
<div v-for="m in editFamilyMembers" :key="m.id" class="edit-merged-item">
<span class="edit-merged-tag sub"></span>
<span class="edit-merged-name" :title="m.goodName">{{ cleanLinkName(m.goodName) }}</span>
<span v-if="deriveLinkTagNames(m.goodName).length" class="member-chips">
<span v-for="c in deriveLinkTagNames(m.goodName)" :key="c" class="member-chip">{{ c }}</span>
</span>
<div class="edit-merged-actions">
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(m)">设为主链接</el-button>
<el-button size="small" link type="danger" @click="removeFamilyMember(m)">移除出族</el-button>
<template v-for="row in familyRows" :key="row.key">
<div class="edit-merged-item" :class="{ primary: row.role === 'primary' }">
<el-icon
class="member-expand-toggle"
:class="{ 'is-expanded': expandedMemberKeys.has(row.key) }"
@click="toggleMemberExpand(row.key)"
><ArrowDown /></el-icon>
<span class="edit-merged-tag" :class="{ sub: row.role !== 'primary' }">{{ row.role === 'primary' ? '主' : '族' }}</span>
<span class="edit-merged-name" :title="row.goodName">{{ cleanLinkName(row.goodName) }}</span>
<span v-if="row.tags.length || deriveLinkTagNames(row.goodName).length" class="member-chips">
<template v-if="row.tags.length">
<span v-for="c in row.tags" :key="c.id" class="member-chip" :class="{ 'is-manual': c.manual }">{{ c.tagName }}</span>
</template>
<template v-else>
<span v-for="c in deriveLinkTagNames(row.goodName)" :key="c" class="member-chip is-preview" :title="'按名称解析预览,展开可配置'">{{ c }}</span>
</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 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-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
@@ -2087,15 +2174,6 @@ onMounted(() => loadAll())
<el-form-item label="分类">
<el-cascader v-model="editForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onEditCascaderChange" />
</el-form-item>
<el-form-item label="标签">
<div class="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-input v-model="customContentForm.goodPrice" placeholder="例如 28.00" />
</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>
</template>
<el-table v-else :data="editVariants" border max-height="320">
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
<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 v-else>
<div class="sku-dims-note">价格由 物流 × 工艺 × 印花数量 × 尺码 × 颜色 决定族内同组合取最低价人工改价走族价格覆盖</div>
<el-table :data="editVariants" border max-height="320">
<el-table-column prop="sku" label="SKU" min-width="170" fixed />
<el-table-column label="物流" width="110">
<template #default>{{ editLinkDims.logistics || '-' }}</template>
</el-table-column>
<el-table-column label="工艺" width="100">
<template #default>{{ editLinkDims.crafts.join(' / ') || '-' }}</template>
</el-table-column>
<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-tabs>
<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; }
.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-title {
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;
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;