revert(admin): restore original layout, replace primary/secondary source UI with family members

This commit is contained in:
yeuimu
2026-08-28 14:35:34 +08:00
parent 7b17fff86f
commit 353e51e871
10 changed files with 124 additions and 1091 deletions
+115 -120
View File
@@ -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,
FolderAdd, Aim, ArrowDown, QuestionFilled,
} from '@element-plus/icons-vue'
import type {
CategoryTree, Country, Tag, TagGroup, Good, GoodDetail,
@@ -257,13 +257,14 @@ const leftTreeData = computed(() => {
})
function buildRightTree(tree: OriginGoodsTreeResponse) {
function mapOgs(ogs: any[], bucketId: string): any[] {
return ogs.map((og: any) => ({
function mapCat(node: any): any {
const children = (node.children || []).map(mapCat)
const goods = (node.originGoods || []).map((og: any) => ({
id: 'og-' + og.id,
label: og.goodName,
isOG: true,
rawId: og.id,
parentId: bucketId,
parentId: 'rc-' + node.categoryId,
goodName: og.goodName,
goodImage: og.goodImage,
goodPrice: og.goodPrice,
@@ -280,56 +281,12 @@ function buildRightTree(tree: OriginGoodsTreeResponse) {
familyName: og.familyName ?? null,
familyStale: og.familyStale ?? null,
}))
}
/** 分类内按族分桶:族节点为父、链接为子;无族链接进「未入族」桶 */
function groupByFamily(ogs: any[], bucketPrefix: string): any[] {
const byFamily = new Map<string, { key: string; code: string | null; name: string | null; stale: boolean | null; ogs: any[] }>()
const ungrouped: any[] = []
for (const og of ogs) {
if (og.familyId) {
const entry = byFamily.get(og.familyId) ?? {
key: og.familyId, code: og.familyCode ?? null, name: og.familyName ?? null,
stale: og.familyStale ?? null, ogs: [] as any[],
}
entry.ogs.push(og)
byFamily.set(og.familyId, entry)
} else {
ungrouped.push(og)
}
}
const nodes: any[] = [...byFamily.values()].map((f) => ({
id: `fam-${f.key}`,
label: `${f.code ?? f.name ?? f.key}${f.ogs.length}`,
isFamily: true,
familyId: f.key,
familyCode: f.code,
familyStale: f.stale,
children: mapOgs(f.ogs, `fam-${f.key}`),
}))
if (ungrouped.length) {
nodes.push({
id: `fam-none-${bucketPrefix}`,
label: `未入族(${ungrouped.length}`,
isFamily: true,
familyId: null,
familyCode: null,
familyStale: null,
children: mapOgs(ungrouped, `fam-none-${bucketPrefix}`),
})
}
return nodes
}
function mapCat(node: any): any {
const children = (node.children || []).map(mapCat)
const familyNodes = groupByFamily(node.originGoods || [], node.categoryId)
return {
id: 'rc-' + node.categoryId,
label: node.categoryName,
configuredCount: node.configuredCount ?? 0,
totalCount: node.totalCount ?? 0,
children: [...children, ...familyNodes],
children: [...children, ...goods],
}
}
rightTreeData.value = tree.tree.map(mapCat)
@@ -484,7 +441,8 @@ 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)
@@ -675,34 +633,91 @@ function addCustomVariant() {
})
}
// ─── Edit family (产品族) ───
// ─── Edit family(编辑弹窗的族成员管理,替代旧主源/副源) ───
const editFamilyId = ref<string>('')
const editFamilyTouched = ref(false)
const editFamilyOptions = ref<Array<{ id: string; familyCode: string | null; familyName: string }>>([])
const editFamilyLoading = ref(false)
const editFamilyCode = ref<string | null>(null)
const editFamilyMembers = ref<Array<{ id: string; goodName: string; goodImage: string | null }>>([])
const familyMemberCount = ref<number | null>(null)
const editFamilyLoaded = ref(false)
const editFamilyAddKw = ref('')
const editFamilyCandidates = ref<Array<{ id: string; goodName: string }>>([])
function initEditFamily(family: { familyId?: string; familyCode?: string | null; familyName?: string } | null) {
function initEditFamily(family: { familyId?: string; familyCode?: string | null } | null) {
editFamilyId.value = family?.familyId ?? ''
editFamilyTouched.value = false
editFamilyOptions.value = family?.familyId
? [{ id: family.familyId, familyCode: family.familyCode ?? null, familyName: family.familyName ?? '' }]
: []
editFamilyCode.value = family?.familyCode ?? null
editFamilyMembers.value = []
familyMemberCount.value = null
editFamilyLoaded.value = false
editFamilyAddKw.value = ''
editFamilyCandidates.value = []
if (editFamilyId.value) loadEditFamily()
}
async function searchEditFamilies(q: string) {
if (!q) return
editFamilyLoading.value = true
async function loadEditFamily() {
if (!editFamilyId.value) return
try {
const res = await productFamiliesApi.list({ keyword: q, page: 1, pageSize: 30 })
editFamilyOptions.value = res.items.map((f) => ({ id: f.id, familyCode: f.familyCode, familyName: f.familyName }))
const f = await productFamiliesApi.detail(editFamilyId.value)
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 }))
} finally {
editFamilyLoading.value = false
editFamilyLoaded.value = true
}
}
function onEditFamilyChange(v: string | null | undefined) {
editFamilyTouched.value = true
editFamilyId.value = v ?? ''
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)
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 = [] }
}
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 || '加入失败')
}
}
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 || '移除失败')
}
}
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 || '切换失败')
}
}
async function openEdit(g: Good) {
@@ -846,10 +861,6 @@ async function handleEditSubmit() {
categoryId: Number(editForm.value.categoryId),
tagIds: editForm.value.tagIds.map(Number),
positionId: editForm.value.positionId ? Number(editForm.value.positionId) : null,
// 族变更:仅在用户改动过选择时提交(undefined = 不动,null = 脱离族)
...(editFamilyTouched.value
? { familyId: editFamilyId.value ? Number(editFamilyId.value) : null }
: {}),
} as any)
if (customPayload) await goodsApi.updateCustomGoodContent(editForm.value.id, customPayload)
ElMessage.success('更新成功')
@@ -1741,18 +1752,6 @@ onMounted(() => loadAll())
<el-image v-if="data.goodImage" :src="data.goodImage" fit="cover" class="og-thumb" />
<div v-else class="og-thumb og-thumb-placeholder" />
<span class="og-name">{{ data.label }}</span>
<el-tag
v-if="data.familyCode"
size="small"
type="info"
class="og-family-tag"
:title="data.familyStale ? '族待处理:上游已变化' : '所属产品族'"
>{{ data.familyCode }}</el-tag>
<span
v-if="data.familyStale"
class="og-badge og-badge--warn"
title="该链接所属族已锁定且上游数据变化,待人工处理"
>族待处理</span>
<span
v-if="data.configuredCount > 0"
class="og-badge og-badge--ok"
@@ -1789,12 +1788,8 @@ onMounted(() => loadAll())
@click.stop="openConfigFromRightTree(data)"
>配置</el-button>
</div>
<div v-else class="og-cat-node" :class="{ 'og-family-node': data.isFamily }">
<span class="og-cat-label">{{ data.isFamily ? '族' : '' }} {{ data.label }}</span>
<el-tag
v-if="data.isFamily && data.familyStale"
size="small" type="warning" class="og-family-stale"
>待处理</el-tag>
<div v-else class="og-cat-node">
<span>{{ data.label }}</span>
<span v-if="data.totalCount" class="og-cat-count">
<template v-if="data.configuredCount < data.totalCount">
{{ data.configuredCount }}/{{ data.totalCount }}
@@ -1905,12 +1900,12 @@ onMounted(() => loadAll())
<el-cascader v-if="mode === 'country' || !configDropTarget" v-model="configForm.cascaderCategory" :options="categoryCascader as any" :props="{ checkStrictly: true }" placeholder="请选择分类" @change="onConfigCascaderChange" style="width:100%" />
<el-tag v-else>{{ configDropTarget?.label }}</el-tag>
</el-form-item>
<el-form-item v-if="configSiblings.length" label="同族链接">
<el-form-item v-if="configSiblings.length" label="合并同名">
<div class="config-merge-box">
<div class="config-merge-tip">勾选同分类链接将与主链接一起归入同一产品族并集尺码/包装 + 五维价格矩阵主链接决定详情与跳转</div>
<div class="config-merge-tip">勾选同分类下同名不同工厂/仓库原产品合并为一个商品主源决定价格与详情</div>
<div class="config-merge-primary">
<el-radio-group v-model="configPrimaryId">
<el-radio :value="String(configOG.rawId)">链接{{ configOG.goodName }}</el-radio>
<el-radio :value="String(configOG.rawId)">{{ configOG.goodName }}</el-radio>
<el-radio v-for="s in checkedSiblingNodes" :key="s.rawId" :value="String(s.rawId)">{{ s.goodName }}</el-radio>
</el-radio-group>
</div>
@@ -2008,34 +2003,35 @@ onMounted(() => loadAll())
>同步详情</el-button>
</div>
<div v-if="!editIsCustom" class="edit-merged-box">
<div class="edit-merged-title">产品族合并新机制</div>
<div class="config-merge-tip" style="padding: 4px 0 8px">
多链接合并已由产品族承载同族链接自动合并尺码/包装并集与五维价格矩阵下方"所属族"可切换
旧的副源关联已废弃仅历史数据只读保留
<div class="edit-merged-title">
关联原产品族成员
<el-tooltip content="同族链接合并为一个商品:尺码/包装并集 + 价格矩阵;主链接决定详情与跳转。">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span v-if="editFamilyCode" class="edit-family-code"> {{ editFamilyCode }} · {{ familyMemberCount ?? editFamilyMembers.length + 1 }} 条链接</span>
</div>
<div class="edit-merged-list">
<div class="edit-merged-item primary">
<span class="edit-merged-tag"></span>
<span>{{ editGood?.originGood?.goodName }}</span>
</div>
<div v-for="m in editFamilyMembers" :key="m.id" class="edit-merged-item">
<span class="edit-merged-tag sub"></span>
<span>{{ m.goodName }}</span>
<el-button size="small" link type="primary" @click="promoteFamilyPrimary(m)">设为主链接</el-button>
<el-button size="small" link type="danger" @click="removeFamilyMember(m)">移除出族</el-button>
</div>
<div v-if="!editFamilyLoaded && editFamilyId" class="edit-family-loading">族成员加载中</div>
<div v-else-if="!editFamilyId" class="edit-family-loading">暂未成族配置合并时自动成族</div>
</div>
<el-select v-model="editFamilyAddKw" filterable remote :remote-method="searchFamilyCandidates"
placeholder="搜索原产品名称添加到族" clearable style="width:100%">
<el-option v-for="c in editFamilyCandidates" :key="c.id" :label="c.goodName" :value="String(c.id)"
@click="addFamilyMember(c)" />
</el-select>
</div>
<el-form v-loading="editDetailLoading" label-width="80px" style="margin-top: 16px">
<el-form-item label="名称"><el-input v-model="editForm.goodName" /></el-form-item>
<el-form-item label="所属族">
<el-select
:model-value="editFamilyId"
clearable
filterable
remote
placeholder="搜索族名称/编码切换,清空则脱离族"
:remote-method="searchEditFamilies"
:loading="editFamilyLoading"
style="width: 100%"
@change="onEditFamilyChange"
>
<el-option
v-for="f in editFamilyOptions"
:key="f.id"
:value="f.id"
:label="`${f.familyCode ? f.familyCode + ' · ' : ''}${f.familyName}`"
/>
</el-select>
</el-form-item>
<el-form-item label="图片">
<ImageUpload v-model="editForm.goodImage" label="上传图片" />
</el-form-item>
@@ -2447,9 +2443,6 @@ onMounted(() => loadAll())
background: linear-gradient(135deg, #f5f7fa, #e9ecef);
}
.og-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.og-family-tag { flex-shrink: 0; }
.og-family-node .og-cat-label { color: var(--el-color-primary); font-weight: 600; }
.og-family-stale { margin-left: 4px; }
.og-price { color: #909399; font-size: 12px; flex-shrink: 0; }
.og-badge {
font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 8px;
@@ -2496,6 +2489,8 @@ onMounted(() => loadAll())
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; }
.edit-merged-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.edit-merged-item {
display: flex; align-items: center; gap: 8px;